1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, create_deployment, current_deployment, delete_compute,
44 delete_site, get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
45 list_aliases, list_compute, list_deployments, list_sites, prune_delete, prune_report, put_blob,
46 put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
47 scrub_blobs, set_alias,
48};
49#[cfg(feature = "handlers")]
50pub(crate) use admin_api::{
51 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
52 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
53 put_graphql_subgraph, register_graphql_safelist,
54};
55mod auth;
56#[cfg(feature = "console")]
57pub mod console;
58mod content;
59mod control_api;
60#[cfg(feature = "compression")]
61pub(crate) use content::maybe_compress;
62pub(crate) use content::multipart_byteranges;
63pub(crate) use content::{
64 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
65};
66pub(crate) use control_api::{
67 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
68 cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
69 list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
70};
71#[cfg(all(test, feature = "handlers"))]
72use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
73mod domain_verify;
74pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
75pub mod envelope;
76#[cfg(feature = "handlers")]
77mod graphql_apq;
78#[cfg(feature = "handlers")]
79mod graphql_cache;
80#[cfg(feature = "handlers")]
81mod graphql_data;
82#[cfg(feature = "handlers")]
83mod graphql_federation;
84#[cfg(feature = "handlers")]
85mod graphql_gateway;
86#[cfg(feature = "handlers")]
87mod graphql_graphiql;
88#[cfg(feature = "handlers")]
89mod graphql_guard;
90#[cfg(feature = "handlers")]
91mod graphql_plan;
92#[cfg(feature = "handlers")]
93mod graphql_registry;
94#[cfg(feature = "handlers")]
95mod graphql_subscription;
96#[cfg(feature = "handlers")]
97mod handler_cache;
98#[cfg(feature = "handlers")]
99mod handler_dispatch;
100#[cfg(feature = "handlers")]
101pub(crate) use handler_dispatch::{
102 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
103 read_blob_fully,
104};
105#[cfg(all(feature = "handlers", test))]
106use handler_dispatch::{resolve_env, set_forwarded_headers};
107mod function_api;
108pub(crate) use function_api::{
109 alias_function, deploy_function, list_functions, remove_function, rollback_function,
110};
111#[cfg(all(test, feature = "handlers"))]
112use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
113mod gateway;
114mod host;
115pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
116#[cfg(feature = "http3")]
117mod http3;
118mod limits;
119#[cfg(feature = "handlers")]
120mod logs;
121#[cfg(feature = "handlers")]
122mod metrics;
123#[cfg(feature = "oidc")]
124mod oidc;
125mod operator;
126pub(crate) use operator::prometheus_metrics;
127#[cfg(feature = "handlers")]
128pub(crate) use operator::{
129 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
130};
131mod proxy;
132pub use proxy::spawn_compute_reconcile;
133pub(crate) use proxy::{
134 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
135 proxy, COMPUTE_WAKE_TIMEOUT,
136};
137#[cfg(feature = "handlers")]
139pub(crate) use proxy::is_upgrade_request;
140#[cfg(all(test, feature = "handlers"))]
141use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
142mod project_api;
143pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
144mod project_scope;
145pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
146mod ratelimit;
147mod routes;
148pub use routes::{router, router_with};
149#[cfg(feature = "mcp")]
150mod mcp_http;
151#[cfg(feature = "handlers")]
152mod scheduler;
153mod serve_pipeline;
154pub use serve_pipeline::http_redirect_router;
155#[cfg(all(test, feature = "handlers"))]
156use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
157pub(crate) use serve_pipeline::{
158 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
159 BootstrapAttestation,
160};
161pub mod signer;
164mod srvmetrics;
165#[cfg(all(feature = "handlers", test))]
166use scheduler::run_scheduler_tick;
167#[cfg(feature = "handlers")]
168pub(crate) use scheduler::{
169 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
170};
171#[cfg(feature = "handlers")]
172use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
173#[cfg(feature = "handlers")]
174mod function_runtime;
175#[cfg(feature = "handlers")]
176pub(crate) use function_runtime::{
177 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
178 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
179 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
180 put_trigger_handler, webhook_ingress,
181};
182#[cfg(feature = "handlers")]
183mod stream;
184#[cfg(feature = "handlers")]
185mod workflow;
186pub use auth::{require_auth, Auth};
187#[cfg(feature = "http3")]
188pub use http3::{
189 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
190 Http3Error,
191};
192pub use limits::{ServerLimits, UploadGuard};
193#[cfg(feature = "oidc")]
194pub use oidc::{OidcConfig, OidcError, OidcVerifier};
195use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
196#[cfg(feature = "handlers")]
197pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
198#[cfg(feature = "handlers")]
199pub(crate) use workflow::{
200 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
201 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
202};
203pub use srvmetrics::{server_metrics, ServerMetrics};
206
207#[derive(Clone, Default)]
212pub struct HandlerRuntime {
213 #[cfg(feature = "handlers")]
214 inner: Option<Arc<HandlerRuntimeInner>>,
215}
216
217#[cfg(feature = "handlers")]
218struct HandlerRuntimeInner {
219 engine: boatramp_handlers::HandlerEngine,
220 async_drain_gate: Arc<tokio::sync::Semaphore>,
226 kv: Arc<dyn boatramp_core::kv::KvStore>,
227 storage: Arc<dyn boatramp_core::Storage>,
228 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
232 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
235 site_semaphores:
238 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
239 stream_semaphores:
243 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
244 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
247 metrics: metrics::Metrics,
250 logs: Arc<logs::LogStore>,
252 #[cfg(feature = "handlers")]
256 graphql_cache: graphql_cache::GraphqlCache,
257 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
263 max_blob_bytes: std::sync::OnceLock<u64>,
267 max_component_bytes: std::sync::OnceLock<u64>,
272 function_meter_locks:
276 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
277 function_semaphores:
280 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
281 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
286 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
290 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
298 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
303}
304
305pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
308
309impl HandlerRuntime {
310 pub fn disabled() -> Self {
312 Self::default()
313 }
314
315 #[cfg(feature = "handlers")]
321 pub fn new(
322 engine: boatramp_handlers::HandlerEngine,
323 kv: Arc<dyn boatramp_core::kv::KvStore>,
324 storage: Arc<dyn boatramp_core::Storage>,
325 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
326 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
327 ) -> Self {
328 let async_drain_slots = engine.async_max_concurrency().max(1);
331 Self {
332 inner: Some(Arc::new(HandlerRuntimeInner {
333 engine,
334 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
335 kv,
336 storage,
337 sql,
338 messaging,
339 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
340 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
341 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
342 metrics: metrics::Metrics::default(),
343 logs: Arc::new(logs::LogStore::default()),
344 #[cfg(feature = "handlers")]
345 graphql_cache: graphql_cache::GraphqlCache::default(),
346 cron_leader_gate: std::sync::OnceLock::new(),
347 max_blob_bytes: std::sync::OnceLock::new(),
348 max_component_bytes: std::sync::OnceLock::new(),
349 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
350 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
351 watch_provider: std::sync::OnceLock::new(),
352 provision_tier: std::sync::OnceLock::new(),
353 invoker: std::sync::OnceLock::new(),
354 federation_runner: std::sync::OnceLock::new(),
355 })),
356 }
357 }
358
359 #[cfg(feature = "handlers")]
362 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
363 self.inner.as_ref().and_then(|inner| inner.sql.clone())
364 }
365
366 #[cfg(feature = "handlers")]
370 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
371 self.inner
372 .as_ref()
373 .and_then(|inner| inner.invoker.get().cloned())
374 }
375
376 #[cfg(feature = "handlers")]
380 pub(crate) async fn introspect_subgraph_sdl(
381 &self,
382 deploy: &DeployStore,
383 project: boatramp_core::project::ProjectRef<'_>,
384 function: &boatramp_core::function::Function,
385 component: &str,
386 ) -> Result<String, function_runtime::SubgraphSdlError> {
387 match self.inner.as_ref() {
388 Some(inner) => {
389 function_runtime::introspect_service_sdl(
390 inner, deploy, project, function, component,
391 )
392 .await
393 }
394 None => Err(function_runtime::SubgraphSdlError::Unavailable),
395 }
396 }
397
398 #[cfg(feature = "handlers")]
404 pub fn set_invoker(&self, deploy: DeployStore) {
405 if let Some(inner) = self.inner.as_ref() {
406 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
407 deploy,
408 Arc::downgrade(inner),
409 ));
410 let _ = inner.invoker.set(invoker);
411 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
414 inner,
415 )));
416 let _ = inner.federation_runner.set(runner);
417 }
418 }
419
420 #[cfg(feature = "handlers")]
424 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
425 self.inner.as_ref().and_then(|inner| inner.sql.clone())
426 }
427
428 #[cfg(feature = "handlers")]
432 pub fn set_watch_provider(
433 &self,
434 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
435 ) {
436 if let Some(inner) = self.inner.as_ref() {
437 let _ = inner.watch_provider.set(provider);
438 }
439 }
440
441 #[cfg(feature = "handlers")]
445 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
446 if let Some(inner) = self.inner.as_ref() {
447 let _ = inner.provision_tier.set(tier);
448 }
449 }
450
451 #[cfg(feature = "handlers")]
455 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
456 if let Some(inner) = self.inner.as_ref() {
457 let _ = inner.max_blob_bytes.set(max_bytes);
458 }
459 }
460
461 #[cfg(feature = "handlers")]
464 pub fn set_max_component_bytes(&self, max_bytes: u64) {
465 if let Some(inner) = self.inner.as_ref() {
466 let _ = inner.max_component_bytes.set(max_bytes);
467 }
468 }
469
470 #[cfg(feature = "handlers")]
475 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
476 if let Some(inner) = self.inner.as_ref() {
477 let _ = inner.cron_leader_gate.set(gate);
478 }
479 }
480
481 #[cfg(feature = "handlers")]
488 async fn precheck_activation(
489 &self,
490 deploy: &DeployStore,
491 manifest: &Manifest,
492 site_config: Option<&SiteConfig>,
493 ) -> Result<(), String> {
494 let Some(inner) = self.inner.as_ref() else {
495 return Ok(());
496 };
497 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
500 return Ok(());
501 }
502 let site_handlers = site_config
504 .and_then(|c| c.handlers.as_ref())
505 .filter(|h| h.enabled)
506 .ok_or_else(|| {
507 "deployment ships handlers/consumers but the site has them disabled".to_string()
508 })?;
509 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
510
511 for handler in &manifest.config.handlers {
513 precheck_component(
514 deploy,
515 manifest,
516 site_handlers,
517 inner,
518 max_component,
519 &handler.imports,
520 &handler.component,
521 &format!("handler {:?}", handler.route),
522 false,
523 )
524 .await?;
525 }
526 for consumer in &manifest.config.consumers {
527 precheck_component(
528 deploy,
529 manifest,
530 site_handlers,
531 inner,
532 max_component,
533 &consumer.imports,
534 &consumer.component,
535 &format!("consumer {:?}", consumer.topic),
536 true,
537 )
538 .await?;
539 }
540 Ok(())
541 }
542
543 #[cfg(not(feature = "handlers"))]
544 async fn precheck_activation(
545 &self,
546 _deploy: &DeployStore,
547 _manifest: &Manifest,
548 _site_config: Option<&SiteConfig>,
549 ) -> Result<(), String> {
550 Ok(())
551 }
552}
553
554#[derive(Default, Clone)]
560pub struct ServerOptions {
561 pub limits: ServerLimits,
563 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
566 pub default_site: Option<String>,
569 pub implicit_routing: bool,
575 pub protect_previews: bool,
580 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
584 pub issuer: Option<Arc<dyn Signer>>,
588 pub bootstrap_secret: Option<String>,
593 pub bootstrap_attestation: Option<String>,
599 pub mesh_control: Option<Arc<dyn MeshControl>>,
603 pub cors_allowed_origins: Vec<String>,
613 #[cfg(feature = "oidc")]
616 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
617 pub posture: boatramp_core::security::SecurityPosture,
621 pub served_over_tls: bool,
626 pub pop_origin: Option<String>,
633 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
637 #[cfg(feature = "console")]
641 pub console: Option<console::ConsoleMount>,
642}
643
644#[derive(Clone, Copy)]
648struct ServedOverTls(bool);
649
650#[derive(Clone, Copy, Default)]
655struct ImplicitRouting(bool);
656
657const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
669
670pub struct DaemonRuntime {
671 baseline: boatramp_core::daemon_config::ConfigBaseline,
672 state: std::sync::RwLock<DaemonState>,
673 reload: tokio::sync::Notify,
676}
677
678struct DaemonState {
679 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
680 generation: Option<String>,
681}
682
683pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
688 #[cfg(feature = "console")]
692 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
693 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
694 None => (false, None, None),
695 };
696 #[cfg(not(feature = "console"))]
697 let (console_enabled, console_host, console_path) = (false, None, None);
698 boatramp_core::daemon_config::ConfigBaseline {
699 default_site: options.default_site.clone(),
700 protect_previews: options.protect_previews,
701 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
702 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
703 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
704 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
705 compute_vcpus: 0,
706 compute_mem_mib: 0,
707 console_enabled,
708 console_host,
709 console_path,
710 max_upload_ceiling: options.posture.max_upload_bytes,
711 max_concurrent_uploads_ceiling: None,
712 posture: options.posture,
713 }
714}
715
716impl DaemonRuntime {
717 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
721 let effective =
722 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
723 Self {
724 baseline,
725 state: std::sync::RwLock::new(DaemonState {
726 effective,
727 generation: None,
728 }),
729 reload: tokio::sync::Notify::new(),
730 }
731 }
732
733 pub fn notify_reload(&self) {
737 self.reload.notify_one();
738 }
739
740 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
742 self.state
743 .read()
744 .expect("daemon config lock")
745 .effective
746 .clone()
747 }
748
749 pub fn generation(&self) -> Option<String> {
752 self.state
753 .read()
754 .expect("daemon config lock")
755 .generation
756 .clone()
757 }
758
759 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
761 &self.baseline
762 }
763
764 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
767 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
768 let generation = deploy.daemon_config_generation().await?;
769 let effective = Arc::new(cfg.resolve(&self.baseline));
770 *self.state.write().expect("daemon config lock") = DaemonState {
771 effective,
772 generation,
773 };
774 Ok(())
775 }
776}
777
778#[derive(Clone, Copy, Default)]
781struct PreviewPolicy {
782 protect: bool,
783}
784
785#[derive(Clone, Default)]
790struct Issuer(Option<Arc<dyn Signer>>);
791
792#[derive(Clone, Default)]
797struct BootstrapGate(Option<Arc<BootstrapInner>>);
798
799struct BootstrapInner {
800 secret_hash: String,
803 lock: tokio::sync::Mutex<()>,
806}
807
808impl BootstrapGate {
809 fn new(secret: Option<&str>) -> Self {
810 Self(secret.filter(|s| !s.is_empty()).map(|s| {
811 Arc::new(BootstrapInner {
812 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
813 lock: tokio::sync::Mutex::new(()),
814 })
815 }))
816 }
817}
818
819#[async_trait::async_trait]
823pub trait MeshControl: Send + Sync {
824 async fn admit(
832 &self,
833 mesh_pubkey_hex: &str,
834 jti: &str,
835 possession_proof: &[u8],
836 proof_iat: u64,
837 now: u64,
838 advertise_addr: Option<&str>,
839 ) -> Result<JoinOutcome, String>;
840
841 async fn rotate_key(&self) -> Result<String, String>;
845
846 async fn revoke(&self, node: u64) -> Result<(), String>;
850
851 async fn members(&self) -> Result<Vec<MeshMember>, String>;
855
856 async fn promote(&self, node: u64) -> Result<(), String>;
859}
860
861pub enum JoinOutcome {
863 Admitted {
866 members: Vec<String>,
868 addrs: std::collections::BTreeMap<u64, String>,
870 },
871 TokenSpent,
873 ProofInvalid,
875 Revoked,
878}
879
880#[derive(Debug, Clone, Serialize)]
882pub struct MeshMember {
883 pub node: u64,
885 pub voter: bool,
887 pub caught_up: bool,
889 pub leader: bool,
891 #[serde(default, skip_serializing_if = "Option::is_none")]
895 pub addr: Option<String>,
896}
897
898#[derive(Clone, Default)]
901struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
902
903#[cfg(feature = "oidc")]
905#[derive(Clone, Default)]
906struct OidcState(Option<Arc<oidc::OidcVerifier>>);
907
908#[cfg(feature = "oidc")]
911const EXCHANGE_TTL_SECS: u64 = 3600;
912
913use boatramp_core::time::now_unix;
914
915#[derive(Clone)]
917struct CorsState(Arc<Vec<String>>);
918
919const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
921const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
924const CORS_MAX_AGE: &str = "600";
926
927fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
931 allowed.iter().any(|a| a == "*" || a == origin)
932}
933
934async fn cors(
941 State(allowed): State<CorsState>,
942 request: Request,
943 next: axum::middleware::Next,
944) -> Response {
945 let origin = request
946 .headers()
947 .get(header::ORIGIN)
948 .and_then(|v| v.to_str().ok())
949 .filter(|o| cors_origin_allowed(&allowed.0, o))
950 .map(str::to_string);
951 let is_preflight = request.method() == Method::OPTIONS
953 && request
954 .headers()
955 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
956 if is_preflight {
957 let allow_headers = request
959 .headers()
960 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
961 .and_then(|v| v.to_str().ok())
962 .map(str::to_string)
963 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
964 let mut response = Response::new(Body::empty());
965 *response.status_mut() = StatusCode::NO_CONTENT;
966 if let Some(origin) = origin {
967 let headers = response.headers_mut();
968 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
969 set_header(headers, header::VARY, "Origin");
970 set_header(
971 headers,
972 header::ACCESS_CONTROL_ALLOW_METHODS,
973 CORS_ALLOW_METHODS,
974 );
975 set_header(
976 headers,
977 header::ACCESS_CONTROL_ALLOW_HEADERS,
978 &allow_headers,
979 );
980 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
981 }
982 return response;
983 }
984 let mut response = next.run(request).await;
985 if let Some(origin) = origin {
986 let headers = response.headers_mut();
987 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
988 if let Ok(value) = HeaderValue::from_str("Origin") {
991 headers.append(header::VARY, value);
992 }
993 }
994 response
995}
996
997const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1002
1003#[derive(Debug, thiserror::Error)]
1005pub enum ServeError {
1006 #[error("server I/O: {0}")]
1008 Io(#[from] std::io::Error),
1009}
1010
1011pub async fn serve(
1014 addr: SocketAddr,
1015 deploy: DeployStore,
1016 auth: Auth,
1017 handlers: HandlerRuntime,
1018) -> Result<(), ServeError> {
1019 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1020}
1021
1022pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1032 if let Err(err) = stream.set_nodelay(true) {
1033 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1034 }
1035}
1036
1037pub async fn serve_with(
1039 addr: SocketAddr,
1040 deploy: DeployStore,
1041 auth: Auth,
1042 handlers: HandlerRuntime,
1043 options: ServerOptions,
1044) -> Result<(), ServeError> {
1045 let listener = {
1046 use axum::serve::ListenerExt;
1047 tokio::net::TcpListener::bind(addr)
1048 .await?
1049 .tap_io(disable_nagle)
1050 };
1051 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1052 #[cfg(feature = "handlers")]
1055 let scheduler = handlers.spawn_scheduler(deploy.clone());
1056 let gateway_prober = gateway::spawn_active_health_prober();
1060 let app = router_with(deploy, auth, handlers, options)
1063 .into_make_service_with_connect_info::<SocketAddr>();
1064
1065 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1069 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1070 shutdown_signal().await;
1071 let _ = signalled_tx.send(true);
1072 });
1073 let signalled = {
1074 let mut rx = signalled_rx;
1075 async move {
1076 let _ = rx.wait_for(|fired| *fired).await;
1077 }
1078 };
1079 let result = serve_with_drain_deadline(
1080 async move { server.await.map_err(ServeError::from) },
1081 signalled,
1082 DRAIN_DEADLINE,
1083 )
1084 .await;
1085 #[cfg(feature = "handlers")]
1087 if let Some(handle) = scheduler {
1088 handle.abort();
1089 }
1090 gateway_prober.abort();
1091 result
1092}
1093
1094async fn serve_with_drain_deadline<Srv, Sig>(
1099 server: Srv,
1100 signalled: Sig,
1101 deadline: Duration,
1102) -> Result<(), ServeError>
1103where
1104 Srv: Future<Output = Result<(), ServeError>>,
1105 Sig: Future<Output = ()>,
1106{
1107 tokio::pin!(server);
1108 let drain_cap = async move {
1109 signalled.await;
1110 tokio::time::sleep(deadline).await;
1111 };
1112 tokio::select! {
1113 result = &mut server => result,
1114 _ = drain_cap => {
1115 tracing::warn!(
1116 deadline_s = deadline.as_secs(),
1117 "drain deadline exceeded; forcing shutdown with requests still in flight"
1118 );
1119 Ok(())
1120 }
1121 }
1122}
1123
1124pub async fn shutdown_signal() {
1127 let ctrl_c = async {
1128 let _ = tokio::signal::ctrl_c().await;
1129 };
1130 #[cfg(unix)]
1131 let terminate = async {
1132 if let Ok(mut sig) =
1133 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1134 {
1135 sig.recv().await;
1136 }
1137 };
1138 #[cfg(not(unix))]
1139 let terminate = std::future::pending::<()>();
1140
1141 tokio::select! {
1142 _ = ctrl_c => {}
1143 _ = terminate => {}
1144 }
1145 tracing::info!("shutdown signal received; draining");
1146}
1147
1148async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1152 match daemon.generation() {
1153 Some(gen) => format!("ok gen={gen}"),
1154 None => "ok".to_string(),
1155 }
1156}
1157
1158async fn readyz(State(deploy): State<DeployStore>) -> Response {
1160 match deploy.ready().await {
1161 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1162 Err(err) => {
1163 tracing::warn!(error = %err, "readiness probe failed");
1164 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1165 }
1166 }
1167}
1168
1169#[derive(Clone)]
1174pub struct RequestId(pub String);
1175
1176fn request_id_for(headers: &HeaderMap) -> String {
1179 if let Some(id) = headers
1180 .get("x-request-id")
1181 .and_then(|v| v.to_str().ok())
1182 .map(str::trim)
1183 .filter(|s| !s.is_empty())
1184 {
1185 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1186 }
1187 use std::sync::atomic::{AtomicU64, Ordering};
1188 static SEQ: AtomicU64 = AtomicU64::new(0);
1189 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1190 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1191}
1192
1193struct AccessLog {
1197 request_id: String,
1198 method: Method,
1199 path: String,
1200 host: String,
1201 client: String,
1202 status: u16,
1203 encoding: String,
1205 start: std::time::Instant,
1206 bytes: std::sync::atomic::AtomicU64,
1207}
1208
1209impl Drop for AccessLog {
1210 fn drop(&mut self) {
1211 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1212 srvmetrics::server_metrics().record_request(self.status, bytes);
1215 tracing::info!(
1216 target: "boatramp::access",
1217 request_id = %self.request_id,
1218 method = %self.method,
1219 path = %self.path,
1220 host = %self.host,
1221 client = %self.client,
1222 status = self.status,
1223 bytes = bytes,
1224 encoding = %self.encoding,
1225 cache_result = srvmetrics::cache_result(self.status),
1226 elapsed_ms = self.start.elapsed().as_millis() as u64,
1227 "request"
1228 );
1229 }
1230}
1231
1232async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1236 let method = request.method().clone();
1237 let path = request.uri().path().to_string();
1238 let request_id = request_id_for(request.headers());
1241 request
1242 .extensions_mut()
1243 .insert(RequestId(request_id.clone()));
1244 let host = request
1245 .headers()
1246 .get(header::HOST)
1247 .and_then(|value| value.to_str().ok())
1248 .unwrap_or("-")
1249 .to_string();
1250 let client = request
1251 .extensions()
1252 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1253 .map(|info| info.0.ip().to_string())
1254 .unwrap_or_else(|| "-".to_string());
1255
1256 let start = std::time::Instant::now();
1257 let response = next.run(request).await;
1258 let encoding = response
1259 .headers()
1260 .get(header::CONTENT_ENCODING)
1261 .and_then(|v| v.to_str().ok())
1262 .unwrap_or("identity")
1263 .to_string();
1264 let log = AccessLog {
1265 request_id,
1266 method,
1267 path,
1268 host,
1269 client,
1270 status: response.status().as_u16(),
1271 encoding,
1272 start,
1273 bytes: std::sync::atomic::AtomicU64::new(0),
1274 };
1275
1276 let (parts, body) = response.into_parts();
1280 let counted = body.into_data_stream().map(move |chunk| {
1281 if let Ok(bytes) = &chunk {
1282 log.bytes
1283 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1284 }
1285 chunk
1286 });
1287 Response::from_parts(parts, Body::from_stream(counted))
1288}
1289
1290fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1292 req_headers
1293 .get(header::IF_NONE_MATCH)
1294 .and_then(|value| value.to_str().ok())
1295 .is_some_and(|value| {
1296 value
1297 .split(',')
1298 .map(str::trim)
1299 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1300 })
1301}
1302
1303fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1304 if let Ok(value) = HeaderValue::from_str(value) {
1305 headers.insert(name, value);
1306 }
1307}
1308
1309fn not_found() -> Response {
1310 (StatusCode::NOT_FOUND, "not found\n").into_response()
1311}
1312
1313fn redirect(status: u16, location: &str) -> Response {
1314 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1315 match HeaderValue::from_str(location) {
1316 Ok(location) => {
1317 let mut headers = HeaderMap::new();
1318 headers.insert(header::LOCATION, location);
1319 (status, headers).into_response()
1320 }
1321 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1322 }
1323}
1324
1325fn deploy_error_response(err: DeployError) -> Response {
1327 let status = match &err {
1328 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1329 StatusCode::NOT_FOUND
1330 }
1331 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1332 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1333 DeployError::Conflict(_) => StatusCode::CONFLICT,
1335 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1337 _ => StatusCode::INTERNAL_SERVER_ERROR,
1338 };
1339 tracing::warn!(error = %err, "request failed");
1340 (status, format!("{err}\n")).into_response()
1341}
1342
1343fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1348 boatramp_core::project::validate_resource_name(kind, value)
1349 .err()
1350 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1351}
1352
1353#[cfg(test)]
1354mod drain_tests {
1355 use super::*;
1356
1357 #[tokio::test]
1358 async fn deadline_forces_shutdown_after_signal() {
1359 let server = std::future::pending::<Result<(), ServeError>>();
1362 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1364 assert!(result.is_ok());
1365 }
1366
1367 #[tokio::test]
1368 async fn server_finishing_first_wins() {
1369 let server = async { Ok(()) };
1372 let signalled = std::future::pending::<()>();
1373 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1374 assert!(result.is_ok());
1375 }
1376
1377 #[tokio::test]
1378 async fn deadline_does_not_trip_before_signal() {
1379 let server = async {
1383 tokio::time::sleep(Duration::from_millis(40)).await;
1384 Err(ServeError::Io(std::io::Error::other("server error")))
1385 };
1386 let signalled = std::future::pending::<()>();
1387 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1388 assert!(result.is_err());
1389 }
1390}
1391
1392#[cfg(all(test, feature = "handlers"))]
1393mod tests {
1394 use super::*;
1395 use boatramp_core::cose::{LocalSigner, TokenAlg};
1396 use boatramp_core::project::ProjectRef;
1397
1398 #[test]
1399 fn query_string_parses_and_url_decodes() {
1400 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1401 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1402 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")); }
1406
1407 #[test]
1408 fn cookie_header_parses_pairs() {
1409 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1410 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1411 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1412 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1413 }
1414
1415 #[test]
1416 fn apply_vary_merges_without_duplicates() {
1417 let base = (StatusCode::OK, "x").into_response();
1418 let r = apply_vary(base, &["accept-language".into()]);
1419 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1420 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1422 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1423 assert!(v.contains("accept-language") && v.contains("cookie"));
1424 assert_eq!(v.matches("accept-language").count(), 1);
1425 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1427 assert!(plain.headers().get(header::VARY).is_none());
1428 }
1429
1430 #[tokio::test]
1434 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1435 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1436 let public = keys.public_key();
1437
1438 let resp = create_join_token(
1440 Extension(Issuer(Some(keys.clone()))),
1441 Json(CreateJoinTokenRequest {
1442 ttl_secs: Some(600),
1443 }),
1444 )
1445 .await;
1446 assert_eq!(resp.status(), StatusCode::CREATED);
1447 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1448 .await
1449 .unwrap();
1450 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1451 let token = parsed["token"].as_str().unwrap();
1452 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1453 assert!(!jti.is_empty());
1454
1455 let no_issuer = create_join_token(
1457 Extension(Issuer(None)),
1458 Json(CreateJoinTokenRequest { ttl_secs: None }),
1459 )
1460 .await;
1461 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1462 }
1463
1464 #[tokio::test]
1469 async fn function_write_path_deploy_rollback_alias_remove() {
1470 use boatramp_core::function::Lifecycle;
1471 use boatramp_core::kv::MemoryKv;
1472 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1473
1474 struct FakeStorage {
1477 present: bool,
1478 }
1479 #[async_trait::async_trait]
1480 impl Storage for FakeStorage {
1481 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1482 Err(StorageError::NotFound(String::new()))
1483 }
1484 async fn get_range(
1485 &self,
1486 _: &str,
1487 _: u64,
1488 _: Option<u64>,
1489 ) -> Result<GetObject, StorageError> {
1490 Err(StorageError::NotFound(String::new()))
1491 }
1492 async fn put(
1493 &self,
1494 _: &str,
1495 _: ByteStream,
1496 _: PutMeta,
1497 ) -> Result<ObjectMeta, StorageError> {
1498 Err(StorageError::unsupported("fake"))
1499 }
1500 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1501 if self.present {
1502 Ok(ObjectMeta {
1503 key: key.to_string(),
1504 ..Default::default()
1505 })
1506 } else {
1507 Err(StorageError::NotFound(key.to_string()))
1508 }
1509 }
1510 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1511 Ok(())
1512 }
1513 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1514 Ok(Vec::new())
1515 }
1516 }
1517
1518 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1519 let status = resp.status();
1520 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1521 .await
1522 .unwrap();
1523 let value = if bytes.is_empty() {
1524 serde_json::Value::Null
1525 } else {
1526 serde_json::from_slice(&bytes).unwrap()
1527 };
1528 (status, value)
1529 }
1530
1531 let deploy = DeployStore::new(
1532 Arc::new(FakeStorage { present: true }),
1533 Arc::new(MemoryKv::new()),
1534 );
1535 let v1 = "a".repeat(64);
1536 let v2 = "b".repeat(64);
1537
1538 let (st, body) = body_json(
1540 deploy_function(
1541 State(deploy.clone()),
1542 axum::extract::Extension(crate::ProjectContext::default()),
1543 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1544 axum::extract::Query(DeployFunctionQuery::default()),
1545 Path("greeter".to_string()),
1546 Json(FunctionUpsert {
1547 component: v1.clone(),
1548 config: Default::default(),
1549 lifecycle: Lifecycle::Independent,
1550 }),
1551 )
1552 .await,
1553 )
1554 .await;
1555 assert_eq!(st, StatusCode::OK);
1556 assert_eq!(body["active"], v1);
1557
1558 let (_, body) = body_json(
1560 deploy_function(
1561 State(deploy.clone()),
1562 axum::extract::Extension(crate::ProjectContext::default()),
1563 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1564 axum::extract::Query(DeployFunctionQuery::default()),
1565 Path("greeter".to_string()),
1566 Json(FunctionUpsert {
1567 component: v2.clone(),
1568 config: Default::default(),
1569 lifecycle: Lifecycle::Independent,
1570 }),
1571 )
1572 .await,
1573 )
1574 .await;
1575 assert_eq!(body["active"], v2);
1576 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1577
1578 let (st, body) = body_json(
1580 rollback_function(
1581 State(deploy.clone()),
1582 axum::extract::Extension(crate::ProjectContext::default()),
1583 Path("greeter".to_string()),
1584 Json(RollbackBody { to: v1.clone() }),
1585 )
1586 .await,
1587 )
1588 .await;
1589 assert_eq!(st, StatusCode::OK);
1590 assert_eq!(body["active"], v1);
1591
1592 let resp = rollback_function(
1594 State(deploy.clone()),
1595 axum::extract::Extension(crate::ProjectContext::default()),
1596 Path("greeter".to_string()),
1597 Json(RollbackBody { to: "c".repeat(64) }),
1598 )
1599 .await;
1600 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1601
1602 let (st, body) = body_json(
1604 alias_function(
1605 State(deploy.clone()),
1606 axum::extract::Extension(crate::ProjectContext::default()),
1607 Path(("greeter".to_string(), "prod".to_string())),
1608 Json(AliasBody {
1609 version: v2.clone(),
1610 }),
1611 )
1612 .await,
1613 )
1614 .await;
1615 assert_eq!(st, StatusCode::OK);
1616 assert_eq!(body["aliases"]["prod"], v2);
1617
1618 let (st, _) = body_json(
1620 remove_function(
1621 State(deploy.clone()),
1622 axum::extract::Extension(crate::ProjectContext::default()),
1623 Path("greeter".to_string()),
1624 )
1625 .await,
1626 )
1627 .await;
1628 assert_eq!(st, StatusCode::NO_CONTENT);
1629 assert!(deploy
1630 .get_function(ProjectRef::DEFAULT, "greeter")
1631 .await
1632 .unwrap()
1633 .is_none());
1634
1635 let empty = DeployStore::new(
1637 Arc::new(FakeStorage { present: false }),
1638 Arc::new(MemoryKv::new()),
1639 );
1640 let resp = deploy_function(
1641 State(empty),
1642 axum::extract::Extension(crate::ProjectContext::default()),
1643 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1644 axum::extract::Query(DeployFunctionQuery::default()),
1645 Path("orphan".to_string()),
1646 Json(FunctionUpsert {
1647 component: v1.clone(),
1648 config: Default::default(),
1649 lifecycle: Lifecycle::default(),
1650 }),
1651 )
1652 .await;
1653 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1654 }
1655
1656 struct StubControl {
1660 admits: std::sync::Mutex<Vec<(String, String)>>,
1661 respond: StubJoin,
1662 }
1663 #[derive(Clone, Copy)]
1664 enum StubJoin {
1665 Admit,
1666 Spent,
1667 Invalid,
1668 Revoked,
1669 }
1670
1671 #[async_trait::async_trait]
1672 impl MeshControl for StubControl {
1673 async fn admit(
1674 &self,
1675 mesh_pubkey_hex: &str,
1676 jti: &str,
1677 _proof: &[u8],
1678 _proof_iat: u64,
1679 _now: u64,
1680 _advertise_addr: Option<&str>,
1681 ) -> Result<JoinOutcome, String> {
1682 self.admits
1683 .lock()
1684 .unwrap()
1685 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1686 Ok(match self.respond {
1687 StubJoin::Admit => JoinOutcome::Admitted {
1688 members: vec!["signed-member".to_string()],
1689 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1690 },
1691 StubJoin::Spent => JoinOutcome::TokenSpent,
1692 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1693 StubJoin::Revoked => JoinOutcome::Revoked,
1694 })
1695 }
1696 async fn rotate_key(&self) -> Result<String, String> {
1697 Ok("cafe".to_string())
1698 }
1699 async fn revoke(&self, _node: u64) -> Result<(), String> {
1700 Ok(())
1701 }
1702 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1703 Ok(Vec::new())
1704 }
1705 async fn promote(&self, _node: u64) -> Result<(), String> {
1706 Ok(())
1707 }
1708 }
1709
1710 #[tokio::test]
1714 async fn cluster_join_dispatches_and_maps_outcomes() {
1715 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1716 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1717 let auth = Auth::with_key(keys.public_key(), kv);
1718 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1719 let req = |proof: &str| JoinRequest {
1720 token: token.clone(),
1721 mesh_pubkey: "302a300506032b6570032100feed".into(),
1722 possession_proof: proof.to_string(),
1723 proof_iat: now_unix(),
1724 advertise_addr: Some("https://joiner:7000".into()),
1725 };
1726
1727 let admitter = Arc::new(StubControl {
1729 admits: std::sync::Mutex::new(Vec::new()),
1730 respond: StubJoin::Admit,
1731 });
1732 let resp = cluster_join(
1733 Extension(auth.clone()),
1734 Extension(MeshControlHandle(Some(admitter.clone()))),
1735 Json(req("aa01")),
1736 )
1737 .await;
1738 assert_eq!(resp.status(), StatusCode::OK);
1739 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1740
1741 let spent = Arc::new(StubControl {
1743 admits: std::sync::Mutex::new(Vec::new()),
1744 respond: StubJoin::Spent,
1745 });
1746 assert_eq!(
1747 cluster_join(
1748 Extension(auth.clone()),
1749 Extension(MeshControlHandle(Some(spent))),
1750 Json(req("aa01")),
1751 )
1752 .await
1753 .status(),
1754 StatusCode::CONFLICT
1755 );
1756 let invalid = Arc::new(StubControl {
1757 admits: std::sync::Mutex::new(Vec::new()),
1758 respond: StubJoin::Invalid,
1759 });
1760 assert_eq!(
1761 cluster_join(
1762 Extension(auth.clone()),
1763 Extension(MeshControlHandle(Some(invalid))),
1764 Json(req("aa01")),
1765 )
1766 .await
1767 .status(),
1768 StatusCode::FORBIDDEN
1769 );
1770 let revoked = Arc::new(StubControl {
1772 admits: std::sync::Mutex::new(Vec::new()),
1773 respond: StubJoin::Revoked,
1774 });
1775 assert_eq!(
1776 cluster_join(
1777 Extension(auth.clone()),
1778 Extension(MeshControlHandle(Some(revoked))),
1779 Json(req("aa01")),
1780 )
1781 .await
1782 .status(),
1783 StatusCode::FORBIDDEN
1784 );
1785
1786 let ok = Arc::new(StubControl {
1788 admits: std::sync::Mutex::new(Vec::new()),
1789 respond: StubJoin::Admit,
1790 });
1791 assert_eq!(
1792 cluster_join(
1793 Extension(auth.clone()),
1794 Extension(MeshControlHandle(Some(ok))),
1795 Json(req("not-hex")),
1796 )
1797 .await
1798 .status(),
1799 StatusCode::BAD_REQUEST
1800 );
1801
1802 let none = cluster_join(
1804 Extension(auth),
1805 Extension(MeshControlHandle(None)),
1806 Json(req("aa01")),
1807 )
1808 .await;
1809 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1810 }
1811
1812 #[tokio::test]
1816 async fn bootstrap_mints_the_first_token_once() {
1817 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1818 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1819 let public = keys.public_key();
1820 let deploy = DeployStore::new(
1821 Arc::new(MemStorage::default()),
1822 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1823 );
1824 let secret = "s3cr3t-bootstrap-value";
1825 let gate = BootstrapGate::new(Some(secret));
1826 let issuer = Issuer(Some(keys.clone()));
1827 let bearer = |s: &str| {
1828 let mut h = HeaderMap::new();
1829 h.insert(
1830 AUTHORIZATION,
1831 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1832 );
1833 h
1834 };
1835 let req = || BootstrapRequest {
1836 roles: vec!["admin".to_string()],
1837 ttl_secs: None,
1838 };
1839
1840 let bad = bootstrap_token(
1842 State(deploy.clone()),
1843 Extension(issuer.clone()),
1844 Extension(gate.clone()),
1845 bearer("wrong"),
1846 Json(req()),
1847 )
1848 .await;
1849 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1850
1851 let ok = bootstrap_token(
1853 State(deploy.clone()),
1854 Extension(issuer.clone()),
1855 Extension(gate.clone()),
1856 bearer(secret),
1857 Json(req()),
1858 )
1859 .await;
1860 assert_eq!(ok.status(), StatusCode::CREATED);
1861 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1862 .await
1863 .unwrap();
1864 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1865 let token = json["token"].as_str().unwrap();
1866 let id = json["id"].as_str().unwrap();
1867 let verified = cose::verify(token, &public, now_unix()).unwrap();
1868 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1869 assert!(deploy
1870 .list_token_meta()
1871 .await
1872 .unwrap()
1873 .iter()
1874 .any(|m| m.revocation_id == id));
1875
1876 let reuse = bootstrap_token(
1878 State(deploy.clone()),
1879 Extension(issuer.clone()),
1880 Extension(gate),
1881 bearer(secret),
1882 Json(req()),
1883 )
1884 .await;
1885 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1886
1887 let disabled = bootstrap_token(
1889 State(deploy),
1890 Extension(issuer),
1891 Extension(BootstrapGate(None)),
1892 bearer(secret),
1893 Json(req()),
1894 )
1895 .await;
1896 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1897 }
1898
1899 #[tokio::test]
1902 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1903 let control = Arc::new(StubControl {
1904 admits: std::sync::Mutex::new(Vec::new()),
1905 respond: StubJoin::Admit,
1906 });
1907 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1908 assert_eq!(resp.status(), StatusCode::OK);
1909 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1910 .await
1911 .unwrap();
1912 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1913 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1914
1915 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1916 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1917 }
1918
1919 #[test]
1920 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1921 use boatramp_core::security::SecurityProfile;
1922 let strict = SecurityProfile::MultiTenant.preset();
1923 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1927 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1928 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1929
1930 assert!(gateway_addr_allowed(public, &strict));
1932 assert!(!gateway_addr_allowed(private, &strict));
1933 assert!(!gateway_addr_allowed(loopback, &strict));
1934 assert!(!gateway_addr_allowed(metadata, &strict));
1935
1936 assert!(gateway_addr_allowed(public, &loose));
1939 assert!(gateway_addr_allowed(private, &loose));
1940 assert!(gateway_addr_allowed(loopback, &loose));
1941 assert!(!gateway_addr_allowed(metadata, &loose));
1942 }
1943
1944 #[test]
1945 fn resolve_env_merges_static_and_host_secrets() {
1946 use boatramp_core::config::HandlersSiteConfig;
1947
1948 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1950
1951 let deploy_env = std::collections::BTreeMap::from([
1952 ("GREETING".to_string(), "hi".to_string()),
1953 ("OVERRIDE_ME".to_string(), "static".to_string()),
1954 ]);
1955 let site_handlers = HandlersSiteConfig {
1956 enabled: true,
1957 secrets: std::collections::BTreeMap::from([
1958 (
1960 "SECRET_TOKEN".to_string(),
1961 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1962 ),
1963 (
1964 "OVERRIDE_ME".to_string(),
1965 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1966 ),
1967 (
1968 "MISSING".to_string(),
1969 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1970 ),
1971 ]),
1972 ..Default::default()
1973 };
1974 let env = resolve_env("blog", &deploy_env, &site_handlers);
1975
1976 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1980 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1981 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1982 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1983
1984 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1985 }
1986
1987 fn req() -> Request {
1988 Request::builder()
1989 .uri("/")
1990 .header(header::HOST, "example.com")
1991 .body(Body::empty())
1992 .unwrap()
1993 }
1994
1995 #[test]
1996 fn forwarded_headers_set_standard_triple() {
1997 let mut request = req();
1998 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1999 let h = request.headers();
2000 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2001 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2002 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2003 }
2004
2005 #[test]
2006 fn forwarded_for_overwrites_spoofed_value() {
2007 let mut request = Request::builder()
2010 .uri("/")
2011 .header(header::HOST, "example.com")
2012 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2013 .body(Body::empty())
2014 .unwrap();
2015 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2016 let values: Vec<_> = request
2017 .headers()
2018 .get_all("x-forwarded-for")
2019 .iter()
2020 .collect();
2021 assert_eq!(values.len(), 1);
2022 assert_eq!(values[0], "203.0.113.7");
2023 }
2024
2025 #[test]
2026 fn forwarded_proto_preserves_upstream_tls() {
2027 let mut request = Request::builder()
2029 .uri("/")
2030 .header(header::HOST, "example.com")
2031 .header("x-forwarded-proto", "https")
2032 .body(Body::empty())
2033 .unwrap();
2034 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2035 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2036 }
2037
2038 #[test]
2039 fn forwarded_host_absent_when_no_host_header() {
2040 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2041 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2042 assert!(request.headers().get("x-forwarded-host").is_none());
2043 assert_eq!(
2044 request.headers().get("x-forwarded-for").unwrap(),
2045 "203.0.113.7"
2046 );
2047 }
2048
2049 use boatramp_core::kv::{KvStore, MemoryKv};
2052 use boatramp_core::messaging::{LogMessaging, Messaging};
2053 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2054
2055 const EVENT_CONSUMER: &[u8] =
2056 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2057
2058 #[derive(Default)]
2059 struct MemStorage {
2060 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2061 }
2062
2063 #[async_trait::async_trait]
2064 impl boatramp_core::Storage for MemStorage {
2065 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2066 let bytes = self
2067 .objects
2068 .lock()
2069 .unwrap()
2070 .get(key)
2071 .cloned()
2072 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2073 let body: ByteStream =
2074 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2075 Ok(GetObject {
2076 meta: ObjectMeta {
2077 key: key.to_string(),
2078 ..Default::default()
2079 },
2080 body,
2081 })
2082 }
2083 async fn get_range(
2084 &self,
2085 key: &str,
2086 _: u64,
2087 _: Option<u64>,
2088 ) -> Result<GetObject, StorageError> {
2089 self.get(key).await
2090 }
2091 async fn put(
2092 &self,
2093 key: &str,
2094 mut body: ByteStream,
2095 _: PutMeta,
2096 ) -> Result<ObjectMeta, StorageError> {
2097 use futures::StreamExt;
2098 let mut buf = Vec::new();
2099 while let Some(chunk) = body.next().await {
2100 buf.extend_from_slice(&chunk?);
2101 }
2102 self.objects.lock().unwrap().insert(key.to_string(), buf);
2103 Ok(ObjectMeta {
2104 key: key.to_string(),
2105 ..Default::default()
2106 })
2107 }
2108 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2109 self.objects
2110 .lock()
2111 .unwrap()
2112 .get(key)
2113 .map(|_| ObjectMeta {
2114 key: key.to_string(),
2115 ..Default::default()
2116 })
2117 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2118 }
2119 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2120 self.objects.lock().unwrap().remove(key);
2121 Ok(())
2122 }
2123 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2124 Ok(Vec::new())
2125 }
2126 }
2127
2128 fn observed_state(
2130 workload: &str,
2131 healthy: bool,
2132 phase: boatramp_core::compute::ReplicaPhase,
2133 ) -> boatramp_core::compute::ObservedInstance {
2134 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2135 boatramp_core::compute::ObservedInstance {
2136 handle: InstanceHandle {
2137 workload: workload.into(),
2138 replica: 0,
2139 backend_ref: "ref-0".into(),
2140 },
2141 node: 1,
2142 backend: "vmm".into(),
2143 endpoint: Endpoint {
2144 scheme: Scheme::Http,
2145 host: "10.0.0.2".into(),
2146 port: 80,
2147 },
2148 region: None,
2149 healthy,
2150 phase,
2151 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2152 workload: workload.into(),
2153 replica: 0,
2154 data_ref: "snap-0".into(),
2155 }),
2156 }
2157 }
2158
2159 #[tokio::test]
2160 async fn has_parked_replica_detects_a_zeroed_replica() {
2161 use boatramp_core::compute::ReplicaPhase;
2162 let storage = Arc::new(MemStorage::default());
2163 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2164 let deploy = DeployStore::new(storage, kv);
2165
2166 assert!(!has_parked_replica(&deploy, "w").await);
2168 deploy
2170 .set_replica_state(
2171 ProjectRef::DEFAULT,
2172 &observed_state("w", true, ReplicaPhase::Running),
2173 )
2174 .await
2175 .unwrap();
2176 assert!(!has_parked_replica(&deploy, "w").await);
2177 deploy
2179 .set_replica_state(
2180 ProjectRef::DEFAULT,
2181 &observed_state("w", false, ReplicaPhase::Zero),
2182 )
2183 .await
2184 .unwrap();
2185 assert!(has_parked_replica(&deploy, "w").await);
2186 }
2187
2188 #[tokio::test]
2189 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2190 use boatramp_core::compute::ReplicaPhase;
2191 let storage = Arc::new(MemStorage::default());
2192 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2193 let deploy = DeployStore::new(storage, kv);
2194
2195 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2197 assert!(empty.is_empty());
2198
2199 deploy
2201 .set_replica_state(
2202 ProjectRef::DEFAULT,
2203 &observed_state("w", true, ReplicaPhase::Running),
2204 )
2205 .await
2206 .unwrap();
2207 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2208 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2209 }
2210
2211 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2215 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2216 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2217 let storage = Arc::new(MemStorage::default());
2218 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2219 let mq = LogMessaging::new(storage, kv.clone());
2220 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2221 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2222 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2223 let topic = "blog/orders/created";
2224
2225 for _ in 0..3 {
2227 mq.publish(topic, b"ok").await.unwrap();
2228 }
2229 loop {
2230 let acked = dispatch_consumer_batch(
2231 &engine,
2232 &mq,
2233 &metrics::Metrics::default(),
2234 "blog",
2235 topic,
2236 "blog/",
2237 "",
2238 boatramp_core::messaging::StartPosition::Latest,
2239 &hash,
2240 EVENT_CONSUMER,
2241 &bindings,
2242 Limits::default(),
2243 Duration::from_secs(30),
2244 5,
2245 10,
2246 )
2247 .await;
2248 if acked == 0 {
2249 break;
2250 }
2251 }
2252 assert_eq!(
2253 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2254 Some(b"3".to_vec())
2255 );
2256
2257 mq.publish(topic, b"fail").await.unwrap();
2260 for _ in 0..5 {
2261 dispatch_consumer_batch(
2262 &engine,
2263 &mq,
2264 &metrics::Metrics::default(),
2265 "blog",
2266 topic,
2267 "blog/",
2268 "",
2269 boatramp_core::messaging::StartPosition::Latest,
2270 &hash,
2271 EVENT_CONSUMER,
2272 &bindings,
2273 Limits::default(),
2274 Duration::ZERO,
2275 2,
2276 10,
2277 )
2278 .await;
2279 }
2280 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2281 assert_eq!(
2283 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2284 Some(b"3".to_vec())
2285 );
2286 }
2287
2288 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2293 async fn consumer_groups_fan_out_through_the_dispatcher() {
2294 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2295 let storage = Arc::new(MemStorage::default());
2296 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2297 let mq = LogMessaging::new(storage, kv.clone());
2298 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2299 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2300 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2301 let topic = "blog/orders/created";
2302 let start = boatramp_core::messaging::StartPosition::Latest;
2303
2304 for g in ["billing", "audit"] {
2307 let n = dispatch_consumer_batch(
2308 &engine,
2309 &mq,
2310 &metrics::Metrics::default(),
2311 "blog",
2312 topic,
2313 "blog/",
2314 g,
2315 start,
2316 &hash,
2317 EVENT_CONSUMER,
2318 &bindings,
2319 Limits::default(),
2320 Duration::from_secs(30),
2321 5,
2322 10,
2323 )
2324 .await;
2325 assert_eq!(n, 0, "no events yet for group {g}");
2326 }
2327 mq.publish(topic, b"ok").await.unwrap();
2328
2329 for g in ["billing", "audit"] {
2331 let n = dispatch_consumer_batch(
2332 &engine,
2333 &mq,
2334 &metrics::Metrics::default(),
2335 "blog",
2336 topic,
2337 "blog/",
2338 g,
2339 start,
2340 &hash,
2341 EVENT_CONSUMER,
2342 &bindings,
2343 Limits::default(),
2344 Duration::from_secs(30),
2345 5,
2346 10,
2347 )
2348 .await;
2349 assert_eq!(n, 1, "group {g} should receive the message");
2350 }
2351 assert_eq!(
2353 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2354 Some(b"2".to_vec())
2355 );
2356 }
2357
2358 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2362 async fn scheduler_runs_current_consumers_not_previews() {
2363 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2364 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2365 use boatramp_handlers::{HandlerEngine, Limits};
2366 use futures::StreamExt;
2367
2368 let storage = Arc::new(MemStorage::default());
2369 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2370 let deploy = DeployStore::new(storage.clone(), kv.clone());
2371 let messaging: Arc<dyn Messaging> =
2372 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2373
2374 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2376 let stream: ByteStream =
2377 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2378 .boxed();
2379 deploy.put_blob(&hash, stream).await.unwrap();
2380 let mut files = std::collections::BTreeMap::new();
2381 files.insert(
2382 "consumer.wasm".to_string(),
2383 FileEntry {
2384 hash: hash.clone(),
2385 size: EVENT_CONSUMER.len() as u64,
2386 content_type: None,
2387 variants: std::collections::BTreeMap::new(),
2388 },
2389 );
2390 let manifest = Manifest {
2391 files,
2392 config: DeployConfig {
2393 consumers: vec![ConsumerConfig {
2394 topic: "orders/created".into(),
2395 component: "consumer.wasm".into(),
2396 imports: vec!["wasi:keyvalue".into()],
2397 group: String::new(),
2398 start: Default::default(),
2399 }],
2400 ..Default::default()
2401 },
2402 ..Default::default()
2403 };
2404 let id = deploy.put_manifest(&manifest).await.unwrap();
2405 deploy
2406 .activate(ProjectRef::DEFAULT, "blog", &id)
2407 .await
2408 .unwrap();
2409 deploy
2410 .set_site_config(
2411 ProjectRef::DEFAULT,
2412 "blog",
2413 &SiteConfig {
2414 handlers: Some(HandlersSiteConfig {
2415 enabled: true,
2416 allow_imports: vec!["wasi:keyvalue".into()],
2417 ..Default::default()
2418 }),
2419 ..Default::default()
2420 },
2421 )
2422 .await
2423 .unwrap();
2424
2425 messaging
2427 .publish("blog/orders/created", b"live")
2428 .await
2429 .unwrap();
2430 messaging
2431 .publish("blog/_preview/abc/orders/created", b"preview")
2432 .await
2433 .unwrap();
2434
2435 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2436 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2437 let inner = rt.inner.clone().unwrap();
2438 let mut cache = std::collections::HashMap::new();
2439 let mut crons = std::collections::HashMap::new();
2440 let mut sweep = std::collections::HashMap::new();
2441 let now = CronNow {
2442 minute: 0,
2443 hour: 0,
2444 dom: 1,
2445 month: 1,
2446 dow: 0,
2447 minute_stamp: 0,
2448 };
2449 for _ in 0..3 {
2450 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2451 .await
2452 .unwrap();
2453 }
2454
2455 assert_eq!(
2457 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2458 Some(b"1".to_vec())
2459 );
2460 assert_eq!(
2463 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2464 .await
2465 .unwrap(),
2466 None
2467 );
2468 }
2469
2470 const KV_COUNTER: &[u8] =
2475 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2476
2477 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2482 async fn function_invoker_runs_target_buffers_and_meters() {
2483 use boatramp_core::deploy::DeployStore;
2484 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2485 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2486 use futures::StreamExt;
2487
2488 const HTTP_200: &[u8] =
2491 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2492
2493 let storage = Arc::new(MemStorage::default());
2494 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2495 let deploy = DeployStore::new(storage.clone(), kv.clone());
2496
2497 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2498 let stream: ByteStream =
2499 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2500 deploy.put_blob(&hash, stream).await.unwrap();
2501 let function = Function {
2502 name: "target".into(),
2503 owner: Owner::Project("default".into()),
2504 versions: vec![FunctionVersion {
2505 id: "v1".into(),
2506 component: hash.clone(),
2507 created: 0,
2508 lifecycle: Lifecycle::Independent,
2509 }],
2510 active: "v1".into(),
2511 aliases: Default::default(),
2512 config: Default::default(),
2513 };
2514 deploy
2515 .put_function(ProjectRef::DEFAULT, &function)
2516 .await
2517 .unwrap();
2518
2519 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2520 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2521 rt.set_invoker(deploy.clone());
2522 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2523
2524 let request = || InvokeRequest {
2525 method: "GET".into(),
2526 path: "/".into(),
2527 headers: vec![],
2528 body: vec![],
2529 };
2530
2531 let response = invoker.invoke("target", request(), 1).await.unwrap();
2533 assert_eq!(response.status, 200);
2534
2535 let metering = deploy
2537 .get_metering(ProjectRef::DEFAULT, "target")
2538 .await
2539 .unwrap()
2540 .unwrap();
2541 assert_eq!(metering.invocations, 1);
2542
2543 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2545 assert!(matches!(err, InvokeError::NotFound));
2546 }
2547
2548 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2554 async fn federation_runner_enforces_the_safelist_before_planning() {
2555 use boatramp_core::deploy::DeployStore;
2556 use boatramp_core::project::ProjectRef;
2557 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2558
2559 let storage = Arc::new(MemStorage::default());
2560 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2561 let deploy = DeployStore::new(storage.clone(), kv.clone());
2562 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2563 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2564 rt.set_invoker(deploy.clone());
2565 let runner = rt
2566 .inner
2567 .as_ref()
2568 .unwrap()
2569 .federation_runner
2570 .get()
2571 .unwrap()
2572 .scoped(ProjectRef::new("default"));
2573
2574 let req = |query: &str| GraphqlRequest {
2575 query: Some(query.to_string()),
2576 persisted_hash: None,
2577 variables: "{}".to_string(),
2578 operation_name: None,
2579 authorization: None,
2580 };
2581
2582 assert!(matches!(
2584 runner.run(req("{ me { id } }"), 1).await,
2585 Err(SupergraphRunError::NotSafelisted)
2586 ));
2587
2588 let query = "{ me { id } }";
2591 let hash = crate::graphql_apq::sha256_hex(query);
2592 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2593 .await
2594 .unwrap();
2595 assert!(matches!(
2596 runner.run(req(query), 1).await,
2597 Err(SupergraphRunError::PlanFailed(_))
2598 ));
2599
2600 let persisted = GraphqlRequest {
2602 query: None,
2603 persisted_hash: Some("deadbeef".to_string()),
2604 variables: "{}".to_string(),
2605 operation_name: None,
2606 authorization: None,
2607 };
2608 assert!(matches!(
2609 runner.run(persisted, 1).await,
2610 Err(SupergraphRunError::NotSafelisted)
2611 ));
2612 }
2613
2614 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2620 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2621 use crate::scheduler::{run_scheduler_tick, CronNow};
2622 use boatramp_core::deploy::DeployStore;
2623 use boatramp_core::function::{
2624 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2625 };
2626 use boatramp_handlers::{HandlerEngine, Limits};
2627 use futures::StreamExt;
2628
2629 const HTTP_200: &[u8] =
2630 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2631
2632 let storage = Arc::new(MemStorage::default());
2633 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2634 let deploy = DeployStore::new(storage.clone(), kv.clone());
2635
2636 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2637 let stream: ByteStream =
2638 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2639 deploy.put_blob(&hash, stream).await.unwrap();
2640
2641 let acme = ProjectRef::new("acme");
2643 let function = Function {
2644 name: "worker".into(),
2645 owner: Owner::Project("acme".into()),
2646 versions: vec![FunctionVersion {
2647 id: "v1".into(),
2648 component: hash.clone(),
2649 created: 0,
2650 lifecycle: Lifecycle::Independent,
2651 }],
2652 active: "v1".into(),
2653 aliases: Default::default(),
2654 config: Default::default(),
2655 };
2656 deploy.put_function(acme, &function).await.unwrap();
2657 let inv = Invocation {
2658 id: "inv1".into(),
2659 function: "worker".into(),
2660 version: "v1".into(),
2661 mode: InvokeMode::Async,
2662 status: InvocationStatus::Queued,
2663 idempotency_key: None,
2664 attempts: 0,
2665 lease_expires: None,
2666 request_b64: None,
2667 request_content_type: None,
2668 result: None,
2669 created: 0,
2670 updated: 0,
2671 };
2672 deploy.put_invocation(acme, &inv).await.unwrap();
2673
2674 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2675 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2676 let inner = rt.inner.as_ref().unwrap();
2677
2678 let mut wasm_cache = std::collections::HashMap::new();
2681 let mut cron_state = std::collections::HashMap::new();
2682 let mut sweep = std::collections::HashMap::new();
2683 let now = CronNow {
2684 minute: 0,
2685 hour: 0,
2686 dom: 1,
2687 month: 1,
2688 dow: 0,
2689 minute_stamp: 0,
2690 };
2691 run_scheduler_tick(
2692 inner,
2693 &deploy,
2694 &mut wasm_cache,
2695 &mut cron_state,
2696 &mut sweep,
2697 now,
2698 )
2699 .await
2700 .unwrap();
2701
2702 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2705 assert_eq!(settled.status, InvocationStatus::Succeeded);
2707 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2709 assert_eq!(metering.invocations, 1);
2710 assert!(deploy
2712 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2713 .await
2714 .unwrap()
2715 .is_none());
2716 assert!(deploy
2717 .get_metering(ProjectRef::DEFAULT, "worker")
2718 .await
2719 .unwrap()
2720 .is_none());
2721 }
2722
2723 #[cfg(feature = "handlers")]
2727 async fn poll_invocation_settled(
2728 deploy: &boatramp_core::deploy::DeployStore,
2729 project: ProjectRef<'_>,
2730 function: &str,
2731 id: &str,
2732 ) -> boatramp_core::function::Invocation {
2733 use boatramp_core::function::InvocationStatus;
2734 for _ in 0..200 {
2735 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2736 if matches!(
2737 inv.status,
2738 InvocationStatus::Succeeded | InvocationStatus::Failed
2739 ) {
2740 return inv;
2741 }
2742 }
2743 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2744 }
2745 panic!("invocation {function}/{id} never settled");
2746 }
2747
2748 #[cfg(feature = "handlers")]
2753 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2754 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2755 use crate::scheduler::{run_scheduler_tick, CronNow};
2756 use boatramp_core::deploy::DeployStore;
2757 use boatramp_core::function::{
2758 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2759 };
2760 use boatramp_handlers::{HandlerEngine, Limits};
2761 use futures::StreamExt;
2762
2763 const HTTP_200: &[u8] =
2764 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2765
2766 let storage = Arc::new(MemStorage::default());
2767 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2768 let deploy = DeployStore::new(storage.clone(), kv.clone());
2769 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2770 let stream: ByteStream =
2771 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2772 deploy.put_blob(&hash, stream).await.unwrap();
2773
2774 let function = Function {
2775 name: "worker".into(),
2776 owner: Owner::Project("default".into()),
2777 versions: vec![FunctionVersion {
2778 id: "v1".into(),
2779 component: hash.clone(),
2780 created: 0,
2781 lifecycle: Lifecycle::Independent,
2782 }],
2783 active: "v1".into(),
2784 aliases: Default::default(),
2785 config: Default::default(),
2786 };
2787 deploy
2788 .put_function(ProjectRef::DEFAULT, &function)
2789 .await
2790 .unwrap();
2791
2792 let base = Invocation {
2795 id: String::new(),
2796 function: "worker".into(),
2797 version: "v1".into(),
2798 mode: InvokeMode::Async,
2799 status: InvocationStatus::Running,
2800 idempotency_key: None,
2801 attempts: 1,
2802 lease_expires: None,
2803 request_b64: None,
2804 request_content_type: None,
2805 result: None,
2806 created: 0,
2807 updated: 0,
2808 };
2809 let orphan = Invocation {
2810 id: "orphan".into(),
2811 lease_expires: Some(1),
2812 ..base.clone()
2813 };
2814 deploy
2815 .put_invocation(ProjectRef::DEFAULT, &orphan)
2816 .await
2817 .unwrap();
2818 let live = Invocation {
2819 id: "live".into(),
2820 lease_expires: Some(u64::MAX),
2821 ..base.clone()
2822 };
2823 deploy
2824 .put_invocation(ProjectRef::DEFAULT, &live)
2825 .await
2826 .unwrap();
2827
2828 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2829 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2830 let inner = rt.inner.as_ref().unwrap();
2831
2832 let now = CronNow {
2833 minute: 0,
2834 hour: 0,
2835 dom: 1,
2836 month: 1,
2837 dow: 0,
2838 minute_stamp: 0,
2839 };
2840 let mut wasm_cache = std::collections::HashMap::new();
2841 let mut cron_state = std::collections::HashMap::new();
2842 let mut sweep = std::collections::HashMap::new();
2843 run_scheduler_tick(
2844 inner,
2845 &deploy,
2846 &mut wasm_cache,
2847 &mut cron_state,
2848 &mut sweep,
2849 now,
2850 )
2851 .await
2852 .unwrap();
2853
2854 let settled =
2856 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2857 assert_eq!(settled.status, InvocationStatus::Succeeded);
2858 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2859 assert_eq!(
2860 settled.lease_expires, None,
2861 "a settled invocation drops its lease"
2862 );
2863 let live_after = deploy
2865 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2866 .await
2867 .unwrap()
2868 .unwrap();
2869 assert_eq!(live_after.status, InvocationStatus::Running);
2870 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2871 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2872 }
2873
2874 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2889 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2890 use boatramp_core::deploy::DeployStore;
2891 use boatramp_core::function::{
2892 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2893 };
2894 use boatramp_handlers::{HandlerEngine, Limits};
2895 use futures::StreamExt;
2896
2897 let storage = Arc::new(MemStorage::default());
2898 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2899 let deploy = DeployStore::new(storage.clone(), kv.clone());
2900
2901 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2904 let stream: ByteStream =
2905 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2906 deploy.put_blob(&hash, stream).await.unwrap();
2907
2908 let store = Function {
2913 name: "store".into(),
2914 owner: Owner::Project("default".into()),
2915 versions: vec![FunctionVersion {
2916 id: "v1".into(),
2917 component: hash.clone(),
2918 created: 0,
2919 lifecycle: Lifecycle::Independent,
2920 }],
2921 active: "v1".into(),
2922 aliases: Default::default(),
2923 config: FunctionConfig {
2924 imports: vec!["wasi:keyvalue".into()],
2925 ..Default::default()
2926 },
2927 };
2928 let acme = ProjectRef::new("acme");
2929 let globex = ProjectRef::new("globex");
2930
2931 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2932 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2933 let inner = rt.inner.as_ref().unwrap();
2934
2935 let request = || {
2936 axum::http::Request::builder()
2937 .method("GET")
2938 .uri("/")
2939 .body(axum::body::Body::empty())
2940 .unwrap()
2941 };
2942
2943 let component = store.resolve(&store.active).unwrap().to_owned();
2946 for project in [acme, globex, ProjectRef::DEFAULT] {
2947 let (response, _) = execute_function(
2948 inner,
2949 &deploy,
2950 project,
2951 &store,
2952 &component,
2953 request(),
2954 0,
2955 boatramp_handlers::Lane::Sync,
2956 )
2957 .await;
2958 assert!(response.status().is_success(), "invocation should succeed");
2959 }
2960
2961 assert_eq!(
2964 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2965 Some(b"1".to_vec()),
2966 "acme's write must be tenant-qualified"
2967 );
2968 assert_eq!(
2969 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2970 Some(b"1".to_vec()),
2971 "globex's write must be tenant-qualified"
2972 );
2973 assert_eq!(
2974 kv.get("hkv/fn/store/hits").await.unwrap(),
2975 Some(b"1".to_vec()),
2976 "the default project must keep the byte-identical pre-project key"
2977 );
2978 }
2981
2982 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2986 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2987 use boatramp_core::config::{
2988 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2989 };
2990 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2991 use boatramp_handlers::{HandlerEngine, Limits};
2992 use futures::StreamExt;
2993 use std::sync::atomic::Ordering;
2994
2995 let storage = Arc::new(MemStorage::default());
2996 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2997 let deploy = DeployStore::new(storage.clone(), kv.clone());
2998
2999 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3000 let stream: ByteStream =
3001 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3002 deploy.put_blob(&hash, stream).await.unwrap();
3003 let mut files = std::collections::BTreeMap::new();
3004 files.insert(
3005 "counter.wasm".to_string(),
3006 FileEntry {
3007 hash: hash.clone(),
3008 size: KV_COUNTER.len() as u64,
3009 content_type: None,
3010 variants: std::collections::BTreeMap::new(),
3011 },
3012 );
3013 let manifest = Manifest {
3014 files,
3015 config: DeployConfig {
3016 handlers: vec![HandlerConfig {
3017 route: "/".into(),
3018 methods: Vec::new(),
3019 component: "counter.wasm".into(),
3020 imports: vec!["wasi:keyvalue".into()],
3021 limits: None,
3022 env: std::collections::BTreeMap::new(),
3023 invoke_targets: Vec::new(),
3024 }],
3025 crons: vec![CronConfig {
3026 schedule: "* * * * *".into(),
3027 route: "/".into(),
3028 overlap: Overlap::Skip,
3029 }],
3030 ..Default::default()
3031 },
3032 ..Default::default()
3033 };
3034 let id = deploy.put_manifest(&manifest).await.unwrap();
3035 deploy
3036 .activate(ProjectRef::DEFAULT, "blog", &id)
3037 .await
3038 .unwrap();
3039 deploy
3040 .set_site_config(
3041 ProjectRef::DEFAULT,
3042 "blog",
3043 &SiteConfig {
3044 handlers: Some(HandlersSiteConfig {
3045 enabled: true,
3046 allow_imports: vec!["wasi:keyvalue".into()],
3047 ..Default::default()
3048 }),
3049 ..Default::default()
3050 },
3051 )
3052 .await
3053 .unwrap();
3054
3055 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3056 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3057 let inner = rt.inner.clone().unwrap();
3058 let mut wasm = std::collections::HashMap::new();
3059 let mut crons = std::collections::HashMap::new();
3060 let mut sweep = std::collections::HashMap::new();
3061 let at = |stamp| CronNow {
3062 minute: 0,
3063 hour: 0,
3064 dom: 1,
3065 month: 1,
3066 dow: 0,
3067 minute_stamp: stamp,
3068 };
3069
3070 let (_, handles) =
3072 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3073 .await
3074 .unwrap();
3075 for h in handles {
3076 h.await.unwrap();
3077 }
3078 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3079
3080 let (_, handles) =
3082 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3083 .await
3084 .unwrap();
3085 assert!(handles.is_empty());
3086 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3087
3088 let (_, handles) =
3090 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3091 .await
3092 .unwrap();
3093 for h in handles {
3094 h.await.unwrap();
3095 }
3096 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3097
3098 crons
3102 .get("default|blog|cron|0")
3103 .unwrap()
3104 .running
3105 .store(true, Ordering::Release);
3106 let (_, handles) =
3107 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3108 .await
3109 .unwrap();
3110 assert!(handles.is_empty());
3111 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3112 }
3113
3114 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3118 async fn cron_leader_gate_suppresses_crons_off_leader() {
3119 use boatramp_core::config::{
3120 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3121 };
3122 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3123 use boatramp_handlers::{HandlerEngine, Limits};
3124 use futures::StreamExt;
3125
3126 let storage = Arc::new(MemStorage::default());
3127 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3128 let deploy = DeployStore::new(storage.clone(), kv.clone());
3129
3130 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3131 let stream: ByteStream =
3132 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3133 deploy.put_blob(&hash, stream).await.unwrap();
3134 let mut files = std::collections::BTreeMap::new();
3135 files.insert(
3136 "counter.wasm".to_string(),
3137 FileEntry {
3138 hash: hash.clone(),
3139 size: KV_COUNTER.len() as u64,
3140 content_type: None,
3141 variants: std::collections::BTreeMap::new(),
3142 },
3143 );
3144 let manifest = Manifest {
3145 files,
3146 config: DeployConfig {
3147 handlers: vec![HandlerConfig {
3148 route: "/".into(),
3149 methods: Vec::new(),
3150 component: "counter.wasm".into(),
3151 imports: vec!["wasi:keyvalue".into()],
3152 limits: None,
3153 env: std::collections::BTreeMap::new(),
3154 invoke_targets: Vec::new(),
3155 }],
3156 crons: vec![CronConfig {
3157 schedule: "* * * * *".into(),
3158 route: "/".into(),
3159 overlap: Overlap::Skip,
3160 }],
3161 ..Default::default()
3162 },
3163 ..Default::default()
3164 };
3165 let id = deploy.put_manifest(&manifest).await.unwrap();
3166 deploy
3167 .activate(ProjectRef::DEFAULT, "blog", &id)
3168 .await
3169 .unwrap();
3170 deploy
3171 .set_site_config(
3172 ProjectRef::DEFAULT,
3173 "blog",
3174 &SiteConfig {
3175 handlers: Some(HandlersSiteConfig {
3176 enabled: true,
3177 allow_imports: vec!["wasi:keyvalue".into()],
3178 ..Default::default()
3179 }),
3180 ..Default::default()
3181 },
3182 )
3183 .await
3184 .unwrap();
3185
3186 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3187 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3188 rt.set_cron_leader_gate(Arc::new(|| false));
3190 let inner = rt.inner.clone().unwrap();
3191 let mut wasm = std::collections::HashMap::new();
3192 let mut crons = std::collections::HashMap::new();
3193 let mut sweep = std::collections::HashMap::new();
3194 let now = CronNow {
3195 minute: 0,
3196 hour: 0,
3197 dom: 1,
3198 month: 1,
3199 dow: 0,
3200 minute_stamp: 100,
3201 };
3202
3203 let (_, handles) =
3204 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3205 .await
3206 .unwrap();
3207 assert!(handles.is_empty(), "a non-leader must not fire crons");
3209 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3210 }
3211
3212 #[tokio::test]
3219 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3220 use boatramp_core::config::HandlersSiteConfig;
3221 use boatramp_core::project::ProjectRef;
3222 use boatramp_handlers::{HandlerEngine, Limits};
3223
3224 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3225 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3226 let sql_dir =
3228 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3229 let _ = std::fs::remove_dir_all(&sql_dir);
3230 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3231 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3232
3233 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3234 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3235 let inner = rt.inner.as_ref().unwrap();
3236
3237 let site = HandlersSiteConfig {
3239 enabled: true,
3240 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3241 ..Default::default()
3242 };
3243 let env = std::collections::BTreeMap::new();
3244 let build = |imports: &[&str]| {
3245 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3246 let site = &site;
3247 let env = &env;
3248 async move {
3249 crate::handler_dispatch::build_bindings(
3250 inner,
3251 ProjectRef::new("default"),
3252 "shop",
3253 "shop",
3254 None,
3255 &imports,
3256 site,
3257 env,
3258 &[],
3259 0,
3260 None,
3261 )
3262 .await
3263 .sql_database_names()
3264 }
3265 };
3266
3267 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3270 assert_eq!(
3272 build(&["sql", "sql:*"]).await,
3273 vec!["", "privileged", "product"]
3274 );
3275 assert!(build(&["sql:secret"]).await.is_empty());
3277 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3279
3280 let _ = std::fs::remove_dir_all(&sql_dir);
3281 }
3282}