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, 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 #[cfg(feature = "console")]
736 pub console: Option<console::ConsoleMount>,
737}
738
739#[derive(Clone, Copy)]
743struct ServedOverTls(bool);
744
745#[derive(Clone, Copy, Default)]
750struct ImplicitRouting(bool);
751
752const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
764
765pub struct DaemonRuntime {
766 baseline: boatramp_core::daemon_config::ConfigBaseline,
767 state: std::sync::RwLock<DaemonState>,
768 reload: tokio::sync::Notify,
771}
772
773struct DaemonState {
774 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
775 generation: Option<String>,
776}
777
778pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
783 #[cfg(feature = "console")]
787 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
788 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
789 None => (false, None, None),
790 };
791 #[cfg(not(feature = "console"))]
792 let (console_enabled, console_host, console_path) = (false, None, None);
793 boatramp_core::daemon_config::ConfigBaseline {
794 default_site: options.default_site.clone(),
795 protect_previews: options.protect_previews,
796 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
797 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
798 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
799 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
800 compute_vcpus: 0,
801 compute_mem_mib: 0,
802 console_enabled,
803 console_host,
804 console_path,
805 max_upload_ceiling: options.posture.max_upload_bytes,
806 max_concurrent_uploads_ceiling: None,
807 posture: options.posture,
808 }
809}
810
811impl DaemonRuntime {
812 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
816 let effective =
817 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
818 Self {
819 baseline,
820 state: std::sync::RwLock::new(DaemonState {
821 effective,
822 generation: None,
823 }),
824 reload: tokio::sync::Notify::new(),
825 }
826 }
827
828 pub fn notify_reload(&self) {
832 self.reload.notify_one();
833 }
834
835 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
837 self.state
838 .read()
839 .expect("daemon config lock")
840 .effective
841 .clone()
842 }
843
844 pub fn generation(&self) -> Option<String> {
847 self.state
848 .read()
849 .expect("daemon config lock")
850 .generation
851 .clone()
852 }
853
854 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
856 &self.baseline
857 }
858
859 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
862 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
863 let generation = deploy.daemon_config_generation().await?;
864 let effective = Arc::new(cfg.resolve(&self.baseline));
865 *self.state.write().expect("daemon config lock") = DaemonState {
866 effective,
867 generation,
868 };
869 Ok(())
870 }
871}
872
873#[derive(Clone, Copy, Default)]
876struct PreviewPolicy {
877 protect: bool,
878}
879
880#[derive(Clone, Default)]
885struct Issuer(Option<Arc<dyn Signer>>);
886
887#[derive(Clone, Default)]
892struct BootstrapGate(Option<Arc<BootstrapInner>>);
893
894struct BootstrapInner {
895 secret_hash: String,
898 lock: tokio::sync::Mutex<()>,
901}
902
903impl BootstrapGate {
904 fn new(secret: Option<&str>) -> Self {
905 Self(secret.filter(|s| !s.is_empty()).map(|s| {
906 Arc::new(BootstrapInner {
907 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
908 lock: tokio::sync::Mutex::new(()),
909 })
910 }))
911 }
912}
913
914#[async_trait::async_trait]
918pub trait MeshControl: Send + Sync {
919 async fn admit(
927 &self,
928 mesh_pubkey_hex: &str,
929 jti: &str,
930 possession_proof: &[u8],
931 proof_iat: u64,
932 now: u64,
933 advertise_addr: Option<&str>,
934 ) -> Result<JoinOutcome, String>;
935
936 async fn rotate_key(&self) -> Result<String, String>;
940
941 async fn revoke(&self, node: u64) -> Result<(), String>;
945
946 async fn members(&self) -> Result<Vec<MeshMember>, String>;
950
951 async fn promote(&self, node: u64) -> Result<(), String>;
954}
955
956pub enum JoinOutcome {
958 Admitted {
961 members: Vec<String>,
963 addrs: std::collections::BTreeMap<u64, String>,
965 },
966 TokenSpent,
968 ProofInvalid,
970 Revoked,
973}
974
975#[derive(Debug, Clone, Serialize)]
977pub struct MeshMember {
978 pub node: u64,
980 pub voter: bool,
982 pub caught_up: bool,
984 pub leader: bool,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub addr: Option<String>,
991}
992
993#[derive(Clone, Default)]
996struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
997
998#[cfg(feature = "oidc")]
1000#[derive(Clone, Default)]
1001struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1002
1003#[cfg(feature = "oidc")]
1006const EXCHANGE_TTL_SECS: u64 = 3600;
1007
1008use boatramp_core::time::now_unix;
1009
1010#[derive(Clone)]
1012struct CorsState(Arc<Vec<String>>);
1013
1014const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1016const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1019const CORS_MAX_AGE: &str = "600";
1021
1022fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1026 allowed.iter().any(|a| a == "*" || a == origin)
1027}
1028
1029async fn cors(
1036 State(allowed): State<CorsState>,
1037 request: Request,
1038 next: axum::middleware::Next,
1039) -> Response {
1040 let origin = request
1041 .headers()
1042 .get(header::ORIGIN)
1043 .and_then(|v| v.to_str().ok())
1044 .filter(|o| cors_origin_allowed(&allowed.0, o))
1045 .map(str::to_string);
1046 let is_preflight = request.method() == Method::OPTIONS
1048 && request
1049 .headers()
1050 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1051 if is_preflight {
1052 let allow_headers = request
1054 .headers()
1055 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1056 .and_then(|v| v.to_str().ok())
1057 .map(str::to_string)
1058 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1059 let mut response = Response::new(Body::empty());
1060 *response.status_mut() = StatusCode::NO_CONTENT;
1061 if let Some(origin) = origin {
1062 let headers = response.headers_mut();
1063 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1064 set_header(headers, header::VARY, "Origin");
1065 set_header(
1066 headers,
1067 header::ACCESS_CONTROL_ALLOW_METHODS,
1068 CORS_ALLOW_METHODS,
1069 );
1070 set_header(
1071 headers,
1072 header::ACCESS_CONTROL_ALLOW_HEADERS,
1073 &allow_headers,
1074 );
1075 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1076 }
1077 return response;
1078 }
1079 let mut response = next.run(request).await;
1080 if let Some(origin) = origin {
1081 let headers = response.headers_mut();
1082 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1083 if let Ok(value) = HeaderValue::from_str("Origin") {
1086 headers.append(header::VARY, value);
1087 }
1088 }
1089 response
1090}
1091
1092const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1097
1098#[derive(Debug, thiserror::Error)]
1100pub enum ServeError {
1101 #[error("server I/O: {0}")]
1103 Io(#[from] std::io::Error),
1104}
1105
1106pub async fn serve(
1109 addr: SocketAddr,
1110 deploy: DeployStore,
1111 auth: Auth,
1112 handlers: HandlerRuntime,
1113) -> Result<(), ServeError> {
1114 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1115}
1116
1117pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1127 if let Err(err) = stream.set_nodelay(true) {
1128 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1129 }
1130}
1131
1132pub async fn serve_with(
1134 addr: SocketAddr,
1135 deploy: DeployStore,
1136 auth: Auth,
1137 handlers: HandlerRuntime,
1138 options: ServerOptions,
1139) -> Result<(), ServeError> {
1140 let tcp = tokio::net::TcpListener::bind(addr).await?;
1141 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1142 let splice_ctx = splice::SpliceCtx {
1148 deploy: deploy.clone(),
1149 posture: options.posture,
1150 daemon: options.daemon_runtime.clone(),
1151 };
1152 #[cfg(feature = "handlers")]
1155 let scheduler = handlers.spawn_scheduler(deploy.clone());
1156 let gateway_prober = gateway::spawn_active_health_prober();
1160 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1163
1164 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1168 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1172 shutdown_signal().await;
1173 let _ = signalled_tx.send(true);
1174 });
1175 let signalled = {
1176 let mut rx = signalled_rx;
1177 async move {
1178 let _ = rx.wait_for(|fired| *fired).await;
1179 }
1180 };
1181 let result = serve_with_drain_deadline(
1182 async move { server.await.map_err(ServeError::from) },
1183 signalled,
1184 DRAIN_DEADLINE,
1185 )
1186 .await;
1187 #[cfg(feature = "handlers")]
1189 if let Some(handle) = scheduler {
1190 handle.abort();
1191 }
1192 gateway_prober.abort();
1193 result
1194}
1195
1196async fn serve_with_drain_deadline<Srv, Sig>(
1201 server: Srv,
1202 signalled: Sig,
1203 deadline: Duration,
1204) -> Result<(), ServeError>
1205where
1206 Srv: Future<Output = Result<(), ServeError>>,
1207 Sig: Future<Output = ()>,
1208{
1209 tokio::pin!(server);
1210 let drain_cap = async move {
1211 signalled.await;
1212 tokio::time::sleep(deadline).await;
1213 };
1214 tokio::select! {
1215 result = &mut server => result,
1216 _ = drain_cap => {
1217 tracing::warn!(
1218 deadline_s = deadline.as_secs(),
1219 "drain deadline exceeded; forcing shutdown with requests still in flight"
1220 );
1221 Ok(())
1222 }
1223 }
1224}
1225
1226pub async fn shutdown_signal() {
1229 let ctrl_c = async {
1230 let _ = tokio::signal::ctrl_c().await;
1231 };
1232 #[cfg(unix)]
1233 let terminate = async {
1234 if let Ok(mut sig) =
1235 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1236 {
1237 sig.recv().await;
1238 }
1239 };
1240 #[cfg(not(unix))]
1241 let terminate = std::future::pending::<()>();
1242
1243 tokio::select! {
1244 _ = ctrl_c => {}
1245 _ = terminate => {}
1246 }
1247 tracing::info!("shutdown signal received; draining");
1248}
1249
1250async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1254 match daemon.generation() {
1255 Some(gen) => format!("ok gen={gen}"),
1256 None => "ok".to_string(),
1257 }
1258}
1259
1260async fn readyz(State(deploy): State<DeployStore>) -> Response {
1262 match deploy.ready().await {
1263 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1264 Err(err) => {
1265 tracing::warn!(error = %err, "readiness probe failed");
1266 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1267 }
1268 }
1269}
1270
1271#[derive(Clone)]
1276pub struct RequestId(pub String);
1277
1278fn request_id_for(headers: &HeaderMap) -> String {
1281 if let Some(id) = headers
1282 .get("x-request-id")
1283 .and_then(|v| v.to_str().ok())
1284 .map(str::trim)
1285 .filter(|s| !s.is_empty())
1286 {
1287 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1288 }
1289 use std::sync::atomic::{AtomicU64, Ordering};
1290 static SEQ: AtomicU64 = AtomicU64::new(0);
1291 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1292 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1293}
1294
1295struct AccessLog {
1299 request_id: String,
1300 method: Method,
1301 path: String,
1302 host: String,
1303 client: String,
1304 status: u16,
1305 encoding: String,
1307 start: std::time::Instant,
1308 bytes: std::sync::atomic::AtomicU64,
1309}
1310
1311impl Drop for AccessLog {
1312 fn drop(&mut self) {
1313 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1314 srvmetrics::server_metrics().record_request(self.status, bytes);
1317 tracing::info!(
1318 target: "boatramp::access",
1319 request_id = %self.request_id,
1320 method = %self.method,
1321 path = %self.path,
1322 host = %self.host,
1323 client = %self.client,
1324 status = self.status,
1325 bytes = bytes,
1326 encoding = %self.encoding,
1327 cache_result = srvmetrics::cache_result(self.status),
1328 elapsed_ms = self.start.elapsed().as_millis() as u64,
1329 "request"
1330 );
1331 }
1332}
1333
1334pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1340 let request_id = request_id_for(request.headers());
1341 request
1342 .extensions_mut()
1343 .insert(RequestId(request_id.clone()));
1344 request_id
1345}
1346
1347pub(crate) struct AccessLogCtx {
1352 request_id: String,
1353 method: Method,
1354 path: String,
1355 host: String,
1356 client: String,
1357 start: std::time::Instant,
1358}
1359
1360impl AccessLogCtx {
1361 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1367 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1368 return None;
1369 }
1370 Some(Self {
1371 request_id,
1372 method: request.method().clone(),
1373 path: request.uri().path().to_string(),
1374 host: request
1375 .headers()
1376 .get(header::HOST)
1377 .and_then(|value| value.to_str().ok())
1378 .or_else(|| request.uri().host()) .unwrap_or("-")
1380 .to_string(),
1381 client: request
1382 .extensions()
1383 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1384 .map(|info| info.0.ip().to_string())
1385 .unwrap_or_else(|| "-".to_string()),
1386 start: std::time::Instant::now(),
1387 })
1388 }
1389
1390 pub(crate) fn finish(self, response: Response) -> Response {
1394 let encoding = response
1395 .headers()
1396 .get(header::CONTENT_ENCODING)
1397 .and_then(|v| v.to_str().ok())
1398 .unwrap_or("identity")
1399 .to_string();
1400 let log = AccessLog {
1401 request_id: self.request_id,
1402 method: self.method,
1403 path: self.path,
1404 host: self.host,
1405 client: self.client,
1406 status: response.status().as_u16(),
1407 encoding,
1408 start: self.start,
1409 bytes: std::sync::atomic::AtomicU64::new(0),
1410 };
1411 let (parts, body) = response.into_parts();
1412 let counted = body.into_data_stream().map(move |chunk| {
1413 if let Ok(bytes) = &chunk {
1414 log.bytes
1415 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1416 }
1417 chunk
1418 });
1419 Response::from_parts(parts, Body::from_stream(counted))
1420 }
1421}
1422
1423async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1428 let request_id = assign_request_id(&mut request);
1429 match AccessLogCtx::capture(&request, request_id) {
1430 None => next.run(request).await,
1431 Some(ctx) => ctx.finish(next.run(request).await),
1432 }
1433}
1434
1435fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1437 req_headers
1438 .get(header::IF_NONE_MATCH)
1439 .and_then(|value| value.to_str().ok())
1440 .is_some_and(|value| {
1441 value
1442 .split(',')
1443 .map(str::trim)
1444 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1445 })
1446}
1447
1448fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1449 if let Ok(value) = HeaderValue::from_str(value) {
1450 headers.insert(name, value);
1451 }
1452}
1453
1454fn not_found() -> Response {
1455 (StatusCode::NOT_FOUND, "not found\n").into_response()
1456}
1457
1458fn redirect(status: u16, location: &str) -> Response {
1459 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1460 match HeaderValue::from_str(location) {
1461 Ok(location) => {
1462 let mut headers = HeaderMap::new();
1463 headers.insert(header::LOCATION, location);
1464 (status, headers).into_response()
1465 }
1466 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1467 }
1468}
1469
1470fn deploy_error_response(err: DeployError) -> Response {
1472 let status = match &err {
1473 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1474 StatusCode::NOT_FOUND
1475 }
1476 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1477 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1478 DeployError::Conflict(_) => StatusCode::CONFLICT,
1480 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1482 _ => StatusCode::INTERNAL_SERVER_ERROR,
1483 };
1484 tracing::warn!(error = %err, "request failed");
1485 (status, format!("{err}\n")).into_response()
1486}
1487
1488fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1493 boatramp_core::project::validate_resource_name(kind, value)
1494 .err()
1495 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1496}
1497
1498#[cfg(test)]
1499mod drain_tests {
1500 use super::*;
1501
1502 #[tokio::test]
1503 async fn deadline_forces_shutdown_after_signal() {
1504 let server = std::future::pending::<Result<(), ServeError>>();
1507 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1509 assert!(result.is_ok());
1510 }
1511
1512 #[tokio::test]
1513 async fn server_finishing_first_wins() {
1514 let server = async { Ok(()) };
1517 let signalled = std::future::pending::<()>();
1518 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1519 assert!(result.is_ok());
1520 }
1521
1522 #[tokio::test]
1523 async fn deadline_does_not_trip_before_signal() {
1524 let server = async {
1528 tokio::time::sleep(Duration::from_millis(40)).await;
1529 Err(ServeError::Io(std::io::Error::other("server error")))
1530 };
1531 let signalled = std::future::pending::<()>();
1532 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1533 assert!(result.is_err());
1534 }
1535}
1536
1537#[cfg(all(test, feature = "handlers"))]
1538mod tests {
1539 use super::*;
1540 use boatramp_core::cose::{LocalSigner, TokenAlg};
1541 use boatramp_core::project::ProjectRef;
1542
1543 #[test]
1544 fn query_string_parses_and_url_decodes() {
1545 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1546 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1547 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")); }
1551
1552 #[test]
1553 fn cookie_header_parses_pairs() {
1554 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1555 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1556 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1557 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1558 }
1559
1560 #[test]
1561 fn apply_vary_merges_without_duplicates() {
1562 let base = (StatusCode::OK, "x").into_response();
1563 let r = apply_vary(base, &["accept-language".into()]);
1564 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1565 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1567 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1568 assert!(v.contains("accept-language") && v.contains("cookie"));
1569 assert_eq!(v.matches("accept-language").count(), 1);
1570 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1572 assert!(plain.headers().get(header::VARY).is_none());
1573 }
1574
1575 #[tokio::test]
1579 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1580 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1581 let public = keys.public_key();
1582
1583 let resp = create_join_token(
1585 Extension(Issuer(Some(keys.clone()))),
1586 Json(CreateJoinTokenRequest {
1587 ttl_secs: Some(600),
1588 }),
1589 )
1590 .await;
1591 assert_eq!(resp.status(), StatusCode::CREATED);
1592 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1593 .await
1594 .unwrap();
1595 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1596 let token = parsed["token"].as_str().unwrap();
1597 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1598 assert!(!jti.is_empty());
1599
1600 let no_issuer = create_join_token(
1602 Extension(Issuer(None)),
1603 Json(CreateJoinTokenRequest { ttl_secs: None }),
1604 )
1605 .await;
1606 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1607 }
1608
1609 #[tokio::test]
1614 async fn function_write_path_deploy_rollback_alias_remove() {
1615 use boatramp_core::function::Lifecycle;
1616 use boatramp_core::kv::MemoryKv;
1617 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1618
1619 struct FakeStorage {
1622 present: bool,
1623 }
1624 #[async_trait::async_trait]
1625 impl Storage for FakeStorage {
1626 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1627 Err(StorageError::NotFound(String::new()))
1628 }
1629 async fn get_range(
1630 &self,
1631 _: &str,
1632 _: u64,
1633 _: Option<u64>,
1634 ) -> Result<GetObject, StorageError> {
1635 Err(StorageError::NotFound(String::new()))
1636 }
1637 async fn put(
1638 &self,
1639 _: &str,
1640 _: ByteStream,
1641 _: PutMeta,
1642 ) -> Result<ObjectMeta, StorageError> {
1643 Err(StorageError::unsupported("fake"))
1644 }
1645 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1646 if self.present {
1647 Ok(ObjectMeta {
1648 key: key.to_string(),
1649 ..Default::default()
1650 })
1651 } else {
1652 Err(StorageError::NotFound(key.to_string()))
1653 }
1654 }
1655 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1656 Ok(())
1657 }
1658 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1659 Ok(Vec::new())
1660 }
1661 }
1662
1663 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1664 let status = resp.status();
1665 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1666 .await
1667 .unwrap();
1668 let value = if bytes.is_empty() {
1669 serde_json::Value::Null
1670 } else {
1671 serde_json::from_slice(&bytes).unwrap()
1672 };
1673 (status, value)
1674 }
1675
1676 let deploy = DeployStore::new(
1677 Arc::new(FakeStorage { present: true }),
1678 Arc::new(MemoryKv::new()),
1679 );
1680 let v1 = "a".repeat(64);
1681 let v2 = "b".repeat(64);
1682
1683 let (st, body) = body_json(
1685 deploy_function(
1686 State(deploy.clone()),
1687 axum::extract::Extension(crate::ProjectContext::default()),
1688 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1689 axum::extract::Query(DeployFunctionQuery::default()),
1690 Path("greeter".to_string()),
1691 Json(FunctionUpsert {
1692 component: v1.clone(),
1693 config: Default::default(),
1694 lifecycle: Lifecycle::Independent,
1695 }),
1696 )
1697 .await,
1698 )
1699 .await;
1700 assert_eq!(st, StatusCode::OK);
1701 assert_eq!(body["active"], v1);
1702
1703 let (_, body) = body_json(
1705 deploy_function(
1706 State(deploy.clone()),
1707 axum::extract::Extension(crate::ProjectContext::default()),
1708 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1709 axum::extract::Query(DeployFunctionQuery::default()),
1710 Path("greeter".to_string()),
1711 Json(FunctionUpsert {
1712 component: v2.clone(),
1713 config: Default::default(),
1714 lifecycle: Lifecycle::Independent,
1715 }),
1716 )
1717 .await,
1718 )
1719 .await;
1720 assert_eq!(body["active"], v2);
1721 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1722
1723 let (st, body) = body_json(
1725 rollback_function(
1726 State(deploy.clone()),
1727 axum::extract::Extension(crate::ProjectContext::default()),
1728 Path("greeter".to_string()),
1729 Json(RollbackBody { to: v1.clone() }),
1730 )
1731 .await,
1732 )
1733 .await;
1734 assert_eq!(st, StatusCode::OK);
1735 assert_eq!(body["active"], v1);
1736
1737 let resp = rollback_function(
1739 State(deploy.clone()),
1740 axum::extract::Extension(crate::ProjectContext::default()),
1741 Path("greeter".to_string()),
1742 Json(RollbackBody { to: "c".repeat(64) }),
1743 )
1744 .await;
1745 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1746
1747 let (st, body) = body_json(
1749 alias_function(
1750 State(deploy.clone()),
1751 axum::extract::Extension(crate::ProjectContext::default()),
1752 Path(("greeter".to_string(), "prod".to_string())),
1753 Json(AliasBody {
1754 version: v2.clone(),
1755 }),
1756 )
1757 .await,
1758 )
1759 .await;
1760 assert_eq!(st, StatusCode::OK);
1761 assert_eq!(body["aliases"]["prod"], v2);
1762
1763 let (st, _) = body_json(
1765 remove_function(
1766 State(deploy.clone()),
1767 axum::extract::Extension(crate::ProjectContext::default()),
1768 Path("greeter".to_string()),
1769 )
1770 .await,
1771 )
1772 .await;
1773 assert_eq!(st, StatusCode::NO_CONTENT);
1774 assert!(deploy
1775 .get_function(ProjectRef::DEFAULT, "greeter")
1776 .await
1777 .unwrap()
1778 .is_none());
1779
1780 let empty = DeployStore::new(
1782 Arc::new(FakeStorage { present: false }),
1783 Arc::new(MemoryKv::new()),
1784 );
1785 let resp = deploy_function(
1786 State(empty),
1787 axum::extract::Extension(crate::ProjectContext::default()),
1788 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1789 axum::extract::Query(DeployFunctionQuery::default()),
1790 Path("orphan".to_string()),
1791 Json(FunctionUpsert {
1792 component: v1.clone(),
1793 config: Default::default(),
1794 lifecycle: Lifecycle::default(),
1795 }),
1796 )
1797 .await;
1798 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1799 }
1800
1801 struct StubControl {
1805 admits: std::sync::Mutex<Vec<(String, String)>>,
1806 respond: StubJoin,
1807 }
1808 #[derive(Clone, Copy)]
1809 enum StubJoin {
1810 Admit,
1811 Spent,
1812 Invalid,
1813 Revoked,
1814 }
1815
1816 #[async_trait::async_trait]
1817 impl MeshControl for StubControl {
1818 async fn admit(
1819 &self,
1820 mesh_pubkey_hex: &str,
1821 jti: &str,
1822 _proof: &[u8],
1823 _proof_iat: u64,
1824 _now: u64,
1825 _advertise_addr: Option<&str>,
1826 ) -> Result<JoinOutcome, String> {
1827 self.admits
1828 .lock()
1829 .unwrap()
1830 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1831 Ok(match self.respond {
1832 StubJoin::Admit => JoinOutcome::Admitted {
1833 members: vec!["signed-member".to_string()],
1834 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1835 },
1836 StubJoin::Spent => JoinOutcome::TokenSpent,
1837 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1838 StubJoin::Revoked => JoinOutcome::Revoked,
1839 })
1840 }
1841 async fn rotate_key(&self) -> Result<String, String> {
1842 Ok("cafe".to_string())
1843 }
1844 async fn revoke(&self, _node: u64) -> Result<(), String> {
1845 Ok(())
1846 }
1847 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1848 Ok(Vec::new())
1849 }
1850 async fn promote(&self, _node: u64) -> Result<(), String> {
1851 Ok(())
1852 }
1853 }
1854
1855 #[tokio::test]
1859 async fn cluster_join_dispatches_and_maps_outcomes() {
1860 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1861 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1862 let auth = Auth::with_key(keys.public_key(), kv);
1863 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1864 let req = |proof: &str| JoinRequest {
1865 token: token.clone(),
1866 mesh_pubkey: "302a300506032b6570032100feed".into(),
1867 possession_proof: proof.to_string(),
1868 proof_iat: now_unix(),
1869 advertise_addr: Some("https://joiner:7000".into()),
1870 };
1871
1872 let admitter = Arc::new(StubControl {
1874 admits: std::sync::Mutex::new(Vec::new()),
1875 respond: StubJoin::Admit,
1876 });
1877 let resp = cluster_join(
1878 Extension(auth.clone()),
1879 Extension(MeshControlHandle(Some(admitter.clone()))),
1880 Json(req("aa01")),
1881 )
1882 .await;
1883 assert_eq!(resp.status(), StatusCode::OK);
1884 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1885
1886 let spent = Arc::new(StubControl {
1888 admits: std::sync::Mutex::new(Vec::new()),
1889 respond: StubJoin::Spent,
1890 });
1891 assert_eq!(
1892 cluster_join(
1893 Extension(auth.clone()),
1894 Extension(MeshControlHandle(Some(spent))),
1895 Json(req("aa01")),
1896 )
1897 .await
1898 .status(),
1899 StatusCode::CONFLICT
1900 );
1901 let invalid = Arc::new(StubControl {
1902 admits: std::sync::Mutex::new(Vec::new()),
1903 respond: StubJoin::Invalid,
1904 });
1905 assert_eq!(
1906 cluster_join(
1907 Extension(auth.clone()),
1908 Extension(MeshControlHandle(Some(invalid))),
1909 Json(req("aa01")),
1910 )
1911 .await
1912 .status(),
1913 StatusCode::FORBIDDEN
1914 );
1915 let revoked = Arc::new(StubControl {
1917 admits: std::sync::Mutex::new(Vec::new()),
1918 respond: StubJoin::Revoked,
1919 });
1920 assert_eq!(
1921 cluster_join(
1922 Extension(auth.clone()),
1923 Extension(MeshControlHandle(Some(revoked))),
1924 Json(req("aa01")),
1925 )
1926 .await
1927 .status(),
1928 StatusCode::FORBIDDEN
1929 );
1930
1931 let ok = Arc::new(StubControl {
1933 admits: std::sync::Mutex::new(Vec::new()),
1934 respond: StubJoin::Admit,
1935 });
1936 assert_eq!(
1937 cluster_join(
1938 Extension(auth.clone()),
1939 Extension(MeshControlHandle(Some(ok))),
1940 Json(req("not-hex")),
1941 )
1942 .await
1943 .status(),
1944 StatusCode::BAD_REQUEST
1945 );
1946
1947 let none = cluster_join(
1949 Extension(auth),
1950 Extension(MeshControlHandle(None)),
1951 Json(req("aa01")),
1952 )
1953 .await;
1954 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1955 }
1956
1957 #[tokio::test]
1961 async fn bootstrap_mints_the_first_token_once() {
1962 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1963 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1964 let public = keys.public_key();
1965 let deploy = DeployStore::new(
1966 Arc::new(MemStorage::default()),
1967 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1968 );
1969 let secret = "s3cr3t-bootstrap-value";
1970 let gate = BootstrapGate::new(Some(secret));
1971 let issuer = Issuer(Some(keys.clone()));
1972 let bearer = |s: &str| {
1973 let mut h = HeaderMap::new();
1974 h.insert(
1975 AUTHORIZATION,
1976 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1977 );
1978 h
1979 };
1980 let req = || BootstrapRequest {
1981 roles: vec!["admin".to_string()],
1982 ttl_secs: None,
1983 };
1984
1985 let bad = bootstrap_token(
1987 State(deploy.clone()),
1988 Extension(issuer.clone()),
1989 Extension(gate.clone()),
1990 bearer("wrong"),
1991 Json(req()),
1992 )
1993 .await;
1994 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1995
1996 let ok = bootstrap_token(
1998 State(deploy.clone()),
1999 Extension(issuer.clone()),
2000 Extension(gate.clone()),
2001 bearer(secret),
2002 Json(req()),
2003 )
2004 .await;
2005 assert_eq!(ok.status(), StatusCode::CREATED);
2006 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2007 .await
2008 .unwrap();
2009 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2010 let token = json["token"].as_str().unwrap();
2011 let id = json["id"].as_str().unwrap();
2012 let verified = cose::verify(token, &public, now_unix()).unwrap();
2013 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2014 assert!(deploy
2015 .list_token_meta()
2016 .await
2017 .unwrap()
2018 .iter()
2019 .any(|m| m.revocation_id == id));
2020
2021 let reuse = bootstrap_token(
2023 State(deploy.clone()),
2024 Extension(issuer.clone()),
2025 Extension(gate),
2026 bearer(secret),
2027 Json(req()),
2028 )
2029 .await;
2030 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2031
2032 let disabled = bootstrap_token(
2034 State(deploy),
2035 Extension(issuer),
2036 Extension(BootstrapGate(None)),
2037 bearer(secret),
2038 Json(req()),
2039 )
2040 .await;
2041 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2042 }
2043
2044 #[tokio::test]
2047 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2048 let control = Arc::new(StubControl {
2049 admits: std::sync::Mutex::new(Vec::new()),
2050 respond: StubJoin::Admit,
2051 });
2052 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2053 assert_eq!(resp.status(), StatusCode::OK);
2054 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2055 .await
2056 .unwrap();
2057 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2058 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2059
2060 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2061 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2062 }
2063
2064 #[test]
2065 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2066 use boatramp_core::security::SecurityProfile;
2067 let strict = SecurityProfile::MultiTenant.preset();
2068 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2072 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2073 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2074
2075 assert!(gateway_addr_allowed(public, &strict));
2077 assert!(!gateway_addr_allowed(private, &strict));
2078 assert!(!gateway_addr_allowed(loopback, &strict));
2079 assert!(!gateway_addr_allowed(metadata, &strict));
2080
2081 assert!(gateway_addr_allowed(public, &loose));
2084 assert!(gateway_addr_allowed(private, &loose));
2085 assert!(gateway_addr_allowed(loopback, &loose));
2086 assert!(!gateway_addr_allowed(metadata, &loose));
2087 }
2088
2089 #[test]
2090 fn resolve_env_merges_static_and_host_secrets() {
2091 use boatramp_core::config::HandlersSiteConfig;
2092
2093 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2095
2096 let deploy_env = std::collections::BTreeMap::from([
2097 ("GREETING".to_string(), "hi".to_string()),
2098 ("OVERRIDE_ME".to_string(), "static".to_string()),
2099 ]);
2100 let site_handlers = HandlersSiteConfig {
2101 enabled: true,
2102 secrets: std::collections::BTreeMap::from([
2103 (
2105 "SECRET_TOKEN".to_string(),
2106 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2107 ),
2108 (
2109 "OVERRIDE_ME".to_string(),
2110 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2111 ),
2112 (
2113 "MISSING".to_string(),
2114 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2115 ),
2116 ]),
2117 ..Default::default()
2118 };
2119 let env = resolve_env("blog", &deploy_env, &site_handlers);
2120
2121 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2125 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2126 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2127 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2128
2129 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2130 }
2131
2132 fn req() -> Request {
2133 Request::builder()
2134 .uri("/")
2135 .header(header::HOST, "example.com")
2136 .body(Body::empty())
2137 .unwrap()
2138 }
2139
2140 #[test]
2141 fn forwarded_headers_set_standard_triple() {
2142 let mut request = req();
2143 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2144 let h = request.headers();
2145 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2146 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2147 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2148 }
2149
2150 #[test]
2151 fn forwarded_for_overwrites_spoofed_value() {
2152 let mut request = Request::builder()
2155 .uri("/")
2156 .header(header::HOST, "example.com")
2157 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2158 .body(Body::empty())
2159 .unwrap();
2160 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2161 let values: Vec<_> = request
2162 .headers()
2163 .get_all("x-forwarded-for")
2164 .iter()
2165 .collect();
2166 assert_eq!(values.len(), 1);
2167 assert_eq!(values[0], "203.0.113.7");
2168 }
2169
2170 #[test]
2171 fn forwarded_proto_preserves_upstream_tls() {
2172 let mut request = Request::builder()
2174 .uri("/")
2175 .header(header::HOST, "example.com")
2176 .header("x-forwarded-proto", "https")
2177 .body(Body::empty())
2178 .unwrap();
2179 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2180 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2181 }
2182
2183 #[test]
2184 fn forwarded_host_absent_when_no_host_header() {
2185 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2186 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2187 assert!(request.headers().get("x-forwarded-host").is_none());
2188 assert_eq!(
2189 request.headers().get("x-forwarded-for").unwrap(),
2190 "203.0.113.7"
2191 );
2192 }
2193
2194 use boatramp_core::kv::{KvStore, MemoryKv};
2197 use boatramp_core::messaging::{LogMessaging, Messaging};
2198 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2199
2200 const EVENT_CONSUMER: &[u8] =
2201 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2202
2203 #[derive(Default)]
2204 struct MemStorage {
2205 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2206 }
2207
2208 #[async_trait::async_trait]
2209 impl boatramp_core::Storage for MemStorage {
2210 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2211 let bytes = self
2212 .objects
2213 .lock()
2214 .unwrap()
2215 .get(key)
2216 .cloned()
2217 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2218 let body: ByteStream =
2219 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2220 Ok(GetObject {
2221 meta: ObjectMeta {
2222 key: key.to_string(),
2223 ..Default::default()
2224 },
2225 body,
2226 })
2227 }
2228 async fn get_range(
2229 &self,
2230 key: &str,
2231 _: u64,
2232 _: Option<u64>,
2233 ) -> Result<GetObject, StorageError> {
2234 self.get(key).await
2235 }
2236 async fn put(
2237 &self,
2238 key: &str,
2239 mut body: ByteStream,
2240 _: PutMeta,
2241 ) -> Result<ObjectMeta, StorageError> {
2242 use futures::StreamExt;
2243 let mut buf = Vec::new();
2244 while let Some(chunk) = body.next().await {
2245 buf.extend_from_slice(&chunk?);
2246 }
2247 self.objects.lock().unwrap().insert(key.to_string(), buf);
2248 Ok(ObjectMeta {
2249 key: key.to_string(),
2250 ..Default::default()
2251 })
2252 }
2253 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2254 self.objects
2255 .lock()
2256 .unwrap()
2257 .get(key)
2258 .map(|_| ObjectMeta {
2259 key: key.to_string(),
2260 ..Default::default()
2261 })
2262 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2263 }
2264 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2265 self.objects.lock().unwrap().remove(key);
2266 Ok(())
2267 }
2268 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2269 Ok(Vec::new())
2270 }
2271 }
2272
2273 fn observed_state(
2275 workload: &str,
2276 healthy: bool,
2277 phase: boatramp_core::compute::ReplicaPhase,
2278 ) -> boatramp_core::compute::ObservedInstance {
2279 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2280 boatramp_core::compute::ObservedInstance {
2281 handle: InstanceHandle {
2282 workload: workload.into(),
2283 replica: 0,
2284 backend_ref: "ref-0".into(),
2285 },
2286 node: 1,
2287 backend: "vmm".into(),
2288 endpoint: Endpoint {
2289 scheme: Scheme::Http,
2290 host: "10.0.0.2".into(),
2291 port: 80,
2292 },
2293 region: None,
2294 healthy,
2295 phase,
2296 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2297 workload: workload.into(),
2298 replica: 0,
2299 data_ref: "snap-0".into(),
2300 }),
2301 }
2302 }
2303
2304 #[tokio::test]
2305 async fn has_parked_replica_detects_a_zeroed_replica() {
2306 use boatramp_core::compute::ReplicaPhase;
2307 let storage = Arc::new(MemStorage::default());
2308 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2309 let deploy = DeployStore::new(storage, kv);
2310
2311 assert!(!has_parked_replica(&deploy, "w").await);
2313 deploy
2315 .set_replica_state(
2316 ProjectRef::DEFAULT,
2317 &observed_state("w", true, ReplicaPhase::Running),
2318 )
2319 .await
2320 .unwrap();
2321 assert!(!has_parked_replica(&deploy, "w").await);
2322 deploy
2324 .set_replica_state(
2325 ProjectRef::DEFAULT,
2326 &observed_state("w", false, ReplicaPhase::Zero),
2327 )
2328 .await
2329 .unwrap();
2330 assert!(has_parked_replica(&deploy, "w").await);
2331 }
2332
2333 #[tokio::test]
2334 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2335 use boatramp_core::compute::ReplicaPhase;
2336 let storage = Arc::new(MemStorage::default());
2337 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2338 let deploy = DeployStore::new(storage, kv);
2339
2340 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2342 assert!(empty.is_empty());
2343
2344 deploy
2346 .set_replica_state(
2347 ProjectRef::DEFAULT,
2348 &observed_state("w", true, ReplicaPhase::Running),
2349 )
2350 .await
2351 .unwrap();
2352 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2353 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2354 }
2355
2356 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2360 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2361 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2362 let storage = Arc::new(MemStorage::default());
2363 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2364 let mq = LogMessaging::new(storage, kv.clone());
2365 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2366 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2367 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2368 let topic = "blog/orders/created";
2369
2370 for _ in 0..3 {
2372 mq.publish(topic, b"ok").await.unwrap();
2373 }
2374 loop {
2375 let acked = dispatch_consumer_batch(
2376 &engine,
2377 &mq,
2378 &metrics::Metrics::default(),
2379 "blog",
2380 topic,
2381 "blog/",
2382 "",
2383 boatramp_core::messaging::StartPosition::Latest,
2384 &hash,
2385 EVENT_CONSUMER,
2386 &bindings,
2387 Limits::default(),
2388 Duration::from_secs(30),
2389 5,
2390 10,
2391 )
2392 .await;
2393 if acked == 0 {
2394 break;
2395 }
2396 }
2397 assert_eq!(
2398 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2399 Some(b"3".to_vec())
2400 );
2401
2402 mq.publish(topic, b"fail").await.unwrap();
2405 for _ in 0..5 {
2406 dispatch_consumer_batch(
2407 &engine,
2408 &mq,
2409 &metrics::Metrics::default(),
2410 "blog",
2411 topic,
2412 "blog/",
2413 "",
2414 boatramp_core::messaging::StartPosition::Latest,
2415 &hash,
2416 EVENT_CONSUMER,
2417 &bindings,
2418 Limits::default(),
2419 Duration::ZERO,
2420 2,
2421 10,
2422 )
2423 .await;
2424 }
2425 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2426 assert_eq!(
2428 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2429 Some(b"3".to_vec())
2430 );
2431 }
2432
2433 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2438 async fn consumer_groups_fan_out_through_the_dispatcher() {
2439 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2440 let storage = Arc::new(MemStorage::default());
2441 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2442 let mq = LogMessaging::new(storage, kv.clone());
2443 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2444 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2445 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2446 let topic = "blog/orders/created";
2447 let start = boatramp_core::messaging::StartPosition::Latest;
2448
2449 for g in ["billing", "audit"] {
2452 let n = dispatch_consumer_batch(
2453 &engine,
2454 &mq,
2455 &metrics::Metrics::default(),
2456 "blog",
2457 topic,
2458 "blog/",
2459 g,
2460 start,
2461 &hash,
2462 EVENT_CONSUMER,
2463 &bindings,
2464 Limits::default(),
2465 Duration::from_secs(30),
2466 5,
2467 10,
2468 )
2469 .await;
2470 assert_eq!(n, 0, "no events yet for group {g}");
2471 }
2472 mq.publish(topic, b"ok").await.unwrap();
2473
2474 for g in ["billing", "audit"] {
2476 let n = dispatch_consumer_batch(
2477 &engine,
2478 &mq,
2479 &metrics::Metrics::default(),
2480 "blog",
2481 topic,
2482 "blog/",
2483 g,
2484 start,
2485 &hash,
2486 EVENT_CONSUMER,
2487 &bindings,
2488 Limits::default(),
2489 Duration::from_secs(30),
2490 5,
2491 10,
2492 )
2493 .await;
2494 assert_eq!(n, 1, "group {g} should receive the message");
2495 }
2496 assert_eq!(
2498 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2499 Some(b"2".to_vec())
2500 );
2501 }
2502
2503 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2507 async fn scheduler_runs_current_consumers_not_previews() {
2508 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2509 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2510 use boatramp_handlers::{HandlerEngine, Limits};
2511 use futures::StreamExt;
2512
2513 let storage = Arc::new(MemStorage::default());
2514 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2515 let deploy = DeployStore::new(storage.clone(), kv.clone());
2516 let messaging: Arc<dyn Messaging> =
2517 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2518
2519 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2521 let stream: ByteStream =
2522 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2523 .boxed();
2524 deploy.put_blob(&hash, stream).await.unwrap();
2525 let mut files = std::collections::BTreeMap::new();
2526 files.insert(
2527 "consumer.wasm".to_string(),
2528 FileEntry {
2529 hash: hash.clone(),
2530 size: EVENT_CONSUMER.len() as u64,
2531 content_type: None,
2532 variants: std::collections::BTreeMap::new(),
2533 },
2534 );
2535 let manifest = Manifest {
2536 files,
2537 config: DeployConfig {
2538 consumers: vec![ConsumerConfig {
2539 topic: "orders/created".into(),
2540 component: "consumer.wasm".into(),
2541 imports: vec!["wasi:keyvalue".into()],
2542 group: String::new(),
2543 start: Default::default(),
2544 }],
2545 ..Default::default()
2546 },
2547 ..Default::default()
2548 };
2549 let id = deploy.put_manifest(&manifest).await.unwrap();
2550 deploy
2551 .activate(ProjectRef::DEFAULT, "blog", &id)
2552 .await
2553 .unwrap();
2554 deploy
2555 .set_site_config(
2556 ProjectRef::DEFAULT,
2557 "blog",
2558 &SiteConfig {
2559 handlers: Some(HandlersSiteConfig {
2560 enabled: true,
2561 allow_imports: vec!["wasi:keyvalue".into()],
2562 ..Default::default()
2563 }),
2564 ..Default::default()
2565 },
2566 )
2567 .await
2568 .unwrap();
2569
2570 messaging
2572 .publish("blog/orders/created", b"live")
2573 .await
2574 .unwrap();
2575 messaging
2576 .publish("blog/_preview/abc/orders/created", b"preview")
2577 .await
2578 .unwrap();
2579
2580 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2581 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2582 let inner = rt.inner.clone().unwrap();
2583 let mut cache = std::collections::HashMap::new();
2584 let mut crons = std::collections::HashMap::new();
2585 let mut sweep = std::collections::HashMap::new();
2586 let now = CronNow {
2587 minute: 0,
2588 hour: 0,
2589 dom: 1,
2590 month: 1,
2591 dow: 0,
2592 minute_stamp: 0,
2593 };
2594 for _ in 0..3 {
2595 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2596 .await
2597 .unwrap();
2598 }
2599
2600 assert_eq!(
2602 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2603 Some(b"1".to_vec())
2604 );
2605 assert_eq!(
2608 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2609 .await
2610 .unwrap(),
2611 None
2612 );
2613 }
2614
2615 const KV_COUNTER: &[u8] =
2620 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2621
2622 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2627 async fn function_invoker_runs_target_buffers_and_meters() {
2628 use boatramp_core::deploy::DeployStore;
2629 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2630 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2631 use futures::StreamExt;
2632
2633 const HTTP_200: &[u8] =
2636 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2637
2638 let storage = Arc::new(MemStorage::default());
2639 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2640 let deploy = DeployStore::new(storage.clone(), kv.clone());
2641
2642 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2643 let stream: ByteStream =
2644 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2645 deploy.put_blob(&hash, stream).await.unwrap();
2646 let function = Function {
2647 name: "target".into(),
2648 owner: Owner::Project("default".into()),
2649 versions: vec![FunctionVersion {
2650 id: "v1".into(),
2651 component: hash.clone(),
2652 created: 0,
2653 lifecycle: Lifecycle::Independent,
2654 }],
2655 active: "v1".into(),
2656 aliases: Default::default(),
2657 config: Default::default(),
2658 };
2659 deploy
2660 .put_function(ProjectRef::DEFAULT, &function)
2661 .await
2662 .unwrap();
2663
2664 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2665 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2666 rt.set_invoker(deploy.clone());
2667 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2668
2669 let request = || InvokeRequest {
2670 method: "GET".into(),
2671 path: "/".into(),
2672 headers: vec![],
2673 body: vec![],
2674 };
2675
2676 let response = invoker.invoke("target", request(), 1).await.unwrap();
2678 assert_eq!(response.status, 200);
2679
2680 let metering = deploy
2682 .get_metering(ProjectRef::DEFAULT, "target")
2683 .await
2684 .unwrap()
2685 .unwrap();
2686 assert_eq!(metering.invocations, 1);
2687
2688 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2690 assert!(matches!(err, InvokeError::NotFound));
2691 }
2692
2693 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2699 async fn federation_runner_enforces_the_safelist_before_planning() {
2700 use boatramp_core::deploy::DeployStore;
2701 use boatramp_core::project::ProjectRef;
2702 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2703
2704 let storage = Arc::new(MemStorage::default());
2705 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2706 let deploy = DeployStore::new(storage.clone(), kv.clone());
2707 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2708 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2709 rt.set_invoker(deploy.clone());
2710 let runner = rt
2711 .inner
2712 .as_ref()
2713 .unwrap()
2714 .federation_runner
2715 .get()
2716 .unwrap()
2717 .scoped(ProjectRef::new("default"));
2718
2719 let req = |query: &str| GraphqlRequest {
2720 query: Some(query.to_string()),
2721 persisted_hash: None,
2722 variables: "{}".to_string(),
2723 operation_name: None,
2724 authorization: None,
2725 };
2726
2727 assert!(matches!(
2729 runner.run(req("{ me { id } }"), 1).await,
2730 Err(SupergraphRunError::NotSafelisted)
2731 ));
2732
2733 let query = "{ me { id } }";
2736 let hash = crate::graphql_apq::sha256_hex(query);
2737 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2738 .await
2739 .unwrap();
2740 assert!(matches!(
2741 runner.run(req(query), 1).await,
2742 Err(SupergraphRunError::PlanFailed(_))
2743 ));
2744
2745 let persisted = GraphqlRequest {
2747 query: None,
2748 persisted_hash: Some("deadbeef".to_string()),
2749 variables: "{}".to_string(),
2750 operation_name: None,
2751 authorization: None,
2752 };
2753 assert!(matches!(
2754 runner.run(persisted, 1).await,
2755 Err(SupergraphRunError::NotSafelisted)
2756 ));
2757 }
2758
2759 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2765 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2766 use crate::scheduler::{run_scheduler_tick, CronNow};
2767 use boatramp_core::deploy::DeployStore;
2768 use boatramp_core::function::{
2769 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2770 };
2771 use boatramp_handlers::{HandlerEngine, Limits};
2772 use futures::StreamExt;
2773
2774 const HTTP_200: &[u8] =
2775 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2776
2777 let storage = Arc::new(MemStorage::default());
2778 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2779 let deploy = DeployStore::new(storage.clone(), kv.clone());
2780
2781 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2782 let stream: ByteStream =
2783 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2784 deploy.put_blob(&hash, stream).await.unwrap();
2785
2786 let acme = ProjectRef::new("acme");
2788 let function = Function {
2789 name: "worker".into(),
2790 owner: Owner::Project("acme".into()),
2791 versions: vec![FunctionVersion {
2792 id: "v1".into(),
2793 component: hash.clone(),
2794 created: 0,
2795 lifecycle: Lifecycle::Independent,
2796 }],
2797 active: "v1".into(),
2798 aliases: Default::default(),
2799 config: Default::default(),
2800 };
2801 deploy.put_function(acme, &function).await.unwrap();
2802 let inv = Invocation {
2803 id: "inv1".into(),
2804 function: "worker".into(),
2805 version: "v1".into(),
2806 mode: InvokeMode::Async,
2807 status: InvocationStatus::Queued,
2808 idempotency_key: None,
2809 attempts: 0,
2810 lease_expires: None,
2811 request_b64: None,
2812 request_content_type: None,
2813 result: None,
2814 created: 0,
2815 updated: 0,
2816 };
2817 deploy.put_invocation(acme, &inv).await.unwrap();
2818
2819 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2820 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2821 let inner = rt.inner.as_ref().unwrap();
2822
2823 let mut wasm_cache = std::collections::HashMap::new();
2826 let mut cron_state = std::collections::HashMap::new();
2827 let mut sweep = std::collections::HashMap::new();
2828 let now = CronNow {
2829 minute: 0,
2830 hour: 0,
2831 dom: 1,
2832 month: 1,
2833 dow: 0,
2834 minute_stamp: 0,
2835 };
2836 run_scheduler_tick(
2837 inner,
2838 &deploy,
2839 &mut wasm_cache,
2840 &mut cron_state,
2841 &mut sweep,
2842 now,
2843 )
2844 .await
2845 .unwrap();
2846
2847 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2850 assert_eq!(settled.status, InvocationStatus::Succeeded);
2852 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2854 assert_eq!(metering.invocations, 1);
2855 assert!(deploy
2857 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2858 .await
2859 .unwrap()
2860 .is_none());
2861 assert!(deploy
2862 .get_metering(ProjectRef::DEFAULT, "worker")
2863 .await
2864 .unwrap()
2865 .is_none());
2866 }
2867
2868 #[cfg(feature = "handlers")]
2872 async fn poll_invocation_settled(
2873 deploy: &boatramp_core::deploy::DeployStore,
2874 project: ProjectRef<'_>,
2875 function: &str,
2876 id: &str,
2877 ) -> boatramp_core::function::Invocation {
2878 use boatramp_core::function::InvocationStatus;
2879 for _ in 0..200 {
2880 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2881 if matches!(
2882 inv.status,
2883 InvocationStatus::Succeeded | InvocationStatus::Failed
2884 ) {
2885 return inv;
2886 }
2887 }
2888 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2889 }
2890 panic!("invocation {function}/{id} never settled");
2891 }
2892
2893 #[cfg(feature = "handlers")]
2898 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2899 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2900 use crate::scheduler::{run_scheduler_tick, CronNow};
2901 use boatramp_core::deploy::DeployStore;
2902 use boatramp_core::function::{
2903 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2904 };
2905 use boatramp_handlers::{HandlerEngine, Limits};
2906 use futures::StreamExt;
2907
2908 const HTTP_200: &[u8] =
2909 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2910
2911 let storage = Arc::new(MemStorage::default());
2912 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2913 let deploy = DeployStore::new(storage.clone(), kv.clone());
2914 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2915 let stream: ByteStream =
2916 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2917 deploy.put_blob(&hash, stream).await.unwrap();
2918
2919 let function = Function {
2920 name: "worker".into(),
2921 owner: Owner::Project("default".into()),
2922 versions: vec![FunctionVersion {
2923 id: "v1".into(),
2924 component: hash.clone(),
2925 created: 0,
2926 lifecycle: Lifecycle::Independent,
2927 }],
2928 active: "v1".into(),
2929 aliases: Default::default(),
2930 config: Default::default(),
2931 };
2932 deploy
2933 .put_function(ProjectRef::DEFAULT, &function)
2934 .await
2935 .unwrap();
2936
2937 let base = Invocation {
2940 id: String::new(),
2941 function: "worker".into(),
2942 version: "v1".into(),
2943 mode: InvokeMode::Async,
2944 status: InvocationStatus::Running,
2945 idempotency_key: None,
2946 attempts: 1,
2947 lease_expires: None,
2948 request_b64: None,
2949 request_content_type: None,
2950 result: None,
2951 created: 0,
2952 updated: 0,
2953 };
2954 let orphan = Invocation {
2955 id: "orphan".into(),
2956 lease_expires: Some(1),
2957 ..base.clone()
2958 };
2959 deploy
2960 .put_invocation(ProjectRef::DEFAULT, &orphan)
2961 .await
2962 .unwrap();
2963 let live = Invocation {
2964 id: "live".into(),
2965 lease_expires: Some(u64::MAX),
2966 ..base.clone()
2967 };
2968 deploy
2969 .put_invocation(ProjectRef::DEFAULT, &live)
2970 .await
2971 .unwrap();
2972
2973 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2974 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2975 let inner = rt.inner.as_ref().unwrap();
2976
2977 let now = CronNow {
2978 minute: 0,
2979 hour: 0,
2980 dom: 1,
2981 month: 1,
2982 dow: 0,
2983 minute_stamp: 0,
2984 };
2985 let mut wasm_cache = std::collections::HashMap::new();
2986 let mut cron_state = std::collections::HashMap::new();
2987 let mut sweep = std::collections::HashMap::new();
2988 run_scheduler_tick(
2989 inner,
2990 &deploy,
2991 &mut wasm_cache,
2992 &mut cron_state,
2993 &mut sweep,
2994 now,
2995 )
2996 .await
2997 .unwrap();
2998
2999 let settled =
3001 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3002 assert_eq!(settled.status, InvocationStatus::Succeeded);
3003 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3004 assert_eq!(
3005 settled.lease_expires, None,
3006 "a settled invocation drops its lease"
3007 );
3008 let live_after = deploy
3010 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3011 .await
3012 .unwrap()
3013 .unwrap();
3014 assert_eq!(live_after.status, InvocationStatus::Running);
3015 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3016 assert_eq!(live_after.lease_expires, Some(u64::MAX));
3017 }
3018
3019 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3034 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3035 use boatramp_core::deploy::DeployStore;
3036 use boatramp_core::function::{
3037 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3038 };
3039 use boatramp_handlers::{HandlerEngine, Limits};
3040 use futures::StreamExt;
3041
3042 let storage = Arc::new(MemStorage::default());
3043 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3044 let deploy = DeployStore::new(storage.clone(), kv.clone());
3045
3046 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3049 let stream: ByteStream =
3050 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3051 deploy.put_blob(&hash, stream).await.unwrap();
3052
3053 let store = Function {
3058 name: "store".into(),
3059 owner: Owner::Project("default".into()),
3060 versions: vec![FunctionVersion {
3061 id: "v1".into(),
3062 component: hash.clone(),
3063 created: 0,
3064 lifecycle: Lifecycle::Independent,
3065 }],
3066 active: "v1".into(),
3067 aliases: Default::default(),
3068 config: FunctionConfig {
3069 imports: vec!["wasi:keyvalue".into()],
3070 ..Default::default()
3071 },
3072 };
3073 let acme = ProjectRef::new("acme");
3074 let globex = ProjectRef::new("globex");
3075
3076 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3077 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3078 let inner = rt.inner.as_ref().unwrap();
3079
3080 let request = || {
3081 axum::http::Request::builder()
3082 .method("GET")
3083 .uri("/")
3084 .body(axum::body::Body::empty())
3085 .unwrap()
3086 };
3087
3088 let component = store.resolve(&store.active).unwrap().to_owned();
3091 for project in [acme, globex, ProjectRef::DEFAULT] {
3092 let (response, _) = execute_function(
3093 inner,
3094 &deploy,
3095 project,
3096 &store,
3097 &component,
3098 request(),
3099 0,
3100 boatramp_handlers::Lane::Sync,
3101 )
3102 .await;
3103 assert!(response.status().is_success(), "invocation should succeed");
3104 }
3105
3106 assert_eq!(
3109 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3110 Some(b"1".to_vec()),
3111 "acme's write must be tenant-qualified"
3112 );
3113 assert_eq!(
3114 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3115 Some(b"1".to_vec()),
3116 "globex's write must be tenant-qualified"
3117 );
3118 assert_eq!(
3119 kv.get("hkv/fn/store/hits").await.unwrap(),
3120 Some(b"1".to_vec()),
3121 "the default project must keep the byte-identical pre-project key"
3122 );
3123 }
3126
3127 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3131 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3132 use boatramp_core::config::{
3133 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3134 };
3135 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3136 use boatramp_handlers::{HandlerEngine, Limits};
3137 use futures::StreamExt;
3138 use std::sync::atomic::Ordering;
3139
3140 let storage = Arc::new(MemStorage::default());
3141 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3142 let deploy = DeployStore::new(storage.clone(), kv.clone());
3143
3144 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3145 let stream: ByteStream =
3146 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3147 deploy.put_blob(&hash, stream).await.unwrap();
3148 let mut files = std::collections::BTreeMap::new();
3149 files.insert(
3150 "counter.wasm".to_string(),
3151 FileEntry {
3152 hash: hash.clone(),
3153 size: KV_COUNTER.len() as u64,
3154 content_type: None,
3155 variants: std::collections::BTreeMap::new(),
3156 },
3157 );
3158 let manifest = Manifest {
3159 files,
3160 config: DeployConfig {
3161 handlers: vec![HandlerConfig {
3162 route: "/".into(),
3163 methods: Vec::new(),
3164 component: "counter.wasm".into(),
3165 imports: vec!["wasi:keyvalue".into()],
3166 streaming: false,
3167 limits: None,
3168 env: std::collections::BTreeMap::new(),
3169 invoke_targets: Vec::new(),
3170 }],
3171 crons: vec![CronConfig {
3172 schedule: "* * * * *".into(),
3173 route: "/".into(),
3174 overlap: Overlap::Skip,
3175 }],
3176 ..Default::default()
3177 },
3178 ..Default::default()
3179 };
3180 let id = deploy.put_manifest(&manifest).await.unwrap();
3181 deploy
3182 .activate(ProjectRef::DEFAULT, "blog", &id)
3183 .await
3184 .unwrap();
3185 deploy
3186 .set_site_config(
3187 ProjectRef::DEFAULT,
3188 "blog",
3189 &SiteConfig {
3190 handlers: Some(HandlersSiteConfig {
3191 enabled: true,
3192 allow_imports: vec!["wasi:keyvalue".into()],
3193 ..Default::default()
3194 }),
3195 ..Default::default()
3196 },
3197 )
3198 .await
3199 .unwrap();
3200
3201 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3202 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3203 let inner = rt.inner.clone().unwrap();
3204 let mut wasm = std::collections::HashMap::new();
3205 let mut crons = std::collections::HashMap::new();
3206 let mut sweep = std::collections::HashMap::new();
3207 let at = |stamp| CronNow {
3208 minute: 0,
3209 hour: 0,
3210 dom: 1,
3211 month: 1,
3212 dow: 0,
3213 minute_stamp: stamp,
3214 };
3215
3216 let (_, handles) =
3218 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3219 .await
3220 .unwrap();
3221 for h in handles {
3222 h.await.unwrap();
3223 }
3224 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3225
3226 let (_, handles) =
3228 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3229 .await
3230 .unwrap();
3231 assert!(handles.is_empty());
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(101))
3237 .await
3238 .unwrap();
3239 for h in handles {
3240 h.await.unwrap();
3241 }
3242 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3243
3244 crons
3248 .get("default|blog|cron|0")
3249 .unwrap()
3250 .running
3251 .store(true, Ordering::Release);
3252 let (_, handles) =
3253 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3254 .await
3255 .unwrap();
3256 assert!(handles.is_empty());
3257 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3258 }
3259
3260 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3264 async fn cron_leader_gate_suppresses_crons_off_leader() {
3265 use boatramp_core::config::{
3266 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3267 };
3268 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3269 use boatramp_handlers::{HandlerEngine, Limits};
3270 use futures::StreamExt;
3271
3272 let storage = Arc::new(MemStorage::default());
3273 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3274 let deploy = DeployStore::new(storage.clone(), kv.clone());
3275
3276 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3277 let stream: ByteStream =
3278 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3279 deploy.put_blob(&hash, stream).await.unwrap();
3280 let mut files = std::collections::BTreeMap::new();
3281 files.insert(
3282 "counter.wasm".to_string(),
3283 FileEntry {
3284 hash: hash.clone(),
3285 size: KV_COUNTER.len() as u64,
3286 content_type: None,
3287 variants: std::collections::BTreeMap::new(),
3288 },
3289 );
3290 let manifest = Manifest {
3291 files,
3292 config: DeployConfig {
3293 handlers: vec![HandlerConfig {
3294 route: "/".into(),
3295 methods: Vec::new(),
3296 component: "counter.wasm".into(),
3297 imports: vec!["wasi:keyvalue".into()],
3298 streaming: false,
3299 limits: None,
3300 env: std::collections::BTreeMap::new(),
3301 invoke_targets: Vec::new(),
3302 }],
3303 crons: vec![CronConfig {
3304 schedule: "* * * * *".into(),
3305 route: "/".into(),
3306 overlap: Overlap::Skip,
3307 }],
3308 ..Default::default()
3309 },
3310 ..Default::default()
3311 };
3312 let id = deploy.put_manifest(&manifest).await.unwrap();
3313 deploy
3314 .activate(ProjectRef::DEFAULT, "blog", &id)
3315 .await
3316 .unwrap();
3317 deploy
3318 .set_site_config(
3319 ProjectRef::DEFAULT,
3320 "blog",
3321 &SiteConfig {
3322 handlers: Some(HandlersSiteConfig {
3323 enabled: true,
3324 allow_imports: vec!["wasi:keyvalue".into()],
3325 ..Default::default()
3326 }),
3327 ..Default::default()
3328 },
3329 )
3330 .await
3331 .unwrap();
3332
3333 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3334 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3335 rt.set_cron_leader_gate(Arc::new(|| false));
3337 let inner = rt.inner.clone().unwrap();
3338 let mut wasm = std::collections::HashMap::new();
3339 let mut crons = std::collections::HashMap::new();
3340 let mut sweep = std::collections::HashMap::new();
3341 let now = CronNow {
3342 minute: 0,
3343 hour: 0,
3344 dom: 1,
3345 month: 1,
3346 dow: 0,
3347 minute_stamp: 100,
3348 };
3349
3350 let (_, handles) =
3351 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3352 .await
3353 .unwrap();
3354 assert!(handles.is_empty(), "a non-leader must not fire crons");
3356 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3357 }
3358
3359 #[tokio::test]
3366 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3367 use boatramp_core::config::HandlersSiteConfig;
3368 use boatramp_core::project::ProjectRef;
3369 use boatramp_handlers::{HandlerEngine, Limits};
3370
3371 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3372 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3373 let sql_dir =
3375 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3376 let _ = std::fs::remove_dir_all(&sql_dir);
3377 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3378 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3379
3380 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3381 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3382 let inner = rt.inner.as_ref().unwrap();
3383
3384 let site = HandlersSiteConfig {
3386 enabled: true,
3387 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3388 ..Default::default()
3389 };
3390 let env = std::collections::BTreeMap::new();
3391 let build = |imports: &[&str]| {
3392 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3393 let site = &site;
3394 let env = &env;
3395 async move {
3396 crate::handler_dispatch::build_bindings(
3397 inner,
3398 ProjectRef::new("default"),
3399 "shop",
3400 "shop",
3401 None,
3402 &imports,
3403 site,
3404 env,
3405 &[],
3406 0,
3407 None,
3408 )
3409 .await
3410 .sql_database_names()
3411 }
3412 };
3413
3414 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3417 assert_eq!(
3419 build(&["sql", "sql:*"]).await,
3420 vec!["", "privileged", "product"]
3421 );
3422 assert!(build(&["sql:secret"]).await.is_empty());
3424 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3426
3427 let _ = std::fs::remove_dir_all(&sql_dir);
3428 }
3429}