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};
49mod auth;
50#[cfg(feature = "console")]
51pub mod console;
52mod content;
53mod control_api;
54#[cfg(feature = "compression")]
55pub(crate) use content::maybe_compress;
56pub(crate) use content::multipart_byteranges;
57pub(crate) use content::{
58 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
59};
60pub(crate) use control_api::{
61 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
62 cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
63 list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
64};
65#[cfg(all(test, feature = "handlers"))]
66use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
67mod domain_verify;
68pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
69pub mod envelope;
70#[cfg(feature = "handlers")]
71mod handler_dispatch;
72#[cfg(feature = "handlers")]
73pub(crate) use handler_dispatch::{
74 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
75 read_blob_fully,
76};
77#[cfg(all(feature = "handlers", test))]
78use handler_dispatch::{resolve_env, set_forwarded_headers};
79mod function_api;
80pub(crate) use function_api::{
81 alias_function, deploy_function, list_functions, remove_function, rollback_function,
82};
83#[cfg(all(test, feature = "handlers"))]
84use function_api::{AliasBody, FunctionUpsert, RollbackBody};
85mod gateway;
86mod host;
87pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
88#[cfg(feature = "http3")]
89mod http3;
90mod limits;
91#[cfg(feature = "handlers")]
92mod logs;
93#[cfg(feature = "handlers")]
94mod metrics;
95#[cfg(feature = "oidc")]
96mod oidc;
97mod operator;
98pub(crate) use operator::prometheus_metrics;
99#[cfg(feature = "handlers")]
100pub(crate) use operator::{
101 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
102};
103mod proxy;
104pub use proxy::spawn_compute_reconcile;
105pub(crate) use proxy::{
106 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
107 proxy, COMPUTE_WAKE_TIMEOUT,
108};
109#[cfg(feature = "handlers")]
111pub(crate) use proxy::is_upgrade_request;
112#[cfg(all(test, feature = "handlers"))]
113use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
114mod project_api;
115pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
116mod project_scope;
117pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
118mod ratelimit;
119mod routes;
120pub use routes::{router, router_with};
121#[cfg(feature = "mcp")]
122mod mcp_http;
123#[cfg(feature = "handlers")]
124mod scheduler;
125mod serve_pipeline;
126pub use serve_pipeline::http_redirect_router;
127#[cfg(all(test, feature = "handlers"))]
128use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
129pub(crate) use serve_pipeline::{
130 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
131 BootstrapAttestation,
132};
133pub mod signer;
136mod srvmetrics;
137#[cfg(all(feature = "handlers", test))]
138use scheduler::run_scheduler_tick;
139#[cfg(feature = "handlers")]
140pub(crate) use scheduler::{
141 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
142};
143#[cfg(feature = "handlers")]
144use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
145#[cfg(feature = "handlers")]
146mod function_runtime;
147#[cfg(feature = "handlers")]
148pub(crate) use function_runtime::{
149 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
150 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
151 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
152 put_trigger_handler, webhook_ingress,
153};
154#[cfg(feature = "handlers")]
155mod stream;
156#[cfg(feature = "handlers")]
157mod workflow;
158pub use auth::{require_auth, Auth};
159#[cfg(feature = "http3")]
160pub use http3::{
161 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
162 Http3Error,
163};
164pub use limits::{ServerLimits, UploadGuard};
165#[cfg(feature = "oidc")]
166pub use oidc::{OidcConfig, OidcError, OidcVerifier};
167use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
168#[cfg(feature = "handlers")]
169pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
170#[cfg(feature = "handlers")]
171pub(crate) use workflow::{
172 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
173 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
174};
175pub use srvmetrics::{server_metrics, ServerMetrics};
178
179#[derive(Clone, Default)]
184pub struct HandlerRuntime {
185 #[cfg(feature = "handlers")]
186 inner: Option<Arc<HandlerRuntimeInner>>,
187}
188
189#[cfg(feature = "handlers")]
190struct HandlerRuntimeInner {
191 engine: boatramp_handlers::HandlerEngine,
192 kv: Arc<dyn boatramp_core::kv::KvStore>,
193 storage: Arc<dyn boatramp_core::Storage>,
194 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
198 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
201 site_semaphores:
204 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
205 stream_semaphores:
209 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
210 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
213 metrics: metrics::Metrics,
216 logs: Arc<logs::LogStore>,
218 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
224 max_blob_bytes: std::sync::OnceLock<u64>,
228 max_component_bytes: std::sync::OnceLock<u64>,
233 function_meter_locks:
237 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
238 function_semaphores:
241 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
242 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
247 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
251 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
259}
260
261pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
264
265impl HandlerRuntime {
266 pub fn disabled() -> Self {
268 Self::default()
269 }
270
271 #[cfg(feature = "handlers")]
277 pub fn new(
278 engine: boatramp_handlers::HandlerEngine,
279 kv: Arc<dyn boatramp_core::kv::KvStore>,
280 storage: Arc<dyn boatramp_core::Storage>,
281 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
282 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
283 ) -> Self {
284 Self {
285 inner: Some(Arc::new(HandlerRuntimeInner {
286 engine,
287 kv,
288 storage,
289 sql,
290 messaging,
291 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
292 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
293 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
294 metrics: metrics::Metrics::default(),
295 logs: Arc::new(logs::LogStore::default()),
296 cron_leader_gate: std::sync::OnceLock::new(),
297 max_blob_bytes: std::sync::OnceLock::new(),
298 max_component_bytes: std::sync::OnceLock::new(),
299 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
300 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
301 watch_provider: std::sync::OnceLock::new(),
302 provision_tier: std::sync::OnceLock::new(),
303 invoker: std::sync::OnceLock::new(),
304 })),
305 }
306 }
307
308 #[cfg(feature = "handlers")]
314 pub fn set_invoker(&self, deploy: DeployStore) {
315 if let Some(inner) = self.inner.as_ref() {
316 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
317 deploy,
318 Arc::downgrade(inner),
319 ));
320 let _ = inner.invoker.set(invoker);
321 }
322 }
323
324 #[cfg(feature = "handlers")]
328 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
329 self.inner.as_ref().and_then(|inner| inner.sql.clone())
330 }
331
332 #[cfg(feature = "handlers")]
336 pub fn set_watch_provider(
337 &self,
338 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
339 ) {
340 if let Some(inner) = self.inner.as_ref() {
341 let _ = inner.watch_provider.set(provider);
342 }
343 }
344
345 #[cfg(feature = "handlers")]
349 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
350 if let Some(inner) = self.inner.as_ref() {
351 let _ = inner.provision_tier.set(tier);
352 }
353 }
354
355 #[cfg(feature = "handlers")]
359 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
360 if let Some(inner) = self.inner.as_ref() {
361 let _ = inner.max_blob_bytes.set(max_bytes);
362 }
363 }
364
365 #[cfg(feature = "handlers")]
368 pub fn set_max_component_bytes(&self, max_bytes: u64) {
369 if let Some(inner) = self.inner.as_ref() {
370 let _ = inner.max_component_bytes.set(max_bytes);
371 }
372 }
373
374 #[cfg(feature = "handlers")]
379 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
380 if let Some(inner) = self.inner.as_ref() {
381 let _ = inner.cron_leader_gate.set(gate);
382 }
383 }
384
385 #[cfg(feature = "handlers")]
392 async fn precheck_activation(
393 &self,
394 deploy: &DeployStore,
395 manifest: &Manifest,
396 site_config: Option<&SiteConfig>,
397 ) -> Result<(), String> {
398 let Some(inner) = self.inner.as_ref() else {
399 return Ok(());
400 };
401 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
404 return Ok(());
405 }
406 let site_handlers = site_config
408 .and_then(|c| c.handlers.as_ref())
409 .filter(|h| h.enabled)
410 .ok_or_else(|| {
411 "deployment ships handlers/consumers but the site has them disabled".to_string()
412 })?;
413 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
414
415 for handler in &manifest.config.handlers {
417 precheck_component(
418 deploy,
419 manifest,
420 site_handlers,
421 inner,
422 max_component,
423 &handler.imports,
424 &handler.component,
425 &format!("handler {:?}", handler.route),
426 )
427 .await?;
428 }
429 for consumer in &manifest.config.consumers {
430 precheck_component(
431 deploy,
432 manifest,
433 site_handlers,
434 inner,
435 max_component,
436 &consumer.imports,
437 &consumer.component,
438 &format!("consumer {:?}", consumer.topic),
439 )
440 .await?;
441 }
442 Ok(())
443 }
444
445 #[cfg(not(feature = "handlers"))]
446 async fn precheck_activation(
447 &self,
448 _deploy: &DeployStore,
449 _manifest: &Manifest,
450 _site_config: Option<&SiteConfig>,
451 ) -> Result<(), String> {
452 Ok(())
453 }
454}
455
456#[derive(Default, Clone)]
462pub struct ServerOptions {
463 pub limits: ServerLimits,
465 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
468 pub default_site: Option<String>,
471 pub implicit_routing: bool,
477 pub protect_previews: bool,
482 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
486 pub issuer: Option<Arc<dyn Signer>>,
490 pub bootstrap_secret: Option<String>,
495 pub bootstrap_attestation: Option<String>,
501 pub mesh_control: Option<Arc<dyn MeshControl>>,
505 pub cors_allowed_origins: Vec<String>,
515 #[cfg(feature = "oidc")]
518 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
519 pub posture: boatramp_core::security::SecurityPosture,
523 pub served_over_tls: bool,
528 pub pop_origin: Option<String>,
535 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
539 #[cfg(feature = "console")]
543 pub console: Option<console::ConsoleMount>,
544}
545
546#[derive(Clone, Copy)]
550struct ServedOverTls(bool);
551
552#[derive(Clone, Copy, Default)]
557struct ImplicitRouting(bool);
558
559const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
571
572pub struct DaemonRuntime {
573 baseline: boatramp_core::daemon_config::ConfigBaseline,
574 state: std::sync::RwLock<DaemonState>,
575 reload: tokio::sync::Notify,
578}
579
580struct DaemonState {
581 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
582 generation: Option<String>,
583}
584
585pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
590 #[cfg(feature = "console")]
594 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
595 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
596 None => (false, None, None),
597 };
598 #[cfg(not(feature = "console"))]
599 let (console_enabled, console_host, console_path) = (false, None, None);
600 boatramp_core::daemon_config::ConfigBaseline {
601 default_site: options.default_site.clone(),
602 protect_previews: options.protect_previews,
603 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
604 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
605 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
606 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
607 compute_vcpus: 0,
608 compute_mem_mib: 0,
609 console_enabled,
610 console_host,
611 console_path,
612 max_upload_ceiling: options.posture.max_upload_bytes,
613 max_concurrent_uploads_ceiling: None,
614 posture: options.posture,
615 }
616}
617
618impl DaemonRuntime {
619 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
623 let effective =
624 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
625 Self {
626 baseline,
627 state: std::sync::RwLock::new(DaemonState {
628 effective,
629 generation: None,
630 }),
631 reload: tokio::sync::Notify::new(),
632 }
633 }
634
635 pub fn notify_reload(&self) {
639 self.reload.notify_one();
640 }
641
642 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
644 self.state
645 .read()
646 .expect("daemon config lock")
647 .effective
648 .clone()
649 }
650
651 pub fn generation(&self) -> Option<String> {
654 self.state
655 .read()
656 .expect("daemon config lock")
657 .generation
658 .clone()
659 }
660
661 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
663 &self.baseline
664 }
665
666 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
669 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
670 let generation = deploy.daemon_config_generation().await?;
671 let effective = Arc::new(cfg.resolve(&self.baseline));
672 *self.state.write().expect("daemon config lock") = DaemonState {
673 effective,
674 generation,
675 };
676 Ok(())
677 }
678}
679
680#[derive(Clone, Copy, Default)]
683struct PreviewPolicy {
684 protect: bool,
685}
686
687#[derive(Clone, Default)]
692struct Issuer(Option<Arc<dyn Signer>>);
693
694#[derive(Clone, Default)]
699struct BootstrapGate(Option<Arc<BootstrapInner>>);
700
701struct BootstrapInner {
702 secret_hash: String,
705 lock: tokio::sync::Mutex<()>,
708}
709
710impl BootstrapGate {
711 fn new(secret: Option<&str>) -> Self {
712 Self(secret.filter(|s| !s.is_empty()).map(|s| {
713 Arc::new(BootstrapInner {
714 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
715 lock: tokio::sync::Mutex::new(()),
716 })
717 }))
718 }
719}
720
721#[async_trait::async_trait]
725pub trait MeshControl: Send + Sync {
726 async fn admit(
734 &self,
735 mesh_pubkey_hex: &str,
736 jti: &str,
737 possession_proof: &[u8],
738 proof_iat: u64,
739 now: u64,
740 advertise_addr: Option<&str>,
741 ) -> Result<JoinOutcome, String>;
742
743 async fn rotate_key(&self) -> Result<String, String>;
747
748 async fn revoke(&self, node: u64) -> Result<(), String>;
752
753 async fn members(&self) -> Result<Vec<MeshMember>, String>;
757
758 async fn promote(&self, node: u64) -> Result<(), String>;
761}
762
763pub enum JoinOutcome {
765 Admitted {
768 members: Vec<String>,
770 addrs: std::collections::BTreeMap<u64, String>,
772 },
773 TokenSpent,
775 ProofInvalid,
777 Revoked,
780}
781
782#[derive(Debug, Clone, Serialize)]
784pub struct MeshMember {
785 pub node: u64,
787 pub voter: bool,
789 pub caught_up: bool,
791 pub leader: bool,
793 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub addr: Option<String>,
798}
799
800#[derive(Clone, Default)]
803struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
804
805#[cfg(feature = "oidc")]
807#[derive(Clone, Default)]
808struct OidcState(Option<Arc<oidc::OidcVerifier>>);
809
810#[cfg(feature = "oidc")]
813const EXCHANGE_TTL_SECS: u64 = 3600;
814
815use boatramp_core::time::now_unix;
816
817#[derive(Clone)]
819struct CorsState(Arc<Vec<String>>);
820
821const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
823const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
826const CORS_MAX_AGE: &str = "600";
828
829fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
833 allowed.iter().any(|a| a == "*" || a == origin)
834}
835
836async fn cors(
843 State(allowed): State<CorsState>,
844 request: Request,
845 next: axum::middleware::Next,
846) -> Response {
847 let origin = request
848 .headers()
849 .get(header::ORIGIN)
850 .and_then(|v| v.to_str().ok())
851 .filter(|o| cors_origin_allowed(&allowed.0, o))
852 .map(str::to_string);
853 let is_preflight = request.method() == Method::OPTIONS
855 && request
856 .headers()
857 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
858 if is_preflight {
859 let allow_headers = request
861 .headers()
862 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
863 .and_then(|v| v.to_str().ok())
864 .map(str::to_string)
865 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
866 let mut response = Response::new(Body::empty());
867 *response.status_mut() = StatusCode::NO_CONTENT;
868 if let Some(origin) = origin {
869 let headers = response.headers_mut();
870 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
871 set_header(headers, header::VARY, "Origin");
872 set_header(
873 headers,
874 header::ACCESS_CONTROL_ALLOW_METHODS,
875 CORS_ALLOW_METHODS,
876 );
877 set_header(
878 headers,
879 header::ACCESS_CONTROL_ALLOW_HEADERS,
880 &allow_headers,
881 );
882 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
883 }
884 return response;
885 }
886 let mut response = next.run(request).await;
887 if let Some(origin) = origin {
888 let headers = response.headers_mut();
889 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
890 if let Ok(value) = HeaderValue::from_str("Origin") {
893 headers.append(header::VARY, value);
894 }
895 }
896 response
897}
898
899const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
904
905#[derive(Debug, thiserror::Error)]
907pub enum ServeError {
908 #[error("server I/O: {0}")]
910 Io(#[from] std::io::Error),
911}
912
913pub async fn serve(
916 addr: SocketAddr,
917 deploy: DeployStore,
918 auth: Auth,
919 handlers: HandlerRuntime,
920) -> Result<(), ServeError> {
921 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
922}
923
924pub async fn serve_with(
926 addr: SocketAddr,
927 deploy: DeployStore,
928 auth: Auth,
929 handlers: HandlerRuntime,
930 options: ServerOptions,
931) -> Result<(), ServeError> {
932 let listener = tokio::net::TcpListener::bind(addr).await?;
933 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
934 #[cfg(feature = "handlers")]
937 let scheduler = handlers.spawn_scheduler(deploy.clone());
938 let gateway_prober = gateway::spawn_active_health_prober();
942 let app = router_with(deploy, auth, handlers, options)
945 .into_make_service_with_connect_info::<SocketAddr>();
946
947 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
951 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
952 shutdown_signal().await;
953 let _ = signalled_tx.send(true);
954 });
955 let signalled = {
956 let mut rx = signalled_rx;
957 async move {
958 let _ = rx.wait_for(|fired| *fired).await;
959 }
960 };
961 let result = serve_with_drain_deadline(
962 async move { server.await.map_err(ServeError::from) },
963 signalled,
964 DRAIN_DEADLINE,
965 )
966 .await;
967 #[cfg(feature = "handlers")]
969 if let Some(handle) = scheduler {
970 handle.abort();
971 }
972 gateway_prober.abort();
973 result
974}
975
976async fn serve_with_drain_deadline<Srv, Sig>(
981 server: Srv,
982 signalled: Sig,
983 deadline: Duration,
984) -> Result<(), ServeError>
985where
986 Srv: Future<Output = Result<(), ServeError>>,
987 Sig: Future<Output = ()>,
988{
989 tokio::pin!(server);
990 let drain_cap = async move {
991 signalled.await;
992 tokio::time::sleep(deadline).await;
993 };
994 tokio::select! {
995 result = &mut server => result,
996 _ = drain_cap => {
997 tracing::warn!(
998 deadline_s = deadline.as_secs(),
999 "drain deadline exceeded; forcing shutdown with requests still in flight"
1000 );
1001 Ok(())
1002 }
1003 }
1004}
1005
1006pub async fn shutdown_signal() {
1009 let ctrl_c = async {
1010 let _ = tokio::signal::ctrl_c().await;
1011 };
1012 #[cfg(unix)]
1013 let terminate = async {
1014 if let Ok(mut sig) =
1015 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1016 {
1017 sig.recv().await;
1018 }
1019 };
1020 #[cfg(not(unix))]
1021 let terminate = std::future::pending::<()>();
1022
1023 tokio::select! {
1024 _ = ctrl_c => {}
1025 _ = terminate => {}
1026 }
1027 tracing::info!("shutdown signal received; draining");
1028}
1029
1030async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1034 match daemon.generation() {
1035 Some(gen) => format!("ok gen={gen}"),
1036 None => "ok".to_string(),
1037 }
1038}
1039
1040async fn readyz(State(deploy): State<DeployStore>) -> Response {
1042 match deploy.ready().await {
1043 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1044 Err(err) => {
1045 tracing::warn!(error = %err, "readiness probe failed");
1046 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1047 }
1048 }
1049}
1050
1051struct AccessLog {
1055 method: Method,
1056 path: String,
1057 host: String,
1058 client: String,
1059 status: u16,
1060 encoding: String,
1062 start: std::time::Instant,
1063 bytes: std::sync::atomic::AtomicU64,
1064}
1065
1066impl Drop for AccessLog {
1067 fn drop(&mut self) {
1068 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1069 srvmetrics::server_metrics().record_request(self.status, bytes);
1072 tracing::info!(
1073 target: "boatramp::access",
1074 method = %self.method,
1075 path = %self.path,
1076 host = %self.host,
1077 client = %self.client,
1078 status = self.status,
1079 bytes = bytes,
1080 encoding = %self.encoding,
1081 cache_result = srvmetrics::cache_result(self.status),
1082 elapsed_ms = self.start.elapsed().as_millis() as u64,
1083 "request"
1084 );
1085 }
1086}
1087
1088async fn access_log(request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1092 let method = request.method().clone();
1093 let path = request.uri().path().to_string();
1094 let host = request
1095 .headers()
1096 .get(header::HOST)
1097 .and_then(|value| value.to_str().ok())
1098 .unwrap_or("-")
1099 .to_string();
1100 let client = request
1101 .extensions()
1102 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1103 .map(|info| info.0.ip().to_string())
1104 .unwrap_or_else(|| "-".to_string());
1105
1106 let start = std::time::Instant::now();
1107 let response = next.run(request).await;
1108 let encoding = response
1109 .headers()
1110 .get(header::CONTENT_ENCODING)
1111 .and_then(|v| v.to_str().ok())
1112 .unwrap_or("identity")
1113 .to_string();
1114 let log = AccessLog {
1115 method,
1116 path,
1117 host,
1118 client,
1119 status: response.status().as_u16(),
1120 encoding,
1121 start,
1122 bytes: std::sync::atomic::AtomicU64::new(0),
1123 };
1124
1125 let (parts, body) = response.into_parts();
1129 let counted = body.into_data_stream().map(move |chunk| {
1130 if let Ok(bytes) = &chunk {
1131 log.bytes
1132 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1133 }
1134 chunk
1135 });
1136 Response::from_parts(parts, Body::from_stream(counted))
1137}
1138
1139fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1141 req_headers
1142 .get(header::IF_NONE_MATCH)
1143 .and_then(|value| value.to_str().ok())
1144 .is_some_and(|value| {
1145 value
1146 .split(',')
1147 .map(str::trim)
1148 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1149 })
1150}
1151
1152fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1153 if let Ok(value) = HeaderValue::from_str(value) {
1154 headers.insert(name, value);
1155 }
1156}
1157
1158fn not_found() -> Response {
1159 (StatusCode::NOT_FOUND, "not found\n").into_response()
1160}
1161
1162fn redirect(status: u16, location: &str) -> Response {
1163 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1164 match HeaderValue::from_str(location) {
1165 Ok(location) => {
1166 let mut headers = HeaderMap::new();
1167 headers.insert(header::LOCATION, location);
1168 (status, headers).into_response()
1169 }
1170 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1171 }
1172}
1173
1174fn deploy_error_response(err: DeployError) -> Response {
1176 let status = match &err {
1177 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1178 StatusCode::NOT_FOUND
1179 }
1180 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1181 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1182 DeployError::Conflict(_) => StatusCode::CONFLICT,
1184 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1186 _ => StatusCode::INTERNAL_SERVER_ERROR,
1187 };
1188 tracing::warn!(error = %err, "request failed");
1189 (status, format!("{err}\n")).into_response()
1190}
1191
1192fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1197 boatramp_core::project::validate_resource_name(kind, value)
1198 .err()
1199 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1200}
1201
1202#[cfg(test)]
1203mod drain_tests {
1204 use super::*;
1205
1206 #[tokio::test]
1207 async fn deadline_forces_shutdown_after_signal() {
1208 let server = std::future::pending::<Result<(), ServeError>>();
1211 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1213 assert!(result.is_ok());
1214 }
1215
1216 #[tokio::test]
1217 async fn server_finishing_first_wins() {
1218 let server = async { Ok(()) };
1221 let signalled = std::future::pending::<()>();
1222 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1223 assert!(result.is_ok());
1224 }
1225
1226 #[tokio::test]
1227 async fn deadline_does_not_trip_before_signal() {
1228 let server = async {
1232 tokio::time::sleep(Duration::from_millis(40)).await;
1233 Err(ServeError::Io(std::io::Error::other("server error")))
1234 };
1235 let signalled = std::future::pending::<()>();
1236 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1237 assert!(result.is_err());
1238 }
1239}
1240
1241#[cfg(all(test, feature = "handlers"))]
1242mod tests {
1243 use super::*;
1244 use boatramp_core::cose::{LocalSigner, TokenAlg};
1245 use boatramp_core::project::ProjectRef;
1246
1247 #[test]
1248 fn query_string_parses_and_url_decodes() {
1249 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1250 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1251 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")); }
1255
1256 #[test]
1257 fn cookie_header_parses_pairs() {
1258 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1259 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1260 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1261 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1262 }
1263
1264 #[test]
1265 fn apply_vary_merges_without_duplicates() {
1266 let base = (StatusCode::OK, "x").into_response();
1267 let r = apply_vary(base, &["accept-language".into()]);
1268 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1269 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1271 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1272 assert!(v.contains("accept-language") && v.contains("cookie"));
1273 assert_eq!(v.matches("accept-language").count(), 1);
1274 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1276 assert!(plain.headers().get(header::VARY).is_none());
1277 }
1278
1279 #[tokio::test]
1283 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1284 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1285 let public = keys.public_key();
1286
1287 let resp = create_join_token(
1289 Extension(Issuer(Some(keys.clone()))),
1290 Json(CreateJoinTokenRequest {
1291 ttl_secs: Some(600),
1292 }),
1293 )
1294 .await;
1295 assert_eq!(resp.status(), StatusCode::CREATED);
1296 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1297 .await
1298 .unwrap();
1299 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1300 let token = parsed["token"].as_str().unwrap();
1301 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1302 assert!(!jti.is_empty());
1303
1304 let no_issuer = create_join_token(
1306 Extension(Issuer(None)),
1307 Json(CreateJoinTokenRequest { ttl_secs: None }),
1308 )
1309 .await;
1310 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1311 }
1312
1313 #[tokio::test]
1318 async fn function_write_path_deploy_rollback_alias_remove() {
1319 use boatramp_core::function::Lifecycle;
1320 use boatramp_core::kv::MemoryKv;
1321 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1322
1323 struct FakeStorage {
1326 present: bool,
1327 }
1328 #[async_trait::async_trait]
1329 impl Storage for FakeStorage {
1330 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1331 Err(StorageError::NotFound(String::new()))
1332 }
1333 async fn get_range(
1334 &self,
1335 _: &str,
1336 _: u64,
1337 _: Option<u64>,
1338 ) -> Result<GetObject, StorageError> {
1339 Err(StorageError::NotFound(String::new()))
1340 }
1341 async fn put(
1342 &self,
1343 _: &str,
1344 _: ByteStream,
1345 _: PutMeta,
1346 ) -> Result<ObjectMeta, StorageError> {
1347 Err(StorageError::unsupported("fake"))
1348 }
1349 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1350 if self.present {
1351 Ok(ObjectMeta {
1352 key: key.to_string(),
1353 ..Default::default()
1354 })
1355 } else {
1356 Err(StorageError::NotFound(key.to_string()))
1357 }
1358 }
1359 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1360 Ok(())
1361 }
1362 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1363 Ok(Vec::new())
1364 }
1365 }
1366
1367 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1368 let status = resp.status();
1369 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1370 .await
1371 .unwrap();
1372 let value = if bytes.is_empty() {
1373 serde_json::Value::Null
1374 } else {
1375 serde_json::from_slice(&bytes).unwrap()
1376 };
1377 (status, value)
1378 }
1379
1380 let deploy = DeployStore::new(
1381 Arc::new(FakeStorage { present: true }),
1382 Arc::new(MemoryKv::new()),
1383 );
1384 let v1 = "a".repeat(64);
1385 let v2 = "b".repeat(64);
1386
1387 let (st, body) = body_json(
1389 deploy_function(
1390 State(deploy.clone()),
1391 axum::extract::Extension(crate::ProjectContext::default()),
1392 Path("greeter".to_string()),
1393 Json(FunctionUpsert {
1394 component: v1.clone(),
1395 config: Default::default(),
1396 lifecycle: Lifecycle::Independent,
1397 }),
1398 )
1399 .await,
1400 )
1401 .await;
1402 assert_eq!(st, StatusCode::OK);
1403 assert_eq!(body["active"], v1);
1404
1405 let (_, body) = body_json(
1407 deploy_function(
1408 State(deploy.clone()),
1409 axum::extract::Extension(crate::ProjectContext::default()),
1410 Path("greeter".to_string()),
1411 Json(FunctionUpsert {
1412 component: v2.clone(),
1413 config: Default::default(),
1414 lifecycle: Lifecycle::Independent,
1415 }),
1416 )
1417 .await,
1418 )
1419 .await;
1420 assert_eq!(body["active"], v2);
1421 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1422
1423 let (st, body) = body_json(
1425 rollback_function(
1426 State(deploy.clone()),
1427 axum::extract::Extension(crate::ProjectContext::default()),
1428 Path("greeter".to_string()),
1429 Json(RollbackBody { to: v1.clone() }),
1430 )
1431 .await,
1432 )
1433 .await;
1434 assert_eq!(st, StatusCode::OK);
1435 assert_eq!(body["active"], v1);
1436
1437 let resp = rollback_function(
1439 State(deploy.clone()),
1440 axum::extract::Extension(crate::ProjectContext::default()),
1441 Path("greeter".to_string()),
1442 Json(RollbackBody { to: "c".repeat(64) }),
1443 )
1444 .await;
1445 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1446
1447 let (st, body) = body_json(
1449 alias_function(
1450 State(deploy.clone()),
1451 axum::extract::Extension(crate::ProjectContext::default()),
1452 Path(("greeter".to_string(), "prod".to_string())),
1453 Json(AliasBody {
1454 version: v2.clone(),
1455 }),
1456 )
1457 .await,
1458 )
1459 .await;
1460 assert_eq!(st, StatusCode::OK);
1461 assert_eq!(body["aliases"]["prod"], v2);
1462
1463 let (st, _) = body_json(
1465 remove_function(
1466 State(deploy.clone()),
1467 axum::extract::Extension(crate::ProjectContext::default()),
1468 Path("greeter".to_string()),
1469 )
1470 .await,
1471 )
1472 .await;
1473 assert_eq!(st, StatusCode::NO_CONTENT);
1474 assert!(deploy
1475 .get_function(ProjectRef::DEFAULT, "greeter")
1476 .await
1477 .unwrap()
1478 .is_none());
1479
1480 let empty = DeployStore::new(
1482 Arc::new(FakeStorage { present: false }),
1483 Arc::new(MemoryKv::new()),
1484 );
1485 let resp = deploy_function(
1486 State(empty),
1487 axum::extract::Extension(crate::ProjectContext::default()),
1488 Path("orphan".to_string()),
1489 Json(FunctionUpsert {
1490 component: v1.clone(),
1491 config: Default::default(),
1492 lifecycle: Lifecycle::default(),
1493 }),
1494 )
1495 .await;
1496 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1497 }
1498
1499 struct StubControl {
1503 admits: std::sync::Mutex<Vec<(String, String)>>,
1504 respond: StubJoin,
1505 }
1506 #[derive(Clone, Copy)]
1507 enum StubJoin {
1508 Admit,
1509 Spent,
1510 Invalid,
1511 Revoked,
1512 }
1513
1514 #[async_trait::async_trait]
1515 impl MeshControl for StubControl {
1516 async fn admit(
1517 &self,
1518 mesh_pubkey_hex: &str,
1519 jti: &str,
1520 _proof: &[u8],
1521 _proof_iat: u64,
1522 _now: u64,
1523 _advertise_addr: Option<&str>,
1524 ) -> Result<JoinOutcome, String> {
1525 self.admits
1526 .lock()
1527 .unwrap()
1528 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1529 Ok(match self.respond {
1530 StubJoin::Admit => JoinOutcome::Admitted {
1531 members: vec!["signed-member".to_string()],
1532 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1533 },
1534 StubJoin::Spent => JoinOutcome::TokenSpent,
1535 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1536 StubJoin::Revoked => JoinOutcome::Revoked,
1537 })
1538 }
1539 async fn rotate_key(&self) -> Result<String, String> {
1540 Ok("cafe".to_string())
1541 }
1542 async fn revoke(&self, _node: u64) -> Result<(), String> {
1543 Ok(())
1544 }
1545 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1546 Ok(Vec::new())
1547 }
1548 async fn promote(&self, _node: u64) -> Result<(), String> {
1549 Ok(())
1550 }
1551 }
1552
1553 #[tokio::test]
1557 async fn cluster_join_dispatches_and_maps_outcomes() {
1558 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1559 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1560 let auth = Auth::with_key(keys.public_key(), kv);
1561 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1562 let req = |proof: &str| JoinRequest {
1563 token: token.clone(),
1564 mesh_pubkey: "302a300506032b6570032100feed".into(),
1565 possession_proof: proof.to_string(),
1566 proof_iat: now_unix(),
1567 advertise_addr: Some("https://joiner:7000".into()),
1568 };
1569
1570 let admitter = Arc::new(StubControl {
1572 admits: std::sync::Mutex::new(Vec::new()),
1573 respond: StubJoin::Admit,
1574 });
1575 let resp = cluster_join(
1576 Extension(auth.clone()),
1577 Extension(MeshControlHandle(Some(admitter.clone()))),
1578 Json(req("aa01")),
1579 )
1580 .await;
1581 assert_eq!(resp.status(), StatusCode::OK);
1582 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1583
1584 let spent = Arc::new(StubControl {
1586 admits: std::sync::Mutex::new(Vec::new()),
1587 respond: StubJoin::Spent,
1588 });
1589 assert_eq!(
1590 cluster_join(
1591 Extension(auth.clone()),
1592 Extension(MeshControlHandle(Some(spent))),
1593 Json(req("aa01")),
1594 )
1595 .await
1596 .status(),
1597 StatusCode::CONFLICT
1598 );
1599 let invalid = Arc::new(StubControl {
1600 admits: std::sync::Mutex::new(Vec::new()),
1601 respond: StubJoin::Invalid,
1602 });
1603 assert_eq!(
1604 cluster_join(
1605 Extension(auth.clone()),
1606 Extension(MeshControlHandle(Some(invalid))),
1607 Json(req("aa01")),
1608 )
1609 .await
1610 .status(),
1611 StatusCode::FORBIDDEN
1612 );
1613 let revoked = Arc::new(StubControl {
1615 admits: std::sync::Mutex::new(Vec::new()),
1616 respond: StubJoin::Revoked,
1617 });
1618 assert_eq!(
1619 cluster_join(
1620 Extension(auth.clone()),
1621 Extension(MeshControlHandle(Some(revoked))),
1622 Json(req("aa01")),
1623 )
1624 .await
1625 .status(),
1626 StatusCode::FORBIDDEN
1627 );
1628
1629 let ok = Arc::new(StubControl {
1631 admits: std::sync::Mutex::new(Vec::new()),
1632 respond: StubJoin::Admit,
1633 });
1634 assert_eq!(
1635 cluster_join(
1636 Extension(auth.clone()),
1637 Extension(MeshControlHandle(Some(ok))),
1638 Json(req("not-hex")),
1639 )
1640 .await
1641 .status(),
1642 StatusCode::BAD_REQUEST
1643 );
1644
1645 let none = cluster_join(
1647 Extension(auth),
1648 Extension(MeshControlHandle(None)),
1649 Json(req("aa01")),
1650 )
1651 .await;
1652 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1653 }
1654
1655 #[tokio::test]
1659 async fn bootstrap_mints_the_first_token_once() {
1660 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1661 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1662 let public = keys.public_key();
1663 let deploy = DeployStore::new(
1664 Arc::new(MemStorage::default()),
1665 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1666 );
1667 let secret = "s3cr3t-bootstrap-value";
1668 let gate = BootstrapGate::new(Some(secret));
1669 let issuer = Issuer(Some(keys.clone()));
1670 let bearer = |s: &str| {
1671 let mut h = HeaderMap::new();
1672 h.insert(
1673 AUTHORIZATION,
1674 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1675 );
1676 h
1677 };
1678 let req = || BootstrapRequest {
1679 roles: vec!["admin".to_string()],
1680 ttl_secs: None,
1681 };
1682
1683 let bad = bootstrap_token(
1685 State(deploy.clone()),
1686 Extension(issuer.clone()),
1687 Extension(gate.clone()),
1688 bearer("wrong"),
1689 Json(req()),
1690 )
1691 .await;
1692 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1693
1694 let ok = bootstrap_token(
1696 State(deploy.clone()),
1697 Extension(issuer.clone()),
1698 Extension(gate.clone()),
1699 bearer(secret),
1700 Json(req()),
1701 )
1702 .await;
1703 assert_eq!(ok.status(), StatusCode::CREATED);
1704 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1705 .await
1706 .unwrap();
1707 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1708 let token = json["token"].as_str().unwrap();
1709 let id = json["id"].as_str().unwrap();
1710 let verified = cose::verify(token, &public, now_unix()).unwrap();
1711 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1712 assert!(deploy
1713 .list_token_meta()
1714 .await
1715 .unwrap()
1716 .iter()
1717 .any(|m| m.revocation_id == id));
1718
1719 let reuse = bootstrap_token(
1721 State(deploy.clone()),
1722 Extension(issuer.clone()),
1723 Extension(gate),
1724 bearer(secret),
1725 Json(req()),
1726 )
1727 .await;
1728 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1729
1730 let disabled = bootstrap_token(
1732 State(deploy),
1733 Extension(issuer),
1734 Extension(BootstrapGate(None)),
1735 bearer(secret),
1736 Json(req()),
1737 )
1738 .await;
1739 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1740 }
1741
1742 #[tokio::test]
1745 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1746 let control = Arc::new(StubControl {
1747 admits: std::sync::Mutex::new(Vec::new()),
1748 respond: StubJoin::Admit,
1749 });
1750 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1751 assert_eq!(resp.status(), StatusCode::OK);
1752 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1753 .await
1754 .unwrap();
1755 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1756 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1757
1758 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1759 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1760 }
1761
1762 #[test]
1763 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1764 use boatramp_core::security::SecurityProfile;
1765 let strict = SecurityProfile::MultiTenant.preset();
1766 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1770 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1771 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1772
1773 assert!(gateway_addr_allowed(public, &strict));
1775 assert!(!gateway_addr_allowed(private, &strict));
1776 assert!(!gateway_addr_allowed(loopback, &strict));
1777 assert!(!gateway_addr_allowed(metadata, &strict));
1778
1779 assert!(gateway_addr_allowed(public, &loose));
1782 assert!(gateway_addr_allowed(private, &loose));
1783 assert!(gateway_addr_allowed(loopback, &loose));
1784 assert!(!gateway_addr_allowed(metadata, &loose));
1785 }
1786
1787 #[test]
1788 fn resolve_env_merges_static_and_host_secrets() {
1789 use boatramp_core::config::HandlersSiteConfig;
1790
1791 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1793
1794 let deploy_env = std::collections::BTreeMap::from([
1795 ("GREETING".to_string(), "hi".to_string()),
1796 ("OVERRIDE_ME".to_string(), "static".to_string()),
1797 ]);
1798 let site_handlers = HandlersSiteConfig {
1799 enabled: true,
1800 secrets: std::collections::BTreeMap::from([
1801 (
1803 "SECRET_TOKEN".to_string(),
1804 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1805 ),
1806 (
1807 "OVERRIDE_ME".to_string(),
1808 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1809 ),
1810 (
1811 "MISSING".to_string(),
1812 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1813 ),
1814 ]),
1815 ..Default::default()
1816 };
1817 let env = resolve_env("blog", &deploy_env, &site_handlers);
1818
1819 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1823 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1824 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1825 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1826
1827 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1828 }
1829
1830 fn req() -> Request {
1831 Request::builder()
1832 .uri("/")
1833 .header(header::HOST, "example.com")
1834 .body(Body::empty())
1835 .unwrap()
1836 }
1837
1838 #[test]
1839 fn forwarded_headers_set_standard_triple() {
1840 let mut request = req();
1841 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1842 let h = request.headers();
1843 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
1844 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
1845 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
1846 }
1847
1848 #[test]
1849 fn forwarded_for_overwrites_spoofed_value() {
1850 let mut request = Request::builder()
1853 .uri("/")
1854 .header(header::HOST, "example.com")
1855 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
1856 .body(Body::empty())
1857 .unwrap();
1858 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1859 let values: Vec<_> = request
1860 .headers()
1861 .get_all("x-forwarded-for")
1862 .iter()
1863 .collect();
1864 assert_eq!(values.len(), 1);
1865 assert_eq!(values[0], "203.0.113.7");
1866 }
1867
1868 #[test]
1869 fn forwarded_proto_preserves_upstream_tls() {
1870 let mut request = Request::builder()
1872 .uri("/")
1873 .header(header::HOST, "example.com")
1874 .header("x-forwarded-proto", "https")
1875 .body(Body::empty())
1876 .unwrap();
1877 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1878 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
1879 }
1880
1881 #[test]
1882 fn forwarded_host_absent_when_no_host_header() {
1883 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
1884 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1885 assert!(request.headers().get("x-forwarded-host").is_none());
1886 assert_eq!(
1887 request.headers().get("x-forwarded-for").unwrap(),
1888 "203.0.113.7"
1889 );
1890 }
1891
1892 use boatramp_core::kv::{KvStore, MemoryKv};
1895 use boatramp_core::messaging::{LogMessaging, Messaging};
1896 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
1897
1898 const EVENT_CONSUMER: &[u8] =
1899 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
1900
1901 #[derive(Default)]
1902 struct MemStorage {
1903 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
1904 }
1905
1906 #[async_trait::async_trait]
1907 impl boatramp_core::Storage for MemStorage {
1908 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
1909 let bytes = self
1910 .objects
1911 .lock()
1912 .unwrap()
1913 .get(key)
1914 .cloned()
1915 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
1916 let body: ByteStream =
1917 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
1918 Ok(GetObject {
1919 meta: ObjectMeta {
1920 key: key.to_string(),
1921 ..Default::default()
1922 },
1923 body,
1924 })
1925 }
1926 async fn get_range(
1927 &self,
1928 key: &str,
1929 _: u64,
1930 _: Option<u64>,
1931 ) -> Result<GetObject, StorageError> {
1932 self.get(key).await
1933 }
1934 async fn put(
1935 &self,
1936 key: &str,
1937 mut body: ByteStream,
1938 _: PutMeta,
1939 ) -> Result<ObjectMeta, StorageError> {
1940 use futures::StreamExt;
1941 let mut buf = Vec::new();
1942 while let Some(chunk) = body.next().await {
1943 buf.extend_from_slice(&chunk?);
1944 }
1945 self.objects.lock().unwrap().insert(key.to_string(), buf);
1946 Ok(ObjectMeta {
1947 key: key.to_string(),
1948 ..Default::default()
1949 })
1950 }
1951 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1952 self.objects
1953 .lock()
1954 .unwrap()
1955 .get(key)
1956 .map(|_| ObjectMeta {
1957 key: key.to_string(),
1958 ..Default::default()
1959 })
1960 .ok_or_else(|| StorageError::NotFound(key.to_string()))
1961 }
1962 async fn delete(&self, key: &str) -> Result<(), StorageError> {
1963 self.objects.lock().unwrap().remove(key);
1964 Ok(())
1965 }
1966 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1967 Ok(Vec::new())
1968 }
1969 }
1970
1971 fn observed_state(
1973 workload: &str,
1974 healthy: bool,
1975 phase: boatramp_core::compute::ReplicaPhase,
1976 ) -> boatramp_core::compute::ObservedInstance {
1977 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
1978 boatramp_core::compute::ObservedInstance {
1979 handle: InstanceHandle {
1980 workload: workload.into(),
1981 replica: 0,
1982 backend_ref: "ref-0".into(),
1983 },
1984 node: 1,
1985 backend: "vmm".into(),
1986 endpoint: Endpoint {
1987 scheme: Scheme::Http,
1988 host: "10.0.0.2".into(),
1989 port: 80,
1990 },
1991 region: None,
1992 healthy,
1993 phase,
1994 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
1995 workload: workload.into(),
1996 replica: 0,
1997 data_ref: "snap-0".into(),
1998 }),
1999 }
2000 }
2001
2002 #[tokio::test]
2003 async fn has_parked_replica_detects_a_zeroed_replica() {
2004 use boatramp_core::compute::ReplicaPhase;
2005 let storage = Arc::new(MemStorage::default());
2006 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2007 let deploy = DeployStore::new(storage, kv);
2008
2009 assert!(!has_parked_replica(&deploy, "w").await);
2011 deploy
2013 .set_replica_state(
2014 ProjectRef::DEFAULT,
2015 &observed_state("w", true, ReplicaPhase::Running),
2016 )
2017 .await
2018 .unwrap();
2019 assert!(!has_parked_replica(&deploy, "w").await);
2020 deploy
2022 .set_replica_state(
2023 ProjectRef::DEFAULT,
2024 &observed_state("w", false, ReplicaPhase::Zero),
2025 )
2026 .await
2027 .unwrap();
2028 assert!(has_parked_replica(&deploy, "w").await);
2029 }
2030
2031 #[tokio::test]
2032 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2033 use boatramp_core::compute::ReplicaPhase;
2034 let storage = Arc::new(MemStorage::default());
2035 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2036 let deploy = DeployStore::new(storage, kv);
2037
2038 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2040 assert!(empty.is_empty());
2041
2042 deploy
2044 .set_replica_state(
2045 ProjectRef::DEFAULT,
2046 &observed_state("w", true, ReplicaPhase::Running),
2047 )
2048 .await
2049 .unwrap();
2050 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2051 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2052 }
2053
2054 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2058 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2059 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2060 let storage = Arc::new(MemStorage::default());
2061 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2062 let mq = LogMessaging::new(storage, kv.clone());
2063 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2064 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2065 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2066 let topic = "blog/orders/created";
2067
2068 for _ in 0..3 {
2070 mq.publish(topic, b"ok").await.unwrap();
2071 }
2072 loop {
2073 let acked = dispatch_consumer_batch(
2074 &engine,
2075 &mq,
2076 &metrics::Metrics::default(),
2077 "blog",
2078 topic,
2079 "blog/",
2080 &hash,
2081 EVENT_CONSUMER,
2082 &bindings,
2083 Limits::default(),
2084 Duration::from_secs(30),
2085 5,
2086 10,
2087 )
2088 .await;
2089 if acked == 0 {
2090 break;
2091 }
2092 }
2093 assert_eq!(
2094 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2095 Some(b"3".to_vec())
2096 );
2097
2098 mq.publish(topic, b"fail").await.unwrap();
2101 for _ in 0..5 {
2102 dispatch_consumer_batch(
2103 &engine,
2104 &mq,
2105 &metrics::Metrics::default(),
2106 "blog",
2107 topic,
2108 "blog/",
2109 &hash,
2110 EVENT_CONSUMER,
2111 &bindings,
2112 Limits::default(),
2113 Duration::ZERO,
2114 2,
2115 10,
2116 )
2117 .await;
2118 }
2119 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2120 assert_eq!(
2122 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2123 Some(b"3".to_vec())
2124 );
2125 }
2126
2127 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2131 async fn scheduler_runs_current_consumers_not_previews() {
2132 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2133 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2134 use boatramp_handlers::{HandlerEngine, Limits};
2135 use futures::StreamExt;
2136
2137 let storage = Arc::new(MemStorage::default());
2138 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2139 let deploy = DeployStore::new(storage.clone(), kv.clone());
2140 let messaging: Arc<dyn Messaging> =
2141 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2142
2143 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2145 let stream: ByteStream =
2146 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2147 .boxed();
2148 deploy.put_blob(&hash, stream).await.unwrap();
2149 let mut files = std::collections::BTreeMap::new();
2150 files.insert(
2151 "consumer.wasm".to_string(),
2152 FileEntry {
2153 hash: hash.clone(),
2154 size: EVENT_CONSUMER.len() as u64,
2155 content_type: None,
2156 variants: std::collections::BTreeMap::new(),
2157 },
2158 );
2159 let manifest = Manifest {
2160 files,
2161 config: DeployConfig {
2162 consumers: vec![ConsumerConfig {
2163 topic: "orders/created".into(),
2164 component: "consumer.wasm".into(),
2165 imports: vec!["wasi:keyvalue".into()],
2166 }],
2167 ..Default::default()
2168 },
2169 ..Default::default()
2170 };
2171 let id = deploy.put_manifest(&manifest).await.unwrap();
2172 deploy
2173 .activate(ProjectRef::DEFAULT, "blog", &id)
2174 .await
2175 .unwrap();
2176 deploy
2177 .set_site_config(
2178 ProjectRef::DEFAULT,
2179 "blog",
2180 &SiteConfig {
2181 handlers: Some(HandlersSiteConfig {
2182 enabled: true,
2183 allow_imports: vec!["wasi:keyvalue".into()],
2184 ..Default::default()
2185 }),
2186 ..Default::default()
2187 },
2188 )
2189 .await
2190 .unwrap();
2191
2192 messaging
2194 .publish("blog/orders/created", b"live")
2195 .await
2196 .unwrap();
2197 messaging
2198 .publish("blog/_preview/abc/orders/created", b"preview")
2199 .await
2200 .unwrap();
2201
2202 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2203 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2204 let inner = rt.inner.clone().unwrap();
2205 let mut cache = std::collections::HashMap::new();
2206 let mut crons = std::collections::HashMap::new();
2207 let now = CronNow {
2208 minute: 0,
2209 hour: 0,
2210 dom: 1,
2211 month: 1,
2212 dow: 0,
2213 minute_stamp: 0,
2214 };
2215 for _ in 0..3 {
2216 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, now)
2217 .await
2218 .unwrap();
2219 }
2220
2221 assert_eq!(
2223 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2224 Some(b"1".to_vec())
2225 );
2226 assert_eq!(
2229 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2230 .await
2231 .unwrap(),
2232 None
2233 );
2234 }
2235
2236 const KV_COUNTER: &[u8] =
2241 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2242
2243 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2248 async fn function_invoker_runs_target_buffers_and_meters() {
2249 use boatramp_core::deploy::DeployStore;
2250 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2251 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2252 use futures::StreamExt;
2253
2254 const HTTP_200: &[u8] =
2257 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2258
2259 let storage = Arc::new(MemStorage::default());
2260 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2261 let deploy = DeployStore::new(storage.clone(), kv.clone());
2262
2263 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2264 let stream: ByteStream =
2265 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2266 deploy.put_blob(&hash, stream).await.unwrap();
2267 let function = Function {
2268 name: "target".into(),
2269 owner: Owner::Project("default".into()),
2270 versions: vec![FunctionVersion {
2271 id: "v1".into(),
2272 component: hash.clone(),
2273 created: 0,
2274 lifecycle: Lifecycle::Independent,
2275 }],
2276 active: "v1".into(),
2277 aliases: Default::default(),
2278 config: Default::default(),
2279 };
2280 deploy
2281 .put_function(ProjectRef::DEFAULT, &function)
2282 .await
2283 .unwrap();
2284
2285 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2286 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2287 rt.set_invoker(deploy.clone());
2288 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2289
2290 let request = || InvokeRequest {
2291 method: "GET".into(),
2292 path: "/".into(),
2293 headers: vec![],
2294 body: vec![],
2295 };
2296
2297 let response = invoker.invoke("target", request(), 1).await.unwrap();
2299 assert_eq!(response.status, 200);
2300
2301 let metering = deploy
2303 .get_metering(ProjectRef::DEFAULT, "target")
2304 .await
2305 .unwrap()
2306 .unwrap();
2307 assert_eq!(metering.invocations, 1);
2308
2309 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2311 assert!(matches!(err, InvokeError::NotFound));
2312 }
2313
2314 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2320 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2321 use crate::scheduler::{run_scheduler_tick, CronNow};
2322 use boatramp_core::deploy::DeployStore;
2323 use boatramp_core::function::{
2324 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2325 };
2326 use boatramp_handlers::{HandlerEngine, Limits};
2327 use futures::StreamExt;
2328
2329 const HTTP_200: &[u8] =
2330 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2331
2332 let storage = Arc::new(MemStorage::default());
2333 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2334 let deploy = DeployStore::new(storage.clone(), kv.clone());
2335
2336 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2337 let stream: ByteStream =
2338 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2339 deploy.put_blob(&hash, stream).await.unwrap();
2340
2341 let acme = ProjectRef::new("acme");
2343 let function = Function {
2344 name: "worker".into(),
2345 owner: Owner::Project("acme".into()),
2346 versions: vec![FunctionVersion {
2347 id: "v1".into(),
2348 component: hash.clone(),
2349 created: 0,
2350 lifecycle: Lifecycle::Independent,
2351 }],
2352 active: "v1".into(),
2353 aliases: Default::default(),
2354 config: Default::default(),
2355 };
2356 deploy.put_function(acme, &function).await.unwrap();
2357 let inv = Invocation {
2358 id: "inv1".into(),
2359 function: "worker".into(),
2360 version: "v1".into(),
2361 mode: InvokeMode::Async,
2362 status: InvocationStatus::Queued,
2363 idempotency_key: None,
2364 attempts: 0,
2365 request_b64: None,
2366 request_content_type: None,
2367 result: None,
2368 created: 0,
2369 updated: 0,
2370 };
2371 deploy.put_invocation(acme, &inv).await.unwrap();
2372
2373 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2374 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2375 let inner = rt.inner.as_ref().unwrap();
2376
2377 let mut wasm_cache = std::collections::HashMap::new();
2380 let mut cron_state = std::collections::HashMap::new();
2381 let now = CronNow {
2382 minute: 0,
2383 hour: 0,
2384 dom: 1,
2385 month: 1,
2386 dow: 0,
2387 minute_stamp: 0,
2388 };
2389 run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
2390 .await
2391 .unwrap();
2392
2393 let settled = deploy
2395 .get_invocation(acme, "worker", "inv1")
2396 .await
2397 .unwrap()
2398 .expect("invocation still present in acme");
2399 assert_eq!(settled.status, InvocationStatus::Succeeded);
2400 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2402 assert_eq!(metering.invocations, 1);
2403 assert!(deploy
2405 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2406 .await
2407 .unwrap()
2408 .is_none());
2409 assert!(deploy
2410 .get_metering(ProjectRef::DEFAULT, "worker")
2411 .await
2412 .unwrap()
2413 .is_none());
2414 }
2415
2416 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2431 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2432 use boatramp_core::deploy::DeployStore;
2433 use boatramp_core::function::{
2434 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2435 };
2436 use boatramp_handlers::{HandlerEngine, Limits};
2437 use futures::StreamExt;
2438
2439 let storage = Arc::new(MemStorage::default());
2440 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2441 let deploy = DeployStore::new(storage.clone(), kv.clone());
2442
2443 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2446 let stream: ByteStream =
2447 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2448 deploy.put_blob(&hash, stream).await.unwrap();
2449
2450 let store = Function {
2455 name: "store".into(),
2456 owner: Owner::Project("default".into()),
2457 versions: vec![FunctionVersion {
2458 id: "v1".into(),
2459 component: hash.clone(),
2460 created: 0,
2461 lifecycle: Lifecycle::Independent,
2462 }],
2463 active: "v1".into(),
2464 aliases: Default::default(),
2465 config: FunctionConfig {
2466 imports: vec!["wasi:keyvalue".into()],
2467 ..Default::default()
2468 },
2469 };
2470 let acme = ProjectRef::new("acme");
2471 let globex = ProjectRef::new("globex");
2472
2473 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2474 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2475 let inner = rt.inner.as_ref().unwrap();
2476
2477 let request = || {
2478 axum::http::Request::builder()
2479 .method("GET")
2480 .uri("/")
2481 .body(axum::body::Body::empty())
2482 .unwrap()
2483 };
2484
2485 let component = store.resolve(&store.active).unwrap().to_owned();
2488 for project in [acme, globex, ProjectRef::DEFAULT] {
2489 let (response, _) =
2490 execute_function(inner, &deploy, project, &store, &component, request(), 0).await;
2491 assert!(response.status().is_success(), "invocation should succeed");
2492 }
2493
2494 assert_eq!(
2497 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2498 Some(b"1".to_vec()),
2499 "acme's write must be tenant-qualified"
2500 );
2501 assert_eq!(
2502 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2503 Some(b"1".to_vec()),
2504 "globex's write must be tenant-qualified"
2505 );
2506 assert_eq!(
2507 kv.get("hkv/fn/store/hits").await.unwrap(),
2508 Some(b"1".to_vec()),
2509 "the default project must keep the byte-identical pre-project key"
2510 );
2511 }
2514
2515 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2519 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2520 use boatramp_core::config::{
2521 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2522 };
2523 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2524 use boatramp_handlers::{HandlerEngine, Limits};
2525 use futures::StreamExt;
2526 use std::sync::atomic::Ordering;
2527
2528 let storage = Arc::new(MemStorage::default());
2529 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2530 let deploy = DeployStore::new(storage.clone(), kv.clone());
2531
2532 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2533 let stream: ByteStream =
2534 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2535 deploy.put_blob(&hash, stream).await.unwrap();
2536 let mut files = std::collections::BTreeMap::new();
2537 files.insert(
2538 "counter.wasm".to_string(),
2539 FileEntry {
2540 hash: hash.clone(),
2541 size: KV_COUNTER.len() as u64,
2542 content_type: None,
2543 variants: std::collections::BTreeMap::new(),
2544 },
2545 );
2546 let manifest = Manifest {
2547 files,
2548 config: DeployConfig {
2549 handlers: vec![HandlerConfig {
2550 route: "/".into(),
2551 methods: Vec::new(),
2552 component: "counter.wasm".into(),
2553 imports: vec!["wasi:keyvalue".into()],
2554 limits: None,
2555 env: std::collections::BTreeMap::new(),
2556 invoke_targets: Vec::new(),
2557 }],
2558 crons: vec![CronConfig {
2559 schedule: "* * * * *".into(),
2560 route: "/".into(),
2561 overlap: Overlap::Skip,
2562 }],
2563 ..Default::default()
2564 },
2565 ..Default::default()
2566 };
2567 let id = deploy.put_manifest(&manifest).await.unwrap();
2568 deploy
2569 .activate(ProjectRef::DEFAULT, "blog", &id)
2570 .await
2571 .unwrap();
2572 deploy
2573 .set_site_config(
2574 ProjectRef::DEFAULT,
2575 "blog",
2576 &SiteConfig {
2577 handlers: Some(HandlersSiteConfig {
2578 enabled: true,
2579 allow_imports: vec!["wasi:keyvalue".into()],
2580 ..Default::default()
2581 }),
2582 ..Default::default()
2583 },
2584 )
2585 .await
2586 .unwrap();
2587
2588 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2589 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2590 let inner = rt.inner.clone().unwrap();
2591 let mut wasm = std::collections::HashMap::new();
2592 let mut crons = std::collections::HashMap::new();
2593 let at = |stamp| CronNow {
2594 minute: 0,
2595 hour: 0,
2596 dom: 1,
2597 month: 1,
2598 dow: 0,
2599 minute_stamp: stamp,
2600 };
2601
2602 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2604 .await
2605 .unwrap();
2606 for h in handles {
2607 h.await.unwrap();
2608 }
2609 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2610
2611 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2613 .await
2614 .unwrap();
2615 assert!(handles.is_empty());
2616 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2617
2618 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(101))
2620 .await
2621 .unwrap();
2622 for h in handles {
2623 h.await.unwrap();
2624 }
2625 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2626
2627 crons
2631 .get("default|blog|cron|0")
2632 .unwrap()
2633 .running
2634 .store(true, Ordering::Release);
2635 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(102))
2636 .await
2637 .unwrap();
2638 assert!(handles.is_empty());
2639 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2640 }
2641
2642 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2646 async fn cron_leader_gate_suppresses_crons_off_leader() {
2647 use boatramp_core::config::{
2648 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2649 };
2650 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2651 use boatramp_handlers::{HandlerEngine, Limits};
2652 use futures::StreamExt;
2653
2654 let storage = Arc::new(MemStorage::default());
2655 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2656 let deploy = DeployStore::new(storage.clone(), kv.clone());
2657
2658 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2659 let stream: ByteStream =
2660 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2661 deploy.put_blob(&hash, stream).await.unwrap();
2662 let mut files = std::collections::BTreeMap::new();
2663 files.insert(
2664 "counter.wasm".to_string(),
2665 FileEntry {
2666 hash: hash.clone(),
2667 size: KV_COUNTER.len() as u64,
2668 content_type: None,
2669 variants: std::collections::BTreeMap::new(),
2670 },
2671 );
2672 let manifest = Manifest {
2673 files,
2674 config: DeployConfig {
2675 handlers: vec![HandlerConfig {
2676 route: "/".into(),
2677 methods: Vec::new(),
2678 component: "counter.wasm".into(),
2679 imports: vec!["wasi:keyvalue".into()],
2680 limits: None,
2681 env: std::collections::BTreeMap::new(),
2682 invoke_targets: Vec::new(),
2683 }],
2684 crons: vec![CronConfig {
2685 schedule: "* * * * *".into(),
2686 route: "/".into(),
2687 overlap: Overlap::Skip,
2688 }],
2689 ..Default::default()
2690 },
2691 ..Default::default()
2692 };
2693 let id = deploy.put_manifest(&manifest).await.unwrap();
2694 deploy
2695 .activate(ProjectRef::DEFAULT, "blog", &id)
2696 .await
2697 .unwrap();
2698 deploy
2699 .set_site_config(
2700 ProjectRef::DEFAULT,
2701 "blog",
2702 &SiteConfig {
2703 handlers: Some(HandlersSiteConfig {
2704 enabled: true,
2705 allow_imports: vec!["wasi:keyvalue".into()],
2706 ..Default::default()
2707 }),
2708 ..Default::default()
2709 },
2710 )
2711 .await
2712 .unwrap();
2713
2714 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2715 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2716 rt.set_cron_leader_gate(Arc::new(|| false));
2718 let inner = rt.inner.clone().unwrap();
2719 let mut wasm = std::collections::HashMap::new();
2720 let mut crons = std::collections::HashMap::new();
2721 let now = CronNow {
2722 minute: 0,
2723 hour: 0,
2724 dom: 1,
2725 month: 1,
2726 dow: 0,
2727 minute_stamp: 100,
2728 };
2729
2730 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, now)
2731 .await
2732 .unwrap();
2733 assert!(handles.is_empty(), "a non-leader must not fire crons");
2735 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
2736 }
2737}