1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, compute_exec, create_deployment, current_deployment,
44 delete_compute, delete_site, get_compute, get_daemon_config, get_deployment, get_site_config,
45 invalidate_cache, list_aliases, list_compute, list_deployments, list_sites, prune_delete,
46 prune_report, put_blob, put_compute, put_daemon_config, put_site_config, remove_alias,
47 rollback_daemon_config, scrub_blobs, set_alias, sql_exec, sql_query,
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, ServeInput,
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, router_with_fast};
168#[cfg(feature = "mcp")]
169mod mcp_http;
170#[cfg(feature = "handlers")]
171mod scheduler;
172mod serve_pipeline;
173pub use serve_pipeline::{http_redirect_router, FastServe};
174#[cfg(test)]
175mod hotpath_test;
176#[cfg(all(test, feature = "handlers"))]
177use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
178pub(crate) use serve_pipeline::{
179 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
180 BootstrapAttestation,
181};
182pub mod signer;
185mod srvmetrics;
186#[cfg(all(feature = "handlers", test))]
187use scheduler::run_scheduler_tick;
188#[cfg(feature = "handlers")]
189pub(crate) use scheduler::{
190 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
191};
192#[cfg(feature = "handlers")]
193use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
194#[cfg(feature = "handlers")]
195mod function_runtime;
196#[cfg(feature = "handlers")]
197pub(crate) use function_runtime::{
198 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
199 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
200 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
201 put_trigger_handler, webhook_ingress,
202};
203#[cfg(feature = "handlers")]
204mod stream;
205#[cfg(feature = "handlers")]
206mod workflow;
207pub use auth::{require_auth, Auth};
208#[cfg(feature = "http3")]
209pub use http3::{
210 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
211 Http3Error,
212};
213pub use limits::{ServerLimits, UploadGuard};
214#[cfg(feature = "oidc")]
215pub use oidc::{OidcConfig, OidcError, OidcVerifier};
216use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
217#[cfg(feature = "handlers")]
218pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
219#[cfg(feature = "handlers")]
220pub(crate) use workflow::{
221 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
222 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
223};
224pub use srvmetrics::{server_metrics, ServerMetrics};
227
228#[derive(Clone, Default)]
233pub struct HandlerRuntime {
234 #[cfg(feature = "handlers")]
235 inner: Option<Arc<HandlerRuntimeInner>>,
236}
237
238#[cfg(feature = "handlers")]
239struct HandlerRuntimeInner {
240 engine: boatramp_handlers::HandlerEngine,
241 async_drain_gate: Arc<tokio::sync::Semaphore>,
247 kv: Arc<dyn boatramp_core::kv::KvStore>,
248 storage: Arc<dyn boatramp_core::Storage>,
249 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
253 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
256 site_semaphores:
259 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
260 stream_semaphores:
264 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
265 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
268 metrics: metrics::Metrics,
271 logs: Arc<logs::LogStore>,
273 #[cfg(feature = "handlers")]
277 graphql_cache: graphql_cache::GraphqlCache,
278 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
284 max_blob_bytes: std::sync::OnceLock<u64>,
288 max_component_bytes: std::sync::OnceLock<u64>,
293 function_meter_locks:
297 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
298 function_semaphores:
301 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
302 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
307 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
311 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
319 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
324}
325
326pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
329
330impl HandlerRuntime {
331 pub fn disabled() -> Self {
333 Self::default()
334 }
335
336 #[cfg(feature = "handlers")]
342 pub fn new(
343 engine: boatramp_handlers::HandlerEngine,
344 kv: Arc<dyn boatramp_core::kv::KvStore>,
345 storage: Arc<dyn boatramp_core::Storage>,
346 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
347 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
348 ) -> Self {
349 let async_drain_slots = engine.async_max_concurrency().max(1);
352 Self {
353 inner: Some(Arc::new(HandlerRuntimeInner {
354 engine,
355 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
356 kv,
357 storage,
358 sql,
359 messaging,
360 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
361 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
362 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
363 metrics: metrics::Metrics::default(),
364 logs: Arc::new(logs::LogStore::default()),
365 #[cfg(feature = "handlers")]
366 graphql_cache: graphql_cache::GraphqlCache::default(),
367 cron_leader_gate: std::sync::OnceLock::new(),
368 max_blob_bytes: std::sync::OnceLock::new(),
369 max_component_bytes: std::sync::OnceLock::new(),
370 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
371 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
372 watch_provider: std::sync::OnceLock::new(),
373 provision_tier: std::sync::OnceLock::new(),
374 invoker: std::sync::OnceLock::new(),
375 federation_runner: std::sync::OnceLock::new(),
376 })),
377 }
378 }
379
380 #[cfg(feature = "handlers")]
383 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
384 self.inner.as_ref().and_then(|inner| inner.sql.clone())
385 }
386
387 #[cfg(feature = "handlers")]
391 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
392 self.inner
393 .as_ref()
394 .and_then(|inner| inner.invoker.get().cloned())
395 }
396
397 #[cfg(feature = "handlers")]
401 pub(crate) async fn introspect_subgraph_sdl(
402 &self,
403 deploy: &DeployStore,
404 project: boatramp_core::project::ProjectRef<'_>,
405 function: &boatramp_core::function::Function,
406 component: &str,
407 ) -> Result<String, function_runtime::SubgraphSdlError> {
408 match self.inner.as_ref() {
409 Some(inner) => {
410 function_runtime::introspect_service_sdl(
411 inner, deploy, project, function, component,
412 )
413 .await
414 }
415 None => Err(function_runtime::SubgraphSdlError::Unavailable),
416 }
417 }
418
419 #[cfg(feature = "handlers")]
425 pub fn set_invoker(&self, deploy: DeployStore) {
426 if let Some(inner) = self.inner.as_ref() {
427 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
428 deploy,
429 Arc::downgrade(inner),
430 ));
431 let _ = inner.invoker.set(invoker);
432 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
435 inner,
436 )));
437 let _ = inner.federation_runner.set(runner);
438 }
439 }
440
441 #[cfg(feature = "handlers")]
445 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
446 self.inner.as_ref().and_then(|inner| inner.sql.clone())
447 }
448
449 #[cfg(feature = "handlers")]
453 pub fn set_watch_provider(
454 &self,
455 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
456 ) {
457 if let Some(inner) = self.inner.as_ref() {
458 let _ = inner.watch_provider.set(provider);
459 }
460 }
461
462 #[cfg(feature = "handlers")]
466 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
467 if let Some(inner) = self.inner.as_ref() {
468 let _ = inner.provision_tier.set(tier);
469 }
470 }
471
472 #[cfg(feature = "handlers")]
476 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
477 if let Some(inner) = self.inner.as_ref() {
478 let _ = inner.max_blob_bytes.set(max_bytes);
479 }
480 }
481
482 #[cfg(feature = "handlers")]
485 pub fn set_max_component_bytes(&self, max_bytes: u64) {
486 if let Some(inner) = self.inner.as_ref() {
487 let _ = inner.max_component_bytes.set(max_bytes);
488 }
489 }
490
491 #[cfg(feature = "handlers")]
496 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
497 if let Some(inner) = self.inner.as_ref() {
498 let _ = inner.cron_leader_gate.set(gate);
499 }
500 }
501
502 #[cfg(feature = "handlers")]
509 async fn precheck_activation(
510 &self,
511 deploy: &DeployStore,
512 manifest: &Manifest,
513 site_config: Option<&SiteConfig>,
514 ) -> Result<(), String> {
515 let Some(inner) = self.inner.as_ref() else {
516 return Ok(());
517 };
518 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
521 return Ok(());
522 }
523 let site_handlers = site_config
525 .and_then(|c| c.handlers.as_ref())
526 .filter(|h| h.enabled)
527 .ok_or_else(|| {
528 "deployment ships handlers/consumers but the site has them disabled".to_string()
529 })?;
530 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
531
532 let sync_ceiling = inner.engine.sync_timeout_ms();
538 let async_ceiling = inner.engine.async_timeout_ms();
539 if let Some(ms) = site_handlers.max_timeout_ms {
540 if u64::from(ms) > sync_ceiling {
541 tracing::warn!(
542 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
543 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
544 applies only to async calls (?mode=async / triggers), capped at \
545 async_max_timeout_ms={async_ceiling}"
546 );
547 }
548 }
549
550 for handler in &manifest.config.handlers {
552 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
553 if u64::from(ms) > sync_ceiling {
554 let route = &handler.route;
555 tracing::warn!(
556 "route {route:?} declares limits.timeout_ms={ms}, above \
557 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
558 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
559 (?mode=async / a queue trigger / a #[consumer]), capped at \
560 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
561 that isn't possible — move the work to the async lane"
562 );
563 }
564 }
565 if !handler.streaming {
570 if let Some(entry) = manifest.files.get(&handler.component) {
571 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
572 if crate::function_api::component_declares_streaming_route(
573 &bytes,
574 &handler.route,
575 ) {
576 let route = &handler.route;
577 tracing::warn!(
578 "route {route:?} is a streaming handler (#[handler(stream)]) but \
579 its config lacks streaming = true: it will run on the sync request \
580 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
581 streaming = true so it serves on the dedicated streaming lane (its \
582 own concurrency budget + a much larger wall-clock)."
583 );
584 }
585 }
586 }
587 }
588 if let Some(entry) = manifest.files.get(&handler.component) {
592 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
593 let unmet = crate::function_api::unmet_requires(&bytes);
594 if !unmet.is_empty() {
595 return Err(format!(
596 "route {:?} [{}] requires capabilities this host does not implement: \
597 {}. Upgrade boatramp or enable those features — see `boatramp \
598 capabilities`.",
599 handler.route,
600 handler.methods.join(","),
601 unmet.join(", ")
602 ));
603 }
604 }
605 }
606 precheck_component(
607 deploy,
608 manifest,
609 site_handlers,
610 inner,
611 max_component,
612 &handler.imports,
613 &handler.component,
614 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
617 false,
618 )
619 .await?;
620 }
621 for consumer in &manifest.config.consumers {
622 precheck_component(
623 deploy,
624 manifest,
625 site_handlers,
626 inner,
627 max_component,
628 &consumer.imports,
629 &consumer.component,
630 &format!("consumer {:?}", consumer.topic),
631 true,
632 )
633 .await?;
634 }
635 Ok(())
636 }
637
638 #[cfg(not(feature = "handlers"))]
639 async fn precheck_activation(
640 &self,
641 _deploy: &DeployStore,
642 _manifest: &Manifest,
643 _site_config: Option<&SiteConfig>,
644 ) -> Result<(), String> {
645 Ok(())
646 }
647}
648
649#[derive(Default, Clone)]
655pub struct ServerOptions {
656 pub limits: ServerLimits,
658 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
661 pub default_site: Option<String>,
664 pub implicit_routing: bool,
670 pub protect_previews: bool,
675 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
679 pub issuer: Option<Arc<dyn Signer>>,
683 pub bootstrap_secret: Option<String>,
688 pub bootstrap_attestation: Option<String>,
694 pub mesh_control: Option<Arc<dyn MeshControl>>,
698 pub cors_allowed_origins: Vec<String>,
708 #[cfg(feature = "oidc")]
711 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
712 pub posture: boatramp_core::security::SecurityPosture,
716 pub served_over_tls: bool,
721 pub pop_origin: Option<String>,
728 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
732 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
736 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
740 #[cfg(feature = "console")]
744 pub console: Option<console::ConsoleMount>,
745}
746
747#[derive(Clone, Copy)]
751struct ServedOverTls(bool);
752
753#[derive(Clone, Copy, Default)]
758struct ImplicitRouting(bool);
759
760const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
772
773pub struct DaemonRuntime {
774 baseline: boatramp_core::daemon_config::ConfigBaseline,
775 state: std::sync::RwLock<DaemonState>,
776 reload: tokio::sync::Notify,
779}
780
781struct DaemonState {
782 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
783 generation: Option<String>,
784}
785
786pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
791 #[cfg(feature = "console")]
795 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
796 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
797 None => (false, None, None),
798 };
799 #[cfg(not(feature = "console"))]
800 let (console_enabled, console_host, console_path) = (false, None, None);
801 boatramp_core::daemon_config::ConfigBaseline {
802 default_site: options.default_site.clone(),
803 protect_previews: options.protect_previews,
804 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
805 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
806 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
807 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
808 compute_vcpus: 0,
809 compute_mem_mib: 0,
810 console_enabled,
811 console_host,
812 console_path,
813 max_upload_ceiling: options.posture.max_upload_bytes,
814 max_concurrent_uploads_ceiling: None,
815 posture: options.posture,
816 }
817}
818
819impl DaemonRuntime {
820 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
824 let effective =
825 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
826 Self {
827 baseline,
828 state: std::sync::RwLock::new(DaemonState {
829 effective,
830 generation: None,
831 }),
832 reload: tokio::sync::Notify::new(),
833 }
834 }
835
836 pub fn notify_reload(&self) {
840 self.reload.notify_one();
841 }
842
843 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
845 self.state
846 .read()
847 .expect("daemon config lock")
848 .effective
849 .clone()
850 }
851
852 pub fn generation(&self) -> Option<String> {
855 self.state
856 .read()
857 .expect("daemon config lock")
858 .generation
859 .clone()
860 }
861
862 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
864 &self.baseline
865 }
866
867 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
870 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
871 let generation = deploy.daemon_config_generation().await?;
872 let effective = Arc::new(cfg.resolve(&self.baseline));
873 *self.state.write().expect("daemon config lock") = DaemonState {
874 effective,
875 generation,
876 };
877 Ok(())
878 }
879}
880
881#[derive(Clone, Copy, Default)]
884struct PreviewPolicy {
885 protect: bool,
886}
887
888#[derive(Clone, Default)]
893struct Issuer(Option<Arc<dyn Signer>>);
894
895#[derive(Clone, Default)]
900struct BootstrapGate(Option<Arc<BootstrapInner>>);
901
902struct BootstrapInner {
903 secret_hash: String,
906 lock: tokio::sync::Mutex<()>,
909}
910
911impl BootstrapGate {
912 fn new(secret: Option<&str>) -> Self {
913 Self(secret.filter(|s| !s.is_empty()).map(|s| {
914 Arc::new(BootstrapInner {
915 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
916 lock: tokio::sync::Mutex::new(()),
917 })
918 }))
919 }
920}
921
922#[async_trait::async_trait]
926pub trait MeshControl: Send + Sync {
927 async fn admit(
935 &self,
936 mesh_pubkey_hex: &str,
937 jti: &str,
938 possession_proof: &[u8],
939 proof_iat: u64,
940 now: u64,
941 advertise_addr: Option<&str>,
942 ) -> Result<JoinOutcome, String>;
943
944 async fn rotate_key(&self) -> Result<String, String>;
948
949 async fn revoke(&self, node: u64) -> Result<(), String>;
953
954 async fn members(&self) -> Result<Vec<MeshMember>, String>;
958
959 async fn promote(&self, node: u64) -> Result<(), String>;
962}
963
964pub enum JoinOutcome {
966 Admitted {
969 members: Vec<String>,
971 addrs: std::collections::BTreeMap<u64, String>,
973 },
974 TokenSpent,
976 ProofInvalid,
978 Revoked,
981}
982
983#[derive(Debug, Clone, Serialize)]
985pub struct MeshMember {
986 pub node: u64,
988 pub voter: bool,
990 pub caught_up: bool,
992 pub leader: bool,
994 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub addr: Option<String>,
999}
1000
1001#[derive(Clone, Default)]
1004struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1005
1006#[cfg(feature = "oidc")]
1008#[derive(Clone, Default)]
1009struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1010
1011#[cfg(feature = "oidc")]
1014const EXCHANGE_TTL_SECS: u64 = 3600;
1015
1016use boatramp_core::time::now_unix;
1017
1018#[derive(Clone)]
1020struct CorsState(Arc<Vec<String>>);
1021
1022const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1024const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1027const CORS_MAX_AGE: &str = "600";
1029
1030fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1034 allowed.iter().any(|a| a == "*" || a == origin)
1035}
1036
1037async fn cors(
1044 State(allowed): State<CorsState>,
1045 request: Request,
1046 next: axum::middleware::Next,
1047) -> Response {
1048 let origin = request
1049 .headers()
1050 .get(header::ORIGIN)
1051 .and_then(|v| v.to_str().ok())
1052 .filter(|o| cors_origin_allowed(&allowed.0, o))
1053 .map(str::to_string);
1054 let is_preflight = request.method() == Method::OPTIONS
1056 && request
1057 .headers()
1058 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1059 if is_preflight {
1060 let allow_headers = request
1062 .headers()
1063 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1064 .and_then(|v| v.to_str().ok())
1065 .map(str::to_string)
1066 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1067 let mut response = Response::new(Body::empty());
1068 *response.status_mut() = StatusCode::NO_CONTENT;
1069 if let Some(origin) = origin {
1070 let headers = response.headers_mut();
1071 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1072 set_header(headers, header::VARY, "Origin");
1073 set_header(
1074 headers,
1075 header::ACCESS_CONTROL_ALLOW_METHODS,
1076 CORS_ALLOW_METHODS,
1077 );
1078 set_header(
1079 headers,
1080 header::ACCESS_CONTROL_ALLOW_HEADERS,
1081 &allow_headers,
1082 );
1083 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1084 }
1085 return response;
1086 }
1087 let mut response = next.run(request).await;
1088 if let Some(origin) = origin {
1089 let headers = response.headers_mut();
1090 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1091 if let Ok(value) = HeaderValue::from_str("Origin") {
1094 headers.append(header::VARY, value);
1095 }
1096 }
1097 response
1098}
1099
1100const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1105
1106#[derive(Debug, thiserror::Error)]
1108pub enum ServeError {
1109 #[error("server I/O: {0}")]
1111 Io(#[from] std::io::Error),
1112}
1113
1114pub async fn serve(
1117 addr: SocketAddr,
1118 deploy: DeployStore,
1119 auth: Auth,
1120 handlers: HandlerRuntime,
1121) -> Result<(), ServeError> {
1122 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1123}
1124
1125pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1135 if let Err(err) = stream.set_nodelay(true) {
1136 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1137 }
1138}
1139
1140pub async fn serve_with(
1142 addr: SocketAddr,
1143 deploy: DeployStore,
1144 auth: Auth,
1145 handlers: HandlerRuntime,
1146 options: ServerOptions,
1147) -> Result<(), ServeError> {
1148 let tcp = tokio::net::TcpListener::bind(addr).await?;
1149 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1150 let splice_ctx = splice::SpliceCtx {
1156 deploy: deploy.clone(),
1157 posture: options.posture,
1158 daemon: options.daemon_runtime.clone(),
1159 };
1160 #[cfg(feature = "handlers")]
1163 let scheduler = handlers.spawn_scheduler(deploy.clone());
1164 let gateway_prober = gateway::spawn_active_health_prober();
1168 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1171
1172 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1176 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1180 shutdown_signal().await;
1181 let _ = signalled_tx.send(true);
1182 });
1183 let signalled = {
1184 let mut rx = signalled_rx;
1185 async move {
1186 let _ = rx.wait_for(|fired| *fired).await;
1187 }
1188 };
1189 let result = serve_with_drain_deadline(
1190 async move { server.await.map_err(ServeError::from) },
1191 signalled,
1192 DRAIN_DEADLINE,
1193 )
1194 .await;
1195 #[cfg(feature = "handlers")]
1197 if let Some(handle) = scheduler {
1198 handle.abort();
1199 }
1200 gateway_prober.abort();
1201 result
1202}
1203
1204async fn serve_with_drain_deadline<Srv, Sig>(
1209 server: Srv,
1210 signalled: Sig,
1211 deadline: Duration,
1212) -> Result<(), ServeError>
1213where
1214 Srv: Future<Output = Result<(), ServeError>>,
1215 Sig: Future<Output = ()>,
1216{
1217 tokio::pin!(server);
1218 let drain_cap = async move {
1219 signalled.await;
1220 tokio::time::sleep(deadline).await;
1221 };
1222 tokio::select! {
1223 result = &mut server => result,
1224 _ = drain_cap => {
1225 tracing::warn!(
1226 deadline_s = deadline.as_secs(),
1227 "drain deadline exceeded; forcing shutdown with requests still in flight"
1228 );
1229 Ok(())
1230 }
1231 }
1232}
1233
1234pub async fn shutdown_signal() {
1237 let ctrl_c = async {
1238 let _ = tokio::signal::ctrl_c().await;
1239 };
1240 #[cfg(unix)]
1241 let terminate = async {
1242 if let Ok(mut sig) =
1243 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1244 {
1245 sig.recv().await;
1246 }
1247 };
1248 #[cfg(not(unix))]
1249 let terminate = std::future::pending::<()>();
1250
1251 tokio::select! {
1252 _ = ctrl_c => {}
1253 _ = terminate => {}
1254 }
1255 tracing::info!("shutdown signal received; draining");
1256}
1257
1258async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1262 match daemon.generation() {
1263 Some(gen) => format!("ok gen={gen}"),
1264 None => "ok".to_string(),
1265 }
1266}
1267
1268async fn readyz(State(deploy): State<DeployStore>) -> Response {
1270 match deploy.ready().await {
1271 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1272 Err(err) => {
1273 tracing::warn!(error = %err, "readiness probe failed");
1274 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1275 }
1276 }
1277}
1278
1279#[derive(Clone)]
1284pub struct RequestId(pub String);
1285
1286fn request_id_for(headers: &HeaderMap) -> String {
1289 if let Some(id) = headers
1290 .get("x-request-id")
1291 .and_then(|v| v.to_str().ok())
1292 .map(str::trim)
1293 .filter(|s| !s.is_empty())
1294 {
1295 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1296 }
1297 use std::sync::atomic::{AtomicU64, Ordering};
1298 static SEQ: AtomicU64 = AtomicU64::new(0);
1299 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1300 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1301}
1302
1303struct AccessLog {
1307 request_id: String,
1308 method: Method,
1309 path: String,
1310 host: String,
1311 client: String,
1312 status: u16,
1313 encoding: String,
1315 start: std::time::Instant,
1316 bytes: std::sync::atomic::AtomicU64,
1317}
1318
1319impl Drop for AccessLog {
1320 fn drop(&mut self) {
1321 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1322 srvmetrics::server_metrics().record_request(self.status, bytes);
1325 tracing::info!(
1326 target: "boatramp::access",
1327 request_id = %self.request_id,
1328 method = %self.method,
1329 path = %self.path,
1330 host = %self.host,
1331 client = %self.client,
1332 status = self.status,
1333 bytes = bytes,
1334 encoding = %self.encoding,
1335 cache_result = srvmetrics::cache_result(self.status),
1336 elapsed_ms = self.start.elapsed().as_millis() as u64,
1337 "request"
1338 );
1339 }
1340}
1341
1342pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1348 let request_id = request_id_for(request.headers());
1349 request
1350 .extensions_mut()
1351 .insert(RequestId(request_id.clone()));
1352 request_id
1353}
1354
1355pub(crate) struct AccessLogCtx {
1360 request_id: String,
1361 method: Method,
1362 path: String,
1363 host: String,
1364 client: String,
1365 start: std::time::Instant,
1366}
1367
1368impl AccessLogCtx {
1369 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1375 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1376 return None;
1377 }
1378 Some(Self {
1379 request_id,
1380 method: request.method().clone(),
1381 path: request.uri().path().to_string(),
1382 host: request
1383 .headers()
1384 .get(header::HOST)
1385 .and_then(|value| value.to_str().ok())
1386 .or_else(|| request.uri().host()) .unwrap_or("-")
1388 .to_string(),
1389 client: request
1390 .extensions()
1391 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1392 .map(|info| info.0.ip().to_string())
1393 .unwrap_or_else(|| "-".to_string()),
1394 start: std::time::Instant::now(),
1395 })
1396 }
1397
1398 pub(crate) fn finish(self, response: Response) -> Response {
1402 let encoding = response
1403 .headers()
1404 .get(header::CONTENT_ENCODING)
1405 .and_then(|v| v.to_str().ok())
1406 .unwrap_or("identity")
1407 .to_string();
1408 let log = AccessLog {
1409 request_id: self.request_id,
1410 method: self.method,
1411 path: self.path,
1412 host: self.host,
1413 client: self.client,
1414 status: response.status().as_u16(),
1415 encoding,
1416 start: self.start,
1417 bytes: std::sync::atomic::AtomicU64::new(0),
1418 };
1419 let (parts, body) = response.into_parts();
1420 let counted = body.into_data_stream().map(move |chunk| {
1421 if let Ok(bytes) = &chunk {
1422 log.bytes
1423 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1424 }
1425 chunk
1426 });
1427 Response::from_parts(parts, Body::from_stream(counted))
1428 }
1429}
1430
1431async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1436 let request_id = assign_request_id(&mut request);
1437 match AccessLogCtx::capture(&request, request_id) {
1438 None => next.run(request).await,
1439 Some(ctx) => ctx.finish(next.run(request).await),
1440 }
1441}
1442
1443fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1445 req_headers
1446 .get(header::IF_NONE_MATCH)
1447 .and_then(|value| value.to_str().ok())
1448 .is_some_and(|value| {
1449 value
1450 .split(',')
1451 .map(str::trim)
1452 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1453 })
1454}
1455
1456fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1457 if let Ok(value) = HeaderValue::from_str(value) {
1458 headers.insert(name, value);
1459 }
1460}
1461
1462fn not_found() -> Response {
1463 (StatusCode::NOT_FOUND, "not found\n").into_response()
1464}
1465
1466fn redirect(status: u16, location: &str) -> Response {
1467 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1468 match HeaderValue::from_str(location) {
1469 Ok(location) => {
1470 let mut headers = HeaderMap::new();
1471 headers.insert(header::LOCATION, location);
1472 (status, headers).into_response()
1473 }
1474 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1475 }
1476}
1477
1478fn deploy_error_response(err: DeployError) -> Response {
1480 let status = match &err {
1481 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1482 StatusCode::NOT_FOUND
1483 }
1484 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1485 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1486 DeployError::Conflict(_) => StatusCode::CONFLICT,
1488 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1490 _ => StatusCode::INTERNAL_SERVER_ERROR,
1491 };
1492 tracing::warn!(error = %err, "request failed");
1493 (status, format!("{err}\n")).into_response()
1494}
1495
1496fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1501 boatramp_core::project::validate_resource_name(kind, value)
1502 .err()
1503 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1504}
1505
1506#[cfg(test)]
1507mod drain_tests {
1508 use super::*;
1509
1510 #[tokio::test]
1511 async fn deadline_forces_shutdown_after_signal() {
1512 let server = std::future::pending::<Result<(), ServeError>>();
1515 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1517 assert!(result.is_ok());
1518 }
1519
1520 #[tokio::test]
1521 async fn server_finishing_first_wins() {
1522 let server = async { Ok(()) };
1525 let signalled = std::future::pending::<()>();
1526 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1527 assert!(result.is_ok());
1528 }
1529
1530 #[tokio::test]
1531 async fn deadline_does_not_trip_before_signal() {
1532 let server = async {
1536 tokio::time::sleep(Duration::from_millis(40)).await;
1537 Err(ServeError::Io(std::io::Error::other("server error")))
1538 };
1539 let signalled = std::future::pending::<()>();
1540 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1541 assert!(result.is_err());
1542 }
1543}
1544
1545#[cfg(all(test, feature = "handlers"))]
1546mod tests {
1547 use super::*;
1548 use boatramp_core::cose::{LocalSigner, TokenAlg};
1549 use boatramp_core::project::ProjectRef;
1550
1551 #[test]
1552 fn query_string_parses_and_url_decodes() {
1553 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1554 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1555 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")); }
1559
1560 #[test]
1561 fn cookie_header_parses_pairs() {
1562 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1563 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1564 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1565 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1566 }
1567
1568 #[test]
1569 fn apply_vary_merges_without_duplicates() {
1570 let base = (StatusCode::OK, "x").into_response();
1571 let r = apply_vary(base, &["accept-language".into()]);
1572 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1573 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1575 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1576 assert!(v.contains("accept-language") && v.contains("cookie"));
1577 assert_eq!(v.matches("accept-language").count(), 1);
1578 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1580 assert!(plain.headers().get(header::VARY).is_none());
1581 }
1582
1583 #[tokio::test]
1587 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1588 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1589 let public = keys.public_key();
1590
1591 let resp = create_join_token(
1593 Extension(Issuer(Some(keys.clone()))),
1594 Json(CreateJoinTokenRequest {
1595 ttl_secs: Some(600),
1596 }),
1597 )
1598 .await;
1599 assert_eq!(resp.status(), StatusCode::CREATED);
1600 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1601 .await
1602 .unwrap();
1603 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1604 let token = parsed["token"].as_str().unwrap();
1605 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1606 assert!(!jti.is_empty());
1607
1608 let no_issuer = create_join_token(
1610 Extension(Issuer(None)),
1611 Json(CreateJoinTokenRequest { ttl_secs: None }),
1612 )
1613 .await;
1614 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1615 }
1616
1617 #[tokio::test]
1622 async fn function_write_path_deploy_rollback_alias_remove() {
1623 use boatramp_core::function::Lifecycle;
1624 use boatramp_core::kv::MemoryKv;
1625 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1626
1627 struct FakeStorage {
1630 present: bool,
1631 }
1632 #[async_trait::async_trait]
1633 impl Storage for FakeStorage {
1634 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1635 Err(StorageError::NotFound(String::new()))
1636 }
1637 async fn get_range(
1638 &self,
1639 _: &str,
1640 _: u64,
1641 _: Option<u64>,
1642 ) -> Result<GetObject, StorageError> {
1643 Err(StorageError::NotFound(String::new()))
1644 }
1645 async fn put(
1646 &self,
1647 _: &str,
1648 _: ByteStream,
1649 _: PutMeta,
1650 ) -> Result<ObjectMeta, StorageError> {
1651 Err(StorageError::unsupported("fake"))
1652 }
1653 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1654 if self.present {
1655 Ok(ObjectMeta {
1656 key: key.to_string(),
1657 ..Default::default()
1658 })
1659 } else {
1660 Err(StorageError::NotFound(key.to_string()))
1661 }
1662 }
1663 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1664 Ok(())
1665 }
1666 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1667 Ok(Vec::new())
1668 }
1669 }
1670
1671 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1672 let status = resp.status();
1673 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1674 .await
1675 .unwrap();
1676 let value = if bytes.is_empty() {
1677 serde_json::Value::Null
1678 } else {
1679 serde_json::from_slice(&bytes).unwrap()
1680 };
1681 (status, value)
1682 }
1683
1684 let deploy = DeployStore::new(
1685 Arc::new(FakeStorage { present: true }),
1686 Arc::new(MemoryKv::new()),
1687 );
1688 let v1 = "a".repeat(64);
1689 let v2 = "b".repeat(64);
1690
1691 let (st, body) = body_json(
1693 deploy_function(
1694 State(deploy.clone()),
1695 axum::extract::Extension(crate::ProjectContext::default()),
1696 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1697 axum::extract::Query(DeployFunctionQuery::default()),
1698 Path("greeter".to_string()),
1699 Json(FunctionUpsert {
1700 component: v1.clone(),
1701 config: Default::default(),
1702 lifecycle: Lifecycle::Independent,
1703 }),
1704 )
1705 .await,
1706 )
1707 .await;
1708 assert_eq!(st, StatusCode::OK);
1709 assert_eq!(body["active"], v1);
1710
1711 let (_, body) = body_json(
1713 deploy_function(
1714 State(deploy.clone()),
1715 axum::extract::Extension(crate::ProjectContext::default()),
1716 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1717 axum::extract::Query(DeployFunctionQuery::default()),
1718 Path("greeter".to_string()),
1719 Json(FunctionUpsert {
1720 component: v2.clone(),
1721 config: Default::default(),
1722 lifecycle: Lifecycle::Independent,
1723 }),
1724 )
1725 .await,
1726 )
1727 .await;
1728 assert_eq!(body["active"], v2);
1729 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1730
1731 let (st, body) = body_json(
1733 rollback_function(
1734 State(deploy.clone()),
1735 axum::extract::Extension(crate::ProjectContext::default()),
1736 Path("greeter".to_string()),
1737 Json(RollbackBody { to: v1.clone() }),
1738 )
1739 .await,
1740 )
1741 .await;
1742 assert_eq!(st, StatusCode::OK);
1743 assert_eq!(body["active"], v1);
1744
1745 let resp = rollback_function(
1747 State(deploy.clone()),
1748 axum::extract::Extension(crate::ProjectContext::default()),
1749 Path("greeter".to_string()),
1750 Json(RollbackBody { to: "c".repeat(64) }),
1751 )
1752 .await;
1753 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1754
1755 let (st, body) = body_json(
1757 alias_function(
1758 State(deploy.clone()),
1759 axum::extract::Extension(crate::ProjectContext::default()),
1760 Path(("greeter".to_string(), "prod".to_string())),
1761 Json(AliasBody {
1762 version: v2.clone(),
1763 }),
1764 )
1765 .await,
1766 )
1767 .await;
1768 assert_eq!(st, StatusCode::OK);
1769 assert_eq!(body["aliases"]["prod"], v2);
1770
1771 let (st, _) = body_json(
1773 remove_function(
1774 State(deploy.clone()),
1775 axum::extract::Extension(crate::ProjectContext::default()),
1776 Path("greeter".to_string()),
1777 )
1778 .await,
1779 )
1780 .await;
1781 assert_eq!(st, StatusCode::NO_CONTENT);
1782 assert!(deploy
1783 .get_function(ProjectRef::DEFAULT, "greeter")
1784 .await
1785 .unwrap()
1786 .is_none());
1787
1788 let empty = DeployStore::new(
1790 Arc::new(FakeStorage { present: false }),
1791 Arc::new(MemoryKv::new()),
1792 );
1793 let resp = deploy_function(
1794 State(empty),
1795 axum::extract::Extension(crate::ProjectContext::default()),
1796 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1797 axum::extract::Query(DeployFunctionQuery::default()),
1798 Path("orphan".to_string()),
1799 Json(FunctionUpsert {
1800 component: v1.clone(),
1801 config: Default::default(),
1802 lifecycle: Lifecycle::default(),
1803 }),
1804 )
1805 .await;
1806 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1807 }
1808
1809 struct StubControl {
1813 admits: std::sync::Mutex<Vec<(String, String)>>,
1814 respond: StubJoin,
1815 }
1816 #[derive(Clone, Copy)]
1817 enum StubJoin {
1818 Admit,
1819 Spent,
1820 Invalid,
1821 Revoked,
1822 }
1823
1824 #[async_trait::async_trait]
1825 impl MeshControl for StubControl {
1826 async fn admit(
1827 &self,
1828 mesh_pubkey_hex: &str,
1829 jti: &str,
1830 _proof: &[u8],
1831 _proof_iat: u64,
1832 _now: u64,
1833 _advertise_addr: Option<&str>,
1834 ) -> Result<JoinOutcome, String> {
1835 self.admits
1836 .lock()
1837 .unwrap()
1838 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1839 Ok(match self.respond {
1840 StubJoin::Admit => JoinOutcome::Admitted {
1841 members: vec!["signed-member".to_string()],
1842 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1843 },
1844 StubJoin::Spent => JoinOutcome::TokenSpent,
1845 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1846 StubJoin::Revoked => JoinOutcome::Revoked,
1847 })
1848 }
1849 async fn rotate_key(&self) -> Result<String, String> {
1850 Ok("cafe".to_string())
1851 }
1852 async fn revoke(&self, _node: u64) -> Result<(), String> {
1853 Ok(())
1854 }
1855 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1856 Ok(Vec::new())
1857 }
1858 async fn promote(&self, _node: u64) -> Result<(), String> {
1859 Ok(())
1860 }
1861 }
1862
1863 #[tokio::test]
1867 async fn cluster_join_dispatches_and_maps_outcomes() {
1868 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1869 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1870 let auth = Auth::with_key(keys.public_key(), kv);
1871 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1872 let req = |proof: &str| JoinRequest {
1873 token: token.clone(),
1874 mesh_pubkey: "302a300506032b6570032100feed".into(),
1875 possession_proof: proof.to_string(),
1876 proof_iat: now_unix(),
1877 advertise_addr: Some("https://joiner:7000".into()),
1878 };
1879
1880 let admitter = Arc::new(StubControl {
1882 admits: std::sync::Mutex::new(Vec::new()),
1883 respond: StubJoin::Admit,
1884 });
1885 let resp = cluster_join(
1886 Extension(auth.clone()),
1887 Extension(MeshControlHandle(Some(admitter.clone()))),
1888 Json(req("aa01")),
1889 )
1890 .await;
1891 assert_eq!(resp.status(), StatusCode::OK);
1892 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1893
1894 let spent = Arc::new(StubControl {
1896 admits: std::sync::Mutex::new(Vec::new()),
1897 respond: StubJoin::Spent,
1898 });
1899 assert_eq!(
1900 cluster_join(
1901 Extension(auth.clone()),
1902 Extension(MeshControlHandle(Some(spent))),
1903 Json(req("aa01")),
1904 )
1905 .await
1906 .status(),
1907 StatusCode::CONFLICT
1908 );
1909 let invalid = Arc::new(StubControl {
1910 admits: std::sync::Mutex::new(Vec::new()),
1911 respond: StubJoin::Invalid,
1912 });
1913 assert_eq!(
1914 cluster_join(
1915 Extension(auth.clone()),
1916 Extension(MeshControlHandle(Some(invalid))),
1917 Json(req("aa01")),
1918 )
1919 .await
1920 .status(),
1921 StatusCode::FORBIDDEN
1922 );
1923 let revoked = Arc::new(StubControl {
1925 admits: std::sync::Mutex::new(Vec::new()),
1926 respond: StubJoin::Revoked,
1927 });
1928 assert_eq!(
1929 cluster_join(
1930 Extension(auth.clone()),
1931 Extension(MeshControlHandle(Some(revoked))),
1932 Json(req("aa01")),
1933 )
1934 .await
1935 .status(),
1936 StatusCode::FORBIDDEN
1937 );
1938
1939 let ok = Arc::new(StubControl {
1941 admits: std::sync::Mutex::new(Vec::new()),
1942 respond: StubJoin::Admit,
1943 });
1944 assert_eq!(
1945 cluster_join(
1946 Extension(auth.clone()),
1947 Extension(MeshControlHandle(Some(ok))),
1948 Json(req("not-hex")),
1949 )
1950 .await
1951 .status(),
1952 StatusCode::BAD_REQUEST
1953 );
1954
1955 let none = cluster_join(
1957 Extension(auth),
1958 Extension(MeshControlHandle(None)),
1959 Json(req("aa01")),
1960 )
1961 .await;
1962 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1963 }
1964
1965 #[tokio::test]
1969 async fn bootstrap_mints_the_first_token_once() {
1970 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1971 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1972 let public = keys.public_key();
1973 let deploy = DeployStore::new(
1974 Arc::new(MemStorage::default()),
1975 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1976 );
1977 let secret = "s3cr3t-bootstrap-value";
1978 let gate = BootstrapGate::new(Some(secret));
1979 let issuer = Issuer(Some(keys.clone()));
1980 let bearer = |s: &str| {
1981 let mut h = HeaderMap::new();
1982 h.insert(
1983 AUTHORIZATION,
1984 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1985 );
1986 h
1987 };
1988 let req = || BootstrapRequest {
1989 roles: vec!["admin".to_string()],
1990 ttl_secs: None,
1991 };
1992
1993 let bad = bootstrap_token(
1995 State(deploy.clone()),
1996 Extension(issuer.clone()),
1997 Extension(gate.clone()),
1998 bearer("wrong"),
1999 Json(req()),
2000 )
2001 .await;
2002 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2003
2004 let ok = bootstrap_token(
2006 State(deploy.clone()),
2007 Extension(issuer.clone()),
2008 Extension(gate.clone()),
2009 bearer(secret),
2010 Json(req()),
2011 )
2012 .await;
2013 assert_eq!(ok.status(), StatusCode::CREATED);
2014 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2015 .await
2016 .unwrap();
2017 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2018 let token = json["token"].as_str().unwrap();
2019 let id = json["id"].as_str().unwrap();
2020 let verified = cose::verify(token, &public, now_unix()).unwrap();
2021 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2022 assert!(deploy
2023 .list_token_meta()
2024 .await
2025 .unwrap()
2026 .iter()
2027 .any(|m| m.revocation_id == id));
2028
2029 let reuse = bootstrap_token(
2031 State(deploy.clone()),
2032 Extension(issuer.clone()),
2033 Extension(gate),
2034 bearer(secret),
2035 Json(req()),
2036 )
2037 .await;
2038 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2039
2040 let disabled = bootstrap_token(
2042 State(deploy),
2043 Extension(issuer),
2044 Extension(BootstrapGate(None)),
2045 bearer(secret),
2046 Json(req()),
2047 )
2048 .await;
2049 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2050 }
2051
2052 #[tokio::test]
2055 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2056 let control = Arc::new(StubControl {
2057 admits: std::sync::Mutex::new(Vec::new()),
2058 respond: StubJoin::Admit,
2059 });
2060 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2061 assert_eq!(resp.status(), StatusCode::OK);
2062 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2063 .await
2064 .unwrap();
2065 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2066 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2067
2068 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2069 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2070 }
2071
2072 #[test]
2073 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2074 use boatramp_core::security::SecurityProfile;
2075 let strict = SecurityProfile::MultiTenant.preset();
2076 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2080 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2081 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2082
2083 assert!(gateway_addr_allowed(public, &strict));
2085 assert!(!gateway_addr_allowed(private, &strict));
2086 assert!(!gateway_addr_allowed(loopback, &strict));
2087 assert!(!gateway_addr_allowed(metadata, &strict));
2088
2089 assert!(gateway_addr_allowed(public, &loose));
2092 assert!(gateway_addr_allowed(private, &loose));
2093 assert!(gateway_addr_allowed(loopback, &loose));
2094 assert!(!gateway_addr_allowed(metadata, &loose));
2095 }
2096
2097 #[test]
2098 fn resolve_env_merges_static_and_host_secrets() {
2099 use boatramp_core::config::HandlersSiteConfig;
2100
2101 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2103
2104 let deploy_env = std::collections::BTreeMap::from([
2105 ("GREETING".to_string(), "hi".to_string()),
2106 ("OVERRIDE_ME".to_string(), "static".to_string()),
2107 ]);
2108 let site_handlers = HandlersSiteConfig {
2109 enabled: true,
2110 secrets: std::collections::BTreeMap::from([
2111 (
2113 "SECRET_TOKEN".to_string(),
2114 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2115 ),
2116 (
2117 "OVERRIDE_ME".to_string(),
2118 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2119 ),
2120 (
2121 "MISSING".to_string(),
2122 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2123 ),
2124 ]),
2125 ..Default::default()
2126 };
2127 let env = resolve_env("blog", &deploy_env, &site_handlers);
2128
2129 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2133 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2134 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2135 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2136
2137 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2138 }
2139
2140 fn req() -> Request {
2141 Request::builder()
2142 .uri("/")
2143 .header(header::HOST, "example.com")
2144 .body(Body::empty())
2145 .unwrap()
2146 }
2147
2148 #[test]
2149 fn forwarded_headers_set_standard_triple() {
2150 let mut request = req();
2151 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2152 let h = request.headers();
2153 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2154 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2155 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2156 }
2157
2158 #[test]
2159 fn forwarded_for_overwrites_spoofed_value() {
2160 let mut request = Request::builder()
2163 .uri("/")
2164 .header(header::HOST, "example.com")
2165 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2166 .body(Body::empty())
2167 .unwrap();
2168 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2169 let values: Vec<_> = request
2170 .headers()
2171 .get_all("x-forwarded-for")
2172 .iter()
2173 .collect();
2174 assert_eq!(values.len(), 1);
2175 assert_eq!(values[0], "203.0.113.7");
2176 }
2177
2178 #[test]
2179 fn forwarded_proto_preserves_upstream_tls() {
2180 let mut request = Request::builder()
2182 .uri("/")
2183 .header(header::HOST, "example.com")
2184 .header("x-forwarded-proto", "https")
2185 .body(Body::empty())
2186 .unwrap();
2187 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2188 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2189 }
2190
2191 #[test]
2192 fn forwarded_host_absent_when_no_host_header() {
2193 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2194 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2195 assert!(request.headers().get("x-forwarded-host").is_none());
2196 assert_eq!(
2197 request.headers().get("x-forwarded-for").unwrap(),
2198 "203.0.113.7"
2199 );
2200 }
2201
2202 use boatramp_core::kv::{KvStore, MemoryKv};
2205 use boatramp_core::messaging::{LogMessaging, Messaging};
2206 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2207
2208 const EVENT_CONSUMER: &[u8] =
2209 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2210
2211 #[derive(Default)]
2212 struct MemStorage {
2213 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2214 }
2215
2216 #[async_trait::async_trait]
2217 impl boatramp_core::Storage for MemStorage {
2218 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2219 let bytes = self
2220 .objects
2221 .lock()
2222 .unwrap()
2223 .get(key)
2224 .cloned()
2225 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2226 let body: ByteStream =
2227 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2228 Ok(GetObject {
2229 meta: ObjectMeta {
2230 key: key.to_string(),
2231 ..Default::default()
2232 },
2233 body,
2234 })
2235 }
2236 async fn get_range(
2237 &self,
2238 key: &str,
2239 _: u64,
2240 _: Option<u64>,
2241 ) -> Result<GetObject, StorageError> {
2242 self.get(key).await
2243 }
2244 async fn put(
2245 &self,
2246 key: &str,
2247 mut body: ByteStream,
2248 _: PutMeta,
2249 ) -> Result<ObjectMeta, StorageError> {
2250 use futures::StreamExt;
2251 let mut buf = Vec::new();
2252 while let Some(chunk) = body.next().await {
2253 buf.extend_from_slice(&chunk?);
2254 }
2255 self.objects.lock().unwrap().insert(key.to_string(), buf);
2256 Ok(ObjectMeta {
2257 key: key.to_string(),
2258 ..Default::default()
2259 })
2260 }
2261 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2262 self.objects
2263 .lock()
2264 .unwrap()
2265 .get(key)
2266 .map(|_| ObjectMeta {
2267 key: key.to_string(),
2268 ..Default::default()
2269 })
2270 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2271 }
2272 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2273 self.objects.lock().unwrap().remove(key);
2274 Ok(())
2275 }
2276 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2277 Ok(Vec::new())
2278 }
2279 }
2280
2281 fn observed_state(
2283 workload: &str,
2284 healthy: bool,
2285 phase: boatramp_core::compute::ReplicaPhase,
2286 ) -> boatramp_core::compute::ObservedInstance {
2287 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2288 boatramp_core::compute::ObservedInstance {
2289 handle: InstanceHandle {
2290 workload: workload.into(),
2291 replica: 0,
2292 backend_ref: "ref-0".into(),
2293 },
2294 node: 1,
2295 backend: "vmm".into(),
2296 endpoint: Endpoint {
2297 scheme: Scheme::Http,
2298 host: "10.0.0.2".into(),
2299 port: 80,
2300 },
2301 region: None,
2302 healthy,
2303 phase,
2304 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2305 workload: workload.into(),
2306 replica: 0,
2307 data_ref: "snap-0".into(),
2308 }),
2309 }
2310 }
2311
2312 #[tokio::test]
2313 async fn has_parked_replica_detects_a_zeroed_replica() {
2314 use boatramp_core::compute::ReplicaPhase;
2315 let storage = Arc::new(MemStorage::default());
2316 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2317 let deploy = DeployStore::new(storage, kv);
2318
2319 assert!(!has_parked_replica(&deploy, "w").await);
2321 deploy
2323 .set_replica_state(
2324 ProjectRef::DEFAULT,
2325 &observed_state("w", true, ReplicaPhase::Running),
2326 )
2327 .await
2328 .unwrap();
2329 assert!(!has_parked_replica(&deploy, "w").await);
2330 deploy
2332 .set_replica_state(
2333 ProjectRef::DEFAULT,
2334 &observed_state("w", false, ReplicaPhase::Zero),
2335 )
2336 .await
2337 .unwrap();
2338 assert!(has_parked_replica(&deploy, "w").await);
2339 }
2340
2341 #[tokio::test]
2342 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2343 use boatramp_core::compute::ReplicaPhase;
2344 let storage = Arc::new(MemStorage::default());
2345 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2346 let deploy = DeployStore::new(storage, kv);
2347
2348 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2350 assert!(empty.is_empty());
2351
2352 deploy
2354 .set_replica_state(
2355 ProjectRef::DEFAULT,
2356 &observed_state("w", true, ReplicaPhase::Running),
2357 )
2358 .await
2359 .unwrap();
2360 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2361 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2362 }
2363
2364 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2368 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2369 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2370 let storage = Arc::new(MemStorage::default());
2371 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2372 let mq = LogMessaging::new(storage, kv.clone());
2373 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2374 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2375 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2376 let topic = "blog/orders/created";
2377
2378 for _ in 0..3 {
2380 mq.publish(topic, b"ok").await.unwrap();
2381 }
2382 loop {
2383 let acked = dispatch_consumer_batch(
2384 &engine,
2385 &mq,
2386 &metrics::Metrics::default(),
2387 "blog",
2388 topic,
2389 "blog/",
2390 "",
2391 boatramp_core::messaging::StartPosition::Latest,
2392 &hash,
2393 EVENT_CONSUMER,
2394 &bindings,
2395 Limits::default(),
2396 Duration::from_secs(30),
2397 5,
2398 10,
2399 )
2400 .await;
2401 if acked == 0 {
2402 break;
2403 }
2404 }
2405 assert_eq!(
2406 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2407 Some(b"3".to_vec())
2408 );
2409
2410 mq.publish(topic, b"fail").await.unwrap();
2413 for _ in 0..5 {
2414 dispatch_consumer_batch(
2415 &engine,
2416 &mq,
2417 &metrics::Metrics::default(),
2418 "blog",
2419 topic,
2420 "blog/",
2421 "",
2422 boatramp_core::messaging::StartPosition::Latest,
2423 &hash,
2424 EVENT_CONSUMER,
2425 &bindings,
2426 Limits::default(),
2427 Duration::ZERO,
2428 2,
2429 10,
2430 )
2431 .await;
2432 }
2433 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2434 assert_eq!(
2436 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2437 Some(b"3".to_vec())
2438 );
2439 }
2440
2441 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2446 async fn consumer_groups_fan_out_through_the_dispatcher() {
2447 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2448 let storage = Arc::new(MemStorage::default());
2449 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2450 let mq = LogMessaging::new(storage, kv.clone());
2451 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2452 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2453 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2454 let topic = "blog/orders/created";
2455 let start = boatramp_core::messaging::StartPosition::Latest;
2456
2457 for g in ["billing", "audit"] {
2460 let n = dispatch_consumer_batch(
2461 &engine,
2462 &mq,
2463 &metrics::Metrics::default(),
2464 "blog",
2465 topic,
2466 "blog/",
2467 g,
2468 start,
2469 &hash,
2470 EVENT_CONSUMER,
2471 &bindings,
2472 Limits::default(),
2473 Duration::from_secs(30),
2474 5,
2475 10,
2476 )
2477 .await;
2478 assert_eq!(n, 0, "no events yet for group {g}");
2479 }
2480 mq.publish(topic, b"ok").await.unwrap();
2481
2482 for g in ["billing", "audit"] {
2484 let n = dispatch_consumer_batch(
2485 &engine,
2486 &mq,
2487 &metrics::Metrics::default(),
2488 "blog",
2489 topic,
2490 "blog/",
2491 g,
2492 start,
2493 &hash,
2494 EVENT_CONSUMER,
2495 &bindings,
2496 Limits::default(),
2497 Duration::from_secs(30),
2498 5,
2499 10,
2500 )
2501 .await;
2502 assert_eq!(n, 1, "group {g} should receive the message");
2503 }
2504 assert_eq!(
2506 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2507 Some(b"2".to_vec())
2508 );
2509 }
2510
2511 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2515 async fn scheduler_runs_current_consumers_not_previews() {
2516 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2517 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2518 use boatramp_handlers::{HandlerEngine, Limits};
2519 use futures::StreamExt;
2520
2521 let storage = Arc::new(MemStorage::default());
2522 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2523 let deploy = DeployStore::new(storage.clone(), kv.clone());
2524 let messaging: Arc<dyn Messaging> =
2525 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2526
2527 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2529 let stream: ByteStream =
2530 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2531 .boxed();
2532 deploy.put_blob(&hash, stream).await.unwrap();
2533 let mut files = std::collections::BTreeMap::new();
2534 files.insert(
2535 "consumer.wasm".to_string(),
2536 FileEntry {
2537 hash: hash.clone(),
2538 size: EVENT_CONSUMER.len() as u64,
2539 content_type: None,
2540 variants: std::collections::BTreeMap::new(),
2541 },
2542 );
2543 let manifest = Manifest {
2544 files,
2545 config: DeployConfig {
2546 consumers: vec![ConsumerConfig {
2547 topic: "orders/created".into(),
2548 component: "consumer.wasm".into(),
2549 imports: vec!["wasi:keyvalue".into()],
2550 group: String::new(),
2551 start: Default::default(),
2552 }],
2553 ..Default::default()
2554 },
2555 ..Default::default()
2556 };
2557 let id = deploy.put_manifest(&manifest).await.unwrap();
2558 deploy
2559 .activate(ProjectRef::DEFAULT, "blog", &id)
2560 .await
2561 .unwrap();
2562 deploy
2563 .set_site_config(
2564 ProjectRef::DEFAULT,
2565 "blog",
2566 &SiteConfig {
2567 handlers: Some(HandlersSiteConfig {
2568 enabled: true,
2569 allow_imports: vec!["wasi:keyvalue".into()],
2570 ..Default::default()
2571 }),
2572 ..Default::default()
2573 },
2574 )
2575 .await
2576 .unwrap();
2577
2578 messaging
2580 .publish("blog/orders/created", b"live")
2581 .await
2582 .unwrap();
2583 messaging
2584 .publish("blog/_preview/abc/orders/created", b"preview")
2585 .await
2586 .unwrap();
2587
2588 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2589 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2590 let inner = rt.inner.clone().unwrap();
2591 let mut cache = std::collections::HashMap::new();
2592 let mut crons = std::collections::HashMap::new();
2593 let mut sweep = std::collections::HashMap::new();
2594 let now = CronNow {
2595 minute: 0,
2596 hour: 0,
2597 dom: 1,
2598 month: 1,
2599 dow: 0,
2600 minute_stamp: 0,
2601 };
2602 for _ in 0..3 {
2603 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2604 .await
2605 .unwrap();
2606 }
2607
2608 assert_eq!(
2610 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2611 Some(b"1".to_vec())
2612 );
2613 assert_eq!(
2616 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2617 .await
2618 .unwrap(),
2619 None
2620 );
2621 }
2622
2623 const KV_COUNTER: &[u8] =
2628 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2629
2630 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2635 async fn function_invoker_runs_target_buffers_and_meters() {
2636 use boatramp_core::deploy::DeployStore;
2637 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2638 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2639 use futures::StreamExt;
2640
2641 const HTTP_200: &[u8] =
2644 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2645
2646 let storage = Arc::new(MemStorage::default());
2647 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2648 let deploy = DeployStore::new(storage.clone(), kv.clone());
2649
2650 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2651 let stream: ByteStream =
2652 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2653 deploy.put_blob(&hash, stream).await.unwrap();
2654 let function = Function {
2655 name: "target".into(),
2656 owner: Owner::Project("default".into()),
2657 versions: vec![FunctionVersion {
2658 id: "v1".into(),
2659 component: hash.clone(),
2660 created: 0,
2661 lifecycle: Lifecycle::Independent,
2662 }],
2663 active: "v1".into(),
2664 aliases: Default::default(),
2665 config: Default::default(),
2666 };
2667 deploy
2668 .put_function(ProjectRef::DEFAULT, &function)
2669 .await
2670 .unwrap();
2671
2672 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2673 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2674 rt.set_invoker(deploy.clone());
2675 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2676
2677 let request = || InvokeRequest {
2678 method: "GET".into(),
2679 path: "/".into(),
2680 headers: vec![],
2681 body: vec![],
2682 };
2683
2684 let response = invoker.invoke("target", request(), 1).await.unwrap();
2686 assert_eq!(response.status, 200);
2687
2688 let metering = deploy
2690 .get_metering(ProjectRef::DEFAULT, "target")
2691 .await
2692 .unwrap()
2693 .unwrap();
2694 assert_eq!(metering.invocations, 1);
2695
2696 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2698 assert!(matches!(err, InvokeError::NotFound));
2699 }
2700
2701 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2707 async fn federation_runner_enforces_the_safelist_before_planning() {
2708 use boatramp_core::deploy::DeployStore;
2709 use boatramp_core::project::ProjectRef;
2710 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2711
2712 let storage = Arc::new(MemStorage::default());
2713 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2714 let deploy = DeployStore::new(storage.clone(), kv.clone());
2715 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2716 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2717 rt.set_invoker(deploy.clone());
2718 let runner = rt
2719 .inner
2720 .as_ref()
2721 .unwrap()
2722 .federation_runner
2723 .get()
2724 .unwrap()
2725 .scoped(ProjectRef::new("default"));
2726
2727 let req = |query: &str| GraphqlRequest {
2728 query: Some(query.to_string()),
2729 persisted_hash: None,
2730 variables: "{}".to_string(),
2731 operation_name: None,
2732 authorization: None,
2733 };
2734
2735 assert!(matches!(
2737 runner.run(req("{ me { id } }"), 1).await,
2738 Err(SupergraphRunError::NotSafelisted)
2739 ));
2740
2741 let query = "{ me { id } }";
2744 let hash = crate::graphql_apq::sha256_hex(query);
2745 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2746 .await
2747 .unwrap();
2748 assert!(matches!(
2749 runner.run(req(query), 1).await,
2750 Err(SupergraphRunError::PlanFailed(_))
2751 ));
2752
2753 let persisted = GraphqlRequest {
2755 query: None,
2756 persisted_hash: Some("deadbeef".to_string()),
2757 variables: "{}".to_string(),
2758 operation_name: None,
2759 authorization: None,
2760 };
2761 assert!(matches!(
2762 runner.run(persisted, 1).await,
2763 Err(SupergraphRunError::NotSafelisted)
2764 ));
2765 }
2766
2767 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2773 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2774 use crate::scheduler::{run_scheduler_tick, CronNow};
2775 use boatramp_core::deploy::DeployStore;
2776 use boatramp_core::function::{
2777 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2778 };
2779 use boatramp_handlers::{HandlerEngine, Limits};
2780 use futures::StreamExt;
2781
2782 const HTTP_200: &[u8] =
2783 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2784
2785 let storage = Arc::new(MemStorage::default());
2786 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2787 let deploy = DeployStore::new(storage.clone(), kv.clone());
2788
2789 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2790 let stream: ByteStream =
2791 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2792 deploy.put_blob(&hash, stream).await.unwrap();
2793
2794 let acme = ProjectRef::new("acme");
2796 let function = Function {
2797 name: "worker".into(),
2798 owner: Owner::Project("acme".into()),
2799 versions: vec![FunctionVersion {
2800 id: "v1".into(),
2801 component: hash.clone(),
2802 created: 0,
2803 lifecycle: Lifecycle::Independent,
2804 }],
2805 active: "v1".into(),
2806 aliases: Default::default(),
2807 config: Default::default(),
2808 };
2809 deploy.put_function(acme, &function).await.unwrap();
2810 let inv = Invocation {
2811 id: "inv1".into(),
2812 function: "worker".into(),
2813 version: "v1".into(),
2814 mode: InvokeMode::Async,
2815 status: InvocationStatus::Queued,
2816 idempotency_key: None,
2817 attempts: 0,
2818 lease_expires: None,
2819 request_b64: None,
2820 request_content_type: None,
2821 result: None,
2822 created: 0,
2823 updated: 0,
2824 };
2825 deploy.put_invocation(acme, &inv).await.unwrap();
2826
2827 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2828 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2829 let inner = rt.inner.as_ref().unwrap();
2830
2831 let mut wasm_cache = std::collections::HashMap::new();
2834 let mut cron_state = std::collections::HashMap::new();
2835 let mut sweep = std::collections::HashMap::new();
2836 let now = CronNow {
2837 minute: 0,
2838 hour: 0,
2839 dom: 1,
2840 month: 1,
2841 dow: 0,
2842 minute_stamp: 0,
2843 };
2844 run_scheduler_tick(
2845 inner,
2846 &deploy,
2847 &mut wasm_cache,
2848 &mut cron_state,
2849 &mut sweep,
2850 now,
2851 )
2852 .await
2853 .unwrap();
2854
2855 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2858 assert_eq!(settled.status, InvocationStatus::Succeeded);
2860 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2862 assert_eq!(metering.invocations, 1);
2863 assert!(deploy
2865 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2866 .await
2867 .unwrap()
2868 .is_none());
2869 assert!(deploy
2870 .get_metering(ProjectRef::DEFAULT, "worker")
2871 .await
2872 .unwrap()
2873 .is_none());
2874 }
2875
2876 #[cfg(feature = "handlers")]
2880 async fn poll_invocation_settled(
2881 deploy: &boatramp_core::deploy::DeployStore,
2882 project: ProjectRef<'_>,
2883 function: &str,
2884 id: &str,
2885 ) -> boatramp_core::function::Invocation {
2886 use boatramp_core::function::InvocationStatus;
2887 for _ in 0..200 {
2888 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2889 if matches!(
2890 inv.status,
2891 InvocationStatus::Succeeded | InvocationStatus::Failed
2892 ) {
2893 return inv;
2894 }
2895 }
2896 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2897 }
2898 panic!("invocation {function}/{id} never settled");
2899 }
2900
2901 #[cfg(feature = "handlers")]
2906 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2907 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2908 use crate::scheduler::{run_scheduler_tick, CronNow};
2909 use boatramp_core::deploy::DeployStore;
2910 use boatramp_core::function::{
2911 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2912 };
2913 use boatramp_handlers::{HandlerEngine, Limits};
2914 use futures::StreamExt;
2915
2916 const HTTP_200: &[u8] =
2917 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2918
2919 let storage = Arc::new(MemStorage::default());
2920 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2921 let deploy = DeployStore::new(storage.clone(), kv.clone());
2922 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2923 let stream: ByteStream =
2924 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2925 deploy.put_blob(&hash, stream).await.unwrap();
2926
2927 let function = Function {
2928 name: "worker".into(),
2929 owner: Owner::Project("default".into()),
2930 versions: vec![FunctionVersion {
2931 id: "v1".into(),
2932 component: hash.clone(),
2933 created: 0,
2934 lifecycle: Lifecycle::Independent,
2935 }],
2936 active: "v1".into(),
2937 aliases: Default::default(),
2938 config: Default::default(),
2939 };
2940 deploy
2941 .put_function(ProjectRef::DEFAULT, &function)
2942 .await
2943 .unwrap();
2944
2945 let base = Invocation {
2948 id: String::new(),
2949 function: "worker".into(),
2950 version: "v1".into(),
2951 mode: InvokeMode::Async,
2952 status: InvocationStatus::Running,
2953 idempotency_key: None,
2954 attempts: 1,
2955 lease_expires: None,
2956 request_b64: None,
2957 request_content_type: None,
2958 result: None,
2959 created: 0,
2960 updated: 0,
2961 };
2962 let orphan = Invocation {
2963 id: "orphan".into(),
2964 lease_expires: Some(1),
2965 ..base.clone()
2966 };
2967 deploy
2968 .put_invocation(ProjectRef::DEFAULT, &orphan)
2969 .await
2970 .unwrap();
2971 let live = Invocation {
2972 id: "live".into(),
2973 lease_expires: Some(u64::MAX),
2974 ..base.clone()
2975 };
2976 deploy
2977 .put_invocation(ProjectRef::DEFAULT, &live)
2978 .await
2979 .unwrap();
2980
2981 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2982 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2983 let inner = rt.inner.as_ref().unwrap();
2984
2985 let now = CronNow {
2986 minute: 0,
2987 hour: 0,
2988 dom: 1,
2989 month: 1,
2990 dow: 0,
2991 minute_stamp: 0,
2992 };
2993 let mut wasm_cache = std::collections::HashMap::new();
2994 let mut cron_state = std::collections::HashMap::new();
2995 let mut sweep = std::collections::HashMap::new();
2996 run_scheduler_tick(
2997 inner,
2998 &deploy,
2999 &mut wasm_cache,
3000 &mut cron_state,
3001 &mut sweep,
3002 now,
3003 )
3004 .await
3005 .unwrap();
3006
3007 let settled =
3009 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3010 assert_eq!(settled.status, InvocationStatus::Succeeded);
3011 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3012 assert_eq!(
3013 settled.lease_expires, None,
3014 "a settled invocation drops its lease"
3015 );
3016 let live_after = deploy
3018 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3019 .await
3020 .unwrap()
3021 .unwrap();
3022 assert_eq!(live_after.status, InvocationStatus::Running);
3023 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3024 assert_eq!(live_after.lease_expires, Some(u64::MAX));
3025 }
3026
3027 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3042 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3043 use boatramp_core::deploy::DeployStore;
3044 use boatramp_core::function::{
3045 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3046 };
3047 use boatramp_handlers::{HandlerEngine, Limits};
3048 use futures::StreamExt;
3049
3050 let storage = Arc::new(MemStorage::default());
3051 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3052 let deploy = DeployStore::new(storage.clone(), kv.clone());
3053
3054 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3057 let stream: ByteStream =
3058 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3059 deploy.put_blob(&hash, stream).await.unwrap();
3060
3061 let store = Function {
3066 name: "store".into(),
3067 owner: Owner::Project("default".into()),
3068 versions: vec![FunctionVersion {
3069 id: "v1".into(),
3070 component: hash.clone(),
3071 created: 0,
3072 lifecycle: Lifecycle::Independent,
3073 }],
3074 active: "v1".into(),
3075 aliases: Default::default(),
3076 config: FunctionConfig {
3077 imports: vec!["wasi:keyvalue".into()],
3078 ..Default::default()
3079 },
3080 };
3081 let acme = ProjectRef::new("acme");
3082 let globex = ProjectRef::new("globex");
3083
3084 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3085 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3086 let inner = rt.inner.as_ref().unwrap();
3087
3088 let request = || {
3089 axum::http::Request::builder()
3090 .method("GET")
3091 .uri("/")
3092 .body(axum::body::Body::empty())
3093 .unwrap()
3094 };
3095
3096 let component = store.resolve(&store.active).unwrap().to_owned();
3099 for project in [acme, globex, ProjectRef::DEFAULT] {
3100 let (response, _) = execute_function(
3101 inner,
3102 &deploy,
3103 project,
3104 &store,
3105 &component,
3106 request(),
3107 0,
3108 boatramp_handlers::Lane::Sync,
3109 )
3110 .await;
3111 assert!(response.status().is_success(), "invocation should succeed");
3112 }
3113
3114 assert_eq!(
3117 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3118 Some(b"1".to_vec()),
3119 "acme's write must be tenant-qualified"
3120 );
3121 assert_eq!(
3122 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3123 Some(b"1".to_vec()),
3124 "globex's write must be tenant-qualified"
3125 );
3126 assert_eq!(
3127 kv.get("hkv/fn/store/hits").await.unwrap(),
3128 Some(b"1".to_vec()),
3129 "the default project must keep the byte-identical pre-project key"
3130 );
3131 }
3134
3135 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3139 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3140 use boatramp_core::config::{
3141 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3142 };
3143 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3144 use boatramp_handlers::{HandlerEngine, Limits};
3145 use futures::StreamExt;
3146 use std::sync::atomic::Ordering;
3147
3148 let storage = Arc::new(MemStorage::default());
3149 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3150 let deploy = DeployStore::new(storage.clone(), kv.clone());
3151
3152 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3153 let stream: ByteStream =
3154 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3155 deploy.put_blob(&hash, stream).await.unwrap();
3156 let mut files = std::collections::BTreeMap::new();
3157 files.insert(
3158 "counter.wasm".to_string(),
3159 FileEntry {
3160 hash: hash.clone(),
3161 size: KV_COUNTER.len() as u64,
3162 content_type: None,
3163 variants: std::collections::BTreeMap::new(),
3164 },
3165 );
3166 let manifest = Manifest {
3167 files,
3168 config: DeployConfig {
3169 handlers: vec![HandlerConfig {
3170 route: "/".into(),
3171 methods: Vec::new(),
3172 component: "counter.wasm".into(),
3173 imports: vec!["wasi:keyvalue".into()],
3174 streaming: false,
3175 limits: None,
3176 env: std::collections::BTreeMap::new(),
3177 invoke_targets: Vec::new(),
3178 }],
3179 crons: vec![CronConfig {
3180 schedule: "* * * * *".into(),
3181 route: "/".into(),
3182 overlap: Overlap::Skip,
3183 }],
3184 ..Default::default()
3185 },
3186 ..Default::default()
3187 };
3188 let id = deploy.put_manifest(&manifest).await.unwrap();
3189 deploy
3190 .activate(ProjectRef::DEFAULT, "blog", &id)
3191 .await
3192 .unwrap();
3193 deploy
3194 .set_site_config(
3195 ProjectRef::DEFAULT,
3196 "blog",
3197 &SiteConfig {
3198 handlers: Some(HandlersSiteConfig {
3199 enabled: true,
3200 allow_imports: vec!["wasi:keyvalue".into()],
3201 ..Default::default()
3202 }),
3203 ..Default::default()
3204 },
3205 )
3206 .await
3207 .unwrap();
3208
3209 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3210 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3211 let inner = rt.inner.clone().unwrap();
3212 let mut wasm = std::collections::HashMap::new();
3213 let mut crons = std::collections::HashMap::new();
3214 let mut sweep = std::collections::HashMap::new();
3215 let at = |stamp| CronNow {
3216 minute: 0,
3217 hour: 0,
3218 dom: 1,
3219 month: 1,
3220 dow: 0,
3221 minute_stamp: stamp,
3222 };
3223
3224 let (_, handles) =
3226 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3227 .await
3228 .unwrap();
3229 for h in handles {
3230 h.await.unwrap();
3231 }
3232 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3233
3234 let (_, handles) =
3236 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3237 .await
3238 .unwrap();
3239 assert!(handles.is_empty());
3240 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3241
3242 let (_, handles) =
3244 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3245 .await
3246 .unwrap();
3247 for h in handles {
3248 h.await.unwrap();
3249 }
3250 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3251
3252 crons
3256 .get("default|blog|cron|0")
3257 .unwrap()
3258 .running
3259 .store(true, Ordering::Release);
3260 let (_, handles) =
3261 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3262 .await
3263 .unwrap();
3264 assert!(handles.is_empty());
3265 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3266 }
3267
3268 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3272 async fn cron_leader_gate_suppresses_crons_off_leader() {
3273 use boatramp_core::config::{
3274 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3275 };
3276 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3277 use boatramp_handlers::{HandlerEngine, Limits};
3278 use futures::StreamExt;
3279
3280 let storage = Arc::new(MemStorage::default());
3281 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3282 let deploy = DeployStore::new(storage.clone(), kv.clone());
3283
3284 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3285 let stream: ByteStream =
3286 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3287 deploy.put_blob(&hash, stream).await.unwrap();
3288 let mut files = std::collections::BTreeMap::new();
3289 files.insert(
3290 "counter.wasm".to_string(),
3291 FileEntry {
3292 hash: hash.clone(),
3293 size: KV_COUNTER.len() as u64,
3294 content_type: None,
3295 variants: std::collections::BTreeMap::new(),
3296 },
3297 );
3298 let manifest = Manifest {
3299 files,
3300 config: DeployConfig {
3301 handlers: vec![HandlerConfig {
3302 route: "/".into(),
3303 methods: Vec::new(),
3304 component: "counter.wasm".into(),
3305 imports: vec!["wasi:keyvalue".into()],
3306 streaming: false,
3307 limits: None,
3308 env: std::collections::BTreeMap::new(),
3309 invoke_targets: Vec::new(),
3310 }],
3311 crons: vec![CronConfig {
3312 schedule: "* * * * *".into(),
3313 route: "/".into(),
3314 overlap: Overlap::Skip,
3315 }],
3316 ..Default::default()
3317 },
3318 ..Default::default()
3319 };
3320 let id = deploy.put_manifest(&manifest).await.unwrap();
3321 deploy
3322 .activate(ProjectRef::DEFAULT, "blog", &id)
3323 .await
3324 .unwrap();
3325 deploy
3326 .set_site_config(
3327 ProjectRef::DEFAULT,
3328 "blog",
3329 &SiteConfig {
3330 handlers: Some(HandlersSiteConfig {
3331 enabled: true,
3332 allow_imports: vec!["wasi:keyvalue".into()],
3333 ..Default::default()
3334 }),
3335 ..Default::default()
3336 },
3337 )
3338 .await
3339 .unwrap();
3340
3341 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3342 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3343 rt.set_cron_leader_gate(Arc::new(|| false));
3345 let inner = rt.inner.clone().unwrap();
3346 let mut wasm = std::collections::HashMap::new();
3347 let mut crons = std::collections::HashMap::new();
3348 let mut sweep = std::collections::HashMap::new();
3349 let now = CronNow {
3350 minute: 0,
3351 hour: 0,
3352 dom: 1,
3353 month: 1,
3354 dow: 0,
3355 minute_stamp: 100,
3356 };
3357
3358 let (_, handles) =
3359 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3360 .await
3361 .unwrap();
3362 assert!(handles.is_empty(), "a non-leader must not fire crons");
3364 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3365 }
3366
3367 #[tokio::test]
3374 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3375 use boatramp_core::config::HandlersSiteConfig;
3376 use boatramp_core::project::ProjectRef;
3377 use boatramp_handlers::{HandlerEngine, Limits};
3378
3379 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3380 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3381 let sql_dir =
3383 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3384 let _ = std::fs::remove_dir_all(&sql_dir);
3385 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3386 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3387
3388 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3389 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3390 let inner = rt.inner.as_ref().unwrap();
3391
3392 let site = HandlersSiteConfig {
3394 enabled: true,
3395 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3396 ..Default::default()
3397 };
3398 let env = std::collections::BTreeMap::new();
3399 let build = |imports: &[&str]| {
3400 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3401 let site = &site;
3402 let env = &env;
3403 async move {
3404 crate::handler_dispatch::build_bindings(
3405 inner,
3406 ProjectRef::new("default"),
3407 "shop",
3408 "shop",
3409 None,
3410 &imports,
3411 site,
3412 env,
3413 &[],
3414 0,
3415 None,
3416 )
3417 .await
3418 .sql_database_names()
3419 }
3420 };
3421
3422 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3425 assert_eq!(
3427 build(&["sql", "sql:*"]).await,
3428 vec!["", "privileged", "product"]
3429 );
3430 assert!(build(&["sql:secret"]).await.is_empty());
3432 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3434
3435 let _ = std::fs::remove_dir_all(&sql_dir);
3436 }
3437}