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_data;
80#[cfg(feature = "handlers")]
81mod graphql_federation;
82#[cfg(feature = "handlers")]
83mod graphql_gateway;
84#[cfg(feature = "handlers")]
85mod graphql_graphiql;
86#[cfg(feature = "handlers")]
87mod graphql_guard;
88#[cfg(feature = "handlers")]
89mod graphql_plan;
90#[cfg(feature = "handlers")]
91mod graphql_registry;
92#[cfg(feature = "handlers")]
93mod graphql_subscription;
94#[cfg(feature = "handlers")]
95mod handler_cache;
96#[cfg(feature = "handlers")]
97mod handler_dispatch;
98#[cfg(feature = "handlers")]
99pub(crate) use handler_dispatch::{
100 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
101 read_blob_fully,
102};
103#[cfg(all(feature = "handlers", test))]
104use handler_dispatch::{resolve_env, set_forwarded_headers};
105mod function_api;
106pub(crate) use function_api::{
107 alias_function, deploy_function, list_functions, remove_function, rollback_function,
108};
109#[cfg(all(test, feature = "handlers"))]
110use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
111mod gateway;
112mod host;
113pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
114#[cfg(feature = "http3")]
115mod http3;
116mod limits;
117#[cfg(feature = "handlers")]
118mod logs;
119#[cfg(feature = "handlers")]
120mod metrics;
121#[cfg(feature = "oidc")]
122mod oidc;
123mod operator;
124pub(crate) use operator::prometheus_metrics;
125#[cfg(feature = "handlers")]
126pub(crate) use operator::{
127 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
128};
129mod proxy;
130pub use proxy::spawn_compute_reconcile;
131pub(crate) use proxy::{
132 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
133 proxy, COMPUTE_WAKE_TIMEOUT,
134};
135#[cfg(feature = "handlers")]
137pub(crate) use proxy::is_upgrade_request;
138#[cfg(all(test, feature = "handlers"))]
139use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
140mod project_api;
141pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
142mod project_scope;
143pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
144mod ratelimit;
145mod routes;
146pub use routes::{router, router_with};
147#[cfg(feature = "mcp")]
148mod mcp_http;
149#[cfg(feature = "handlers")]
150mod scheduler;
151mod serve_pipeline;
152pub use serve_pipeline::http_redirect_router;
153#[cfg(all(test, feature = "handlers"))]
154use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
155pub(crate) use serve_pipeline::{
156 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
157 BootstrapAttestation,
158};
159pub mod signer;
162mod srvmetrics;
163#[cfg(all(feature = "handlers", test))]
164use scheduler::run_scheduler_tick;
165#[cfg(feature = "handlers")]
166pub(crate) use scheduler::{
167 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
168};
169#[cfg(feature = "handlers")]
170use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
171#[cfg(feature = "handlers")]
172mod function_runtime;
173#[cfg(feature = "handlers")]
174pub(crate) use function_runtime::{
175 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
176 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
177 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
178 put_trigger_handler, webhook_ingress,
179};
180#[cfg(feature = "handlers")]
181mod stream;
182#[cfg(feature = "handlers")]
183mod workflow;
184pub use auth::{require_auth, Auth};
185#[cfg(feature = "http3")]
186pub use http3::{
187 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
188 Http3Error,
189};
190pub use limits::{ServerLimits, UploadGuard};
191#[cfg(feature = "oidc")]
192pub use oidc::{OidcConfig, OidcError, OidcVerifier};
193use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
194#[cfg(feature = "handlers")]
195pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
196#[cfg(feature = "handlers")]
197pub(crate) use workflow::{
198 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
199 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
200};
201pub use srvmetrics::{server_metrics, ServerMetrics};
204
205#[derive(Clone, Default)]
210pub struct HandlerRuntime {
211 #[cfg(feature = "handlers")]
212 inner: Option<Arc<HandlerRuntimeInner>>,
213}
214
215#[cfg(feature = "handlers")]
216struct HandlerRuntimeInner {
217 engine: boatramp_handlers::HandlerEngine,
218 kv: Arc<dyn boatramp_core::kv::KvStore>,
219 storage: Arc<dyn boatramp_core::Storage>,
220 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
224 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
227 site_semaphores:
230 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
231 stream_semaphores:
235 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
236 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
239 metrics: metrics::Metrics,
242 logs: Arc<logs::LogStore>,
244 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
250 max_blob_bytes: std::sync::OnceLock<u64>,
254 max_component_bytes: std::sync::OnceLock<u64>,
259 function_meter_locks:
263 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
264 function_semaphores:
267 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
268 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
273 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
277 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
285 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
290}
291
292pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
295
296impl HandlerRuntime {
297 pub fn disabled() -> Self {
299 Self::default()
300 }
301
302 #[cfg(feature = "handlers")]
308 pub fn new(
309 engine: boatramp_handlers::HandlerEngine,
310 kv: Arc<dyn boatramp_core::kv::KvStore>,
311 storage: Arc<dyn boatramp_core::Storage>,
312 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
313 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
314 ) -> Self {
315 Self {
316 inner: Some(Arc::new(HandlerRuntimeInner {
317 engine,
318 kv,
319 storage,
320 sql,
321 messaging,
322 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
323 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
324 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
325 metrics: metrics::Metrics::default(),
326 logs: Arc::new(logs::LogStore::default()),
327 cron_leader_gate: std::sync::OnceLock::new(),
328 max_blob_bytes: std::sync::OnceLock::new(),
329 max_component_bytes: std::sync::OnceLock::new(),
330 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
331 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
332 watch_provider: std::sync::OnceLock::new(),
333 provision_tier: std::sync::OnceLock::new(),
334 invoker: std::sync::OnceLock::new(),
335 federation_runner: std::sync::OnceLock::new(),
336 })),
337 }
338 }
339
340 #[cfg(feature = "handlers")]
343 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
344 self.inner.as_ref().and_then(|inner| inner.sql.clone())
345 }
346
347 #[cfg(feature = "handlers")]
351 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
352 self.inner
353 .as_ref()
354 .and_then(|inner| inner.invoker.get().cloned())
355 }
356
357 #[cfg(feature = "handlers")]
361 pub(crate) async fn introspect_subgraph_sdl(
362 &self,
363 deploy: &DeployStore,
364 project: boatramp_core::project::ProjectRef<'_>,
365 function: &boatramp_core::function::Function,
366 component: &str,
367 ) -> Result<String, function_runtime::SubgraphSdlError> {
368 match self.inner.as_ref() {
369 Some(inner) => {
370 function_runtime::introspect_service_sdl(
371 inner, deploy, project, function, component,
372 )
373 .await
374 }
375 None => Err(function_runtime::SubgraphSdlError::Unavailable),
376 }
377 }
378
379 #[cfg(feature = "handlers")]
385 pub fn set_invoker(&self, deploy: DeployStore) {
386 if let Some(inner) = self.inner.as_ref() {
387 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
388 deploy,
389 Arc::downgrade(inner),
390 ));
391 let _ = inner.invoker.set(invoker);
392 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
395 inner,
396 )));
397 let _ = inner.federation_runner.set(runner);
398 }
399 }
400
401 #[cfg(feature = "handlers")]
405 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
406 self.inner.as_ref().and_then(|inner| inner.sql.clone())
407 }
408
409 #[cfg(feature = "handlers")]
413 pub fn set_watch_provider(
414 &self,
415 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
416 ) {
417 if let Some(inner) = self.inner.as_ref() {
418 let _ = inner.watch_provider.set(provider);
419 }
420 }
421
422 #[cfg(feature = "handlers")]
426 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
427 if let Some(inner) = self.inner.as_ref() {
428 let _ = inner.provision_tier.set(tier);
429 }
430 }
431
432 #[cfg(feature = "handlers")]
436 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
437 if let Some(inner) = self.inner.as_ref() {
438 let _ = inner.max_blob_bytes.set(max_bytes);
439 }
440 }
441
442 #[cfg(feature = "handlers")]
445 pub fn set_max_component_bytes(&self, max_bytes: u64) {
446 if let Some(inner) = self.inner.as_ref() {
447 let _ = inner.max_component_bytes.set(max_bytes);
448 }
449 }
450
451 #[cfg(feature = "handlers")]
456 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
457 if let Some(inner) = self.inner.as_ref() {
458 let _ = inner.cron_leader_gate.set(gate);
459 }
460 }
461
462 #[cfg(feature = "handlers")]
469 async fn precheck_activation(
470 &self,
471 deploy: &DeployStore,
472 manifest: &Manifest,
473 site_config: Option<&SiteConfig>,
474 ) -> Result<(), String> {
475 let Some(inner) = self.inner.as_ref() else {
476 return Ok(());
477 };
478 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
481 return Ok(());
482 }
483 let site_handlers = site_config
485 .and_then(|c| c.handlers.as_ref())
486 .filter(|h| h.enabled)
487 .ok_or_else(|| {
488 "deployment ships handlers/consumers but the site has them disabled".to_string()
489 })?;
490 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
491
492 for handler in &manifest.config.handlers {
494 precheck_component(
495 deploy,
496 manifest,
497 site_handlers,
498 inner,
499 max_component,
500 &handler.imports,
501 &handler.component,
502 &format!("handler {:?}", handler.route),
503 )
504 .await?;
505 }
506 for consumer in &manifest.config.consumers {
507 precheck_component(
508 deploy,
509 manifest,
510 site_handlers,
511 inner,
512 max_component,
513 &consumer.imports,
514 &consumer.component,
515 &format!("consumer {:?}", consumer.topic),
516 )
517 .await?;
518 }
519 Ok(())
520 }
521
522 #[cfg(not(feature = "handlers"))]
523 async fn precheck_activation(
524 &self,
525 _deploy: &DeployStore,
526 _manifest: &Manifest,
527 _site_config: Option<&SiteConfig>,
528 ) -> Result<(), String> {
529 Ok(())
530 }
531}
532
533#[derive(Default, Clone)]
539pub struct ServerOptions {
540 pub limits: ServerLimits,
542 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
545 pub default_site: Option<String>,
548 pub implicit_routing: bool,
554 pub protect_previews: bool,
559 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
563 pub issuer: Option<Arc<dyn Signer>>,
567 pub bootstrap_secret: Option<String>,
572 pub bootstrap_attestation: Option<String>,
578 pub mesh_control: Option<Arc<dyn MeshControl>>,
582 pub cors_allowed_origins: Vec<String>,
592 #[cfg(feature = "oidc")]
595 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
596 pub posture: boatramp_core::security::SecurityPosture,
600 pub served_over_tls: bool,
605 pub pop_origin: Option<String>,
612 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
616 #[cfg(feature = "console")]
620 pub console: Option<console::ConsoleMount>,
621}
622
623#[derive(Clone, Copy)]
627struct ServedOverTls(bool);
628
629#[derive(Clone, Copy, Default)]
634struct ImplicitRouting(bool);
635
636const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
648
649pub struct DaemonRuntime {
650 baseline: boatramp_core::daemon_config::ConfigBaseline,
651 state: std::sync::RwLock<DaemonState>,
652 reload: tokio::sync::Notify,
655}
656
657struct DaemonState {
658 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
659 generation: Option<String>,
660}
661
662pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
667 #[cfg(feature = "console")]
671 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
672 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
673 None => (false, None, None),
674 };
675 #[cfg(not(feature = "console"))]
676 let (console_enabled, console_host, console_path) = (false, None, None);
677 boatramp_core::daemon_config::ConfigBaseline {
678 default_site: options.default_site.clone(),
679 protect_previews: options.protect_previews,
680 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
681 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
682 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
683 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
684 compute_vcpus: 0,
685 compute_mem_mib: 0,
686 console_enabled,
687 console_host,
688 console_path,
689 max_upload_ceiling: options.posture.max_upload_bytes,
690 max_concurrent_uploads_ceiling: None,
691 posture: options.posture,
692 }
693}
694
695impl DaemonRuntime {
696 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
700 let effective =
701 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
702 Self {
703 baseline,
704 state: std::sync::RwLock::new(DaemonState {
705 effective,
706 generation: None,
707 }),
708 reload: tokio::sync::Notify::new(),
709 }
710 }
711
712 pub fn notify_reload(&self) {
716 self.reload.notify_one();
717 }
718
719 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
721 self.state
722 .read()
723 .expect("daemon config lock")
724 .effective
725 .clone()
726 }
727
728 pub fn generation(&self) -> Option<String> {
731 self.state
732 .read()
733 .expect("daemon config lock")
734 .generation
735 .clone()
736 }
737
738 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
740 &self.baseline
741 }
742
743 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
746 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
747 let generation = deploy.daemon_config_generation().await?;
748 let effective = Arc::new(cfg.resolve(&self.baseline));
749 *self.state.write().expect("daemon config lock") = DaemonState {
750 effective,
751 generation,
752 };
753 Ok(())
754 }
755}
756
757#[derive(Clone, Copy, Default)]
760struct PreviewPolicy {
761 protect: bool,
762}
763
764#[derive(Clone, Default)]
769struct Issuer(Option<Arc<dyn Signer>>);
770
771#[derive(Clone, Default)]
776struct BootstrapGate(Option<Arc<BootstrapInner>>);
777
778struct BootstrapInner {
779 secret_hash: String,
782 lock: tokio::sync::Mutex<()>,
785}
786
787impl BootstrapGate {
788 fn new(secret: Option<&str>) -> Self {
789 Self(secret.filter(|s| !s.is_empty()).map(|s| {
790 Arc::new(BootstrapInner {
791 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
792 lock: tokio::sync::Mutex::new(()),
793 })
794 }))
795 }
796}
797
798#[async_trait::async_trait]
802pub trait MeshControl: Send + Sync {
803 async fn admit(
811 &self,
812 mesh_pubkey_hex: &str,
813 jti: &str,
814 possession_proof: &[u8],
815 proof_iat: u64,
816 now: u64,
817 advertise_addr: Option<&str>,
818 ) -> Result<JoinOutcome, String>;
819
820 async fn rotate_key(&self) -> Result<String, String>;
824
825 async fn revoke(&self, node: u64) -> Result<(), String>;
829
830 async fn members(&self) -> Result<Vec<MeshMember>, String>;
834
835 async fn promote(&self, node: u64) -> Result<(), String>;
838}
839
840pub enum JoinOutcome {
842 Admitted {
845 members: Vec<String>,
847 addrs: std::collections::BTreeMap<u64, String>,
849 },
850 TokenSpent,
852 ProofInvalid,
854 Revoked,
857}
858
859#[derive(Debug, Clone, Serialize)]
861pub struct MeshMember {
862 pub node: u64,
864 pub voter: bool,
866 pub caught_up: bool,
868 pub leader: bool,
870 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub addr: Option<String>,
875}
876
877#[derive(Clone, Default)]
880struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
881
882#[cfg(feature = "oidc")]
884#[derive(Clone, Default)]
885struct OidcState(Option<Arc<oidc::OidcVerifier>>);
886
887#[cfg(feature = "oidc")]
890const EXCHANGE_TTL_SECS: u64 = 3600;
891
892use boatramp_core::time::now_unix;
893
894#[derive(Clone)]
896struct CorsState(Arc<Vec<String>>);
897
898const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
900const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
903const CORS_MAX_AGE: &str = "600";
905
906fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
910 allowed.iter().any(|a| a == "*" || a == origin)
911}
912
913async fn cors(
920 State(allowed): State<CorsState>,
921 request: Request,
922 next: axum::middleware::Next,
923) -> Response {
924 let origin = request
925 .headers()
926 .get(header::ORIGIN)
927 .and_then(|v| v.to_str().ok())
928 .filter(|o| cors_origin_allowed(&allowed.0, o))
929 .map(str::to_string);
930 let is_preflight = request.method() == Method::OPTIONS
932 && request
933 .headers()
934 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
935 if is_preflight {
936 let allow_headers = request
938 .headers()
939 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
940 .and_then(|v| v.to_str().ok())
941 .map(str::to_string)
942 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
943 let mut response = Response::new(Body::empty());
944 *response.status_mut() = StatusCode::NO_CONTENT;
945 if let Some(origin) = origin {
946 let headers = response.headers_mut();
947 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
948 set_header(headers, header::VARY, "Origin");
949 set_header(
950 headers,
951 header::ACCESS_CONTROL_ALLOW_METHODS,
952 CORS_ALLOW_METHODS,
953 );
954 set_header(
955 headers,
956 header::ACCESS_CONTROL_ALLOW_HEADERS,
957 &allow_headers,
958 );
959 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
960 }
961 return response;
962 }
963 let mut response = next.run(request).await;
964 if let Some(origin) = origin {
965 let headers = response.headers_mut();
966 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
967 if let Ok(value) = HeaderValue::from_str("Origin") {
970 headers.append(header::VARY, value);
971 }
972 }
973 response
974}
975
976const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
981
982#[derive(Debug, thiserror::Error)]
984pub enum ServeError {
985 #[error("server I/O: {0}")]
987 Io(#[from] std::io::Error),
988}
989
990pub async fn serve(
993 addr: SocketAddr,
994 deploy: DeployStore,
995 auth: Auth,
996 handlers: HandlerRuntime,
997) -> Result<(), ServeError> {
998 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
999}
1000
1001pub async fn serve_with(
1003 addr: SocketAddr,
1004 deploy: DeployStore,
1005 auth: Auth,
1006 handlers: HandlerRuntime,
1007 options: ServerOptions,
1008) -> Result<(), ServeError> {
1009 let listener = tokio::net::TcpListener::bind(addr).await?;
1010 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1011 #[cfg(feature = "handlers")]
1014 let scheduler = handlers.spawn_scheduler(deploy.clone());
1015 let gateway_prober = gateway::spawn_active_health_prober();
1019 let app = router_with(deploy, auth, handlers, options)
1022 .into_make_service_with_connect_info::<SocketAddr>();
1023
1024 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1028 let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1029 shutdown_signal().await;
1030 let _ = signalled_tx.send(true);
1031 });
1032 let signalled = {
1033 let mut rx = signalled_rx;
1034 async move {
1035 let _ = rx.wait_for(|fired| *fired).await;
1036 }
1037 };
1038 let result = serve_with_drain_deadline(
1039 async move { server.await.map_err(ServeError::from) },
1040 signalled,
1041 DRAIN_DEADLINE,
1042 )
1043 .await;
1044 #[cfg(feature = "handlers")]
1046 if let Some(handle) = scheduler {
1047 handle.abort();
1048 }
1049 gateway_prober.abort();
1050 result
1051}
1052
1053async fn serve_with_drain_deadline<Srv, Sig>(
1058 server: Srv,
1059 signalled: Sig,
1060 deadline: Duration,
1061) -> Result<(), ServeError>
1062where
1063 Srv: Future<Output = Result<(), ServeError>>,
1064 Sig: Future<Output = ()>,
1065{
1066 tokio::pin!(server);
1067 let drain_cap = async move {
1068 signalled.await;
1069 tokio::time::sleep(deadline).await;
1070 };
1071 tokio::select! {
1072 result = &mut server => result,
1073 _ = drain_cap => {
1074 tracing::warn!(
1075 deadline_s = deadline.as_secs(),
1076 "drain deadline exceeded; forcing shutdown with requests still in flight"
1077 );
1078 Ok(())
1079 }
1080 }
1081}
1082
1083pub async fn shutdown_signal() {
1086 let ctrl_c = async {
1087 let _ = tokio::signal::ctrl_c().await;
1088 };
1089 #[cfg(unix)]
1090 let terminate = async {
1091 if let Ok(mut sig) =
1092 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1093 {
1094 sig.recv().await;
1095 }
1096 };
1097 #[cfg(not(unix))]
1098 let terminate = std::future::pending::<()>();
1099
1100 tokio::select! {
1101 _ = ctrl_c => {}
1102 _ = terminate => {}
1103 }
1104 tracing::info!("shutdown signal received; draining");
1105}
1106
1107async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1111 match daemon.generation() {
1112 Some(gen) => format!("ok gen={gen}"),
1113 None => "ok".to_string(),
1114 }
1115}
1116
1117async fn readyz(State(deploy): State<DeployStore>) -> Response {
1119 match deploy.ready().await {
1120 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1121 Err(err) => {
1122 tracing::warn!(error = %err, "readiness probe failed");
1123 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1124 }
1125 }
1126}
1127
1128struct AccessLog {
1132 method: Method,
1133 path: String,
1134 host: String,
1135 client: String,
1136 status: u16,
1137 encoding: String,
1139 start: std::time::Instant,
1140 bytes: std::sync::atomic::AtomicU64,
1141}
1142
1143impl Drop for AccessLog {
1144 fn drop(&mut self) {
1145 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1146 srvmetrics::server_metrics().record_request(self.status, bytes);
1149 tracing::info!(
1150 target: "boatramp::access",
1151 method = %self.method,
1152 path = %self.path,
1153 host = %self.host,
1154 client = %self.client,
1155 status = self.status,
1156 bytes = bytes,
1157 encoding = %self.encoding,
1158 cache_result = srvmetrics::cache_result(self.status),
1159 elapsed_ms = self.start.elapsed().as_millis() as u64,
1160 "request"
1161 );
1162 }
1163}
1164
1165async fn access_log(request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1169 let method = request.method().clone();
1170 let path = request.uri().path().to_string();
1171 let host = request
1172 .headers()
1173 .get(header::HOST)
1174 .and_then(|value| value.to_str().ok())
1175 .unwrap_or("-")
1176 .to_string();
1177 let client = request
1178 .extensions()
1179 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1180 .map(|info| info.0.ip().to_string())
1181 .unwrap_or_else(|| "-".to_string());
1182
1183 let start = std::time::Instant::now();
1184 let response = next.run(request).await;
1185 let encoding = response
1186 .headers()
1187 .get(header::CONTENT_ENCODING)
1188 .and_then(|v| v.to_str().ok())
1189 .unwrap_or("identity")
1190 .to_string();
1191 let log = AccessLog {
1192 method,
1193 path,
1194 host,
1195 client,
1196 status: response.status().as_u16(),
1197 encoding,
1198 start,
1199 bytes: std::sync::atomic::AtomicU64::new(0),
1200 };
1201
1202 let (parts, body) = response.into_parts();
1206 let counted = body.into_data_stream().map(move |chunk| {
1207 if let Ok(bytes) = &chunk {
1208 log.bytes
1209 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1210 }
1211 chunk
1212 });
1213 Response::from_parts(parts, Body::from_stream(counted))
1214}
1215
1216fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1218 req_headers
1219 .get(header::IF_NONE_MATCH)
1220 .and_then(|value| value.to_str().ok())
1221 .is_some_and(|value| {
1222 value
1223 .split(',')
1224 .map(str::trim)
1225 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1226 })
1227}
1228
1229fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1230 if let Ok(value) = HeaderValue::from_str(value) {
1231 headers.insert(name, value);
1232 }
1233}
1234
1235fn not_found() -> Response {
1236 (StatusCode::NOT_FOUND, "not found\n").into_response()
1237}
1238
1239fn redirect(status: u16, location: &str) -> Response {
1240 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1241 match HeaderValue::from_str(location) {
1242 Ok(location) => {
1243 let mut headers = HeaderMap::new();
1244 headers.insert(header::LOCATION, location);
1245 (status, headers).into_response()
1246 }
1247 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1248 }
1249}
1250
1251fn deploy_error_response(err: DeployError) -> Response {
1253 let status = match &err {
1254 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1255 StatusCode::NOT_FOUND
1256 }
1257 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1258 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1259 DeployError::Conflict(_) => StatusCode::CONFLICT,
1261 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1263 _ => StatusCode::INTERNAL_SERVER_ERROR,
1264 };
1265 tracing::warn!(error = %err, "request failed");
1266 (status, format!("{err}\n")).into_response()
1267}
1268
1269fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1274 boatramp_core::project::validate_resource_name(kind, value)
1275 .err()
1276 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1277}
1278
1279#[cfg(test)]
1280mod drain_tests {
1281 use super::*;
1282
1283 #[tokio::test]
1284 async fn deadline_forces_shutdown_after_signal() {
1285 let server = std::future::pending::<Result<(), ServeError>>();
1288 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1290 assert!(result.is_ok());
1291 }
1292
1293 #[tokio::test]
1294 async fn server_finishing_first_wins() {
1295 let server = async { Ok(()) };
1298 let signalled = std::future::pending::<()>();
1299 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1300 assert!(result.is_ok());
1301 }
1302
1303 #[tokio::test]
1304 async fn deadline_does_not_trip_before_signal() {
1305 let server = async {
1309 tokio::time::sleep(Duration::from_millis(40)).await;
1310 Err(ServeError::Io(std::io::Error::other("server error")))
1311 };
1312 let signalled = std::future::pending::<()>();
1313 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1314 assert!(result.is_err());
1315 }
1316}
1317
1318#[cfg(all(test, feature = "handlers"))]
1319mod tests {
1320 use super::*;
1321 use boatramp_core::cose::{LocalSigner, TokenAlg};
1322 use boatramp_core::project::ProjectRef;
1323
1324 #[test]
1325 fn query_string_parses_and_url_decodes() {
1326 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1327 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1328 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")); }
1332
1333 #[test]
1334 fn cookie_header_parses_pairs() {
1335 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1336 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1337 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1338 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1339 }
1340
1341 #[test]
1342 fn apply_vary_merges_without_duplicates() {
1343 let base = (StatusCode::OK, "x").into_response();
1344 let r = apply_vary(base, &["accept-language".into()]);
1345 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1346 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1348 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1349 assert!(v.contains("accept-language") && v.contains("cookie"));
1350 assert_eq!(v.matches("accept-language").count(), 1);
1351 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1353 assert!(plain.headers().get(header::VARY).is_none());
1354 }
1355
1356 #[tokio::test]
1360 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1361 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1362 let public = keys.public_key();
1363
1364 let resp = create_join_token(
1366 Extension(Issuer(Some(keys.clone()))),
1367 Json(CreateJoinTokenRequest {
1368 ttl_secs: Some(600),
1369 }),
1370 )
1371 .await;
1372 assert_eq!(resp.status(), StatusCode::CREATED);
1373 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1374 .await
1375 .unwrap();
1376 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1377 let token = parsed["token"].as_str().unwrap();
1378 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1379 assert!(!jti.is_empty());
1380
1381 let no_issuer = create_join_token(
1383 Extension(Issuer(None)),
1384 Json(CreateJoinTokenRequest { ttl_secs: None }),
1385 )
1386 .await;
1387 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1388 }
1389
1390 #[tokio::test]
1395 async fn function_write_path_deploy_rollback_alias_remove() {
1396 use boatramp_core::function::Lifecycle;
1397 use boatramp_core::kv::MemoryKv;
1398 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1399
1400 struct FakeStorage {
1403 present: bool,
1404 }
1405 #[async_trait::async_trait]
1406 impl Storage for FakeStorage {
1407 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1408 Err(StorageError::NotFound(String::new()))
1409 }
1410 async fn get_range(
1411 &self,
1412 _: &str,
1413 _: u64,
1414 _: Option<u64>,
1415 ) -> Result<GetObject, StorageError> {
1416 Err(StorageError::NotFound(String::new()))
1417 }
1418 async fn put(
1419 &self,
1420 _: &str,
1421 _: ByteStream,
1422 _: PutMeta,
1423 ) -> Result<ObjectMeta, StorageError> {
1424 Err(StorageError::unsupported("fake"))
1425 }
1426 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1427 if self.present {
1428 Ok(ObjectMeta {
1429 key: key.to_string(),
1430 ..Default::default()
1431 })
1432 } else {
1433 Err(StorageError::NotFound(key.to_string()))
1434 }
1435 }
1436 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1437 Ok(())
1438 }
1439 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1440 Ok(Vec::new())
1441 }
1442 }
1443
1444 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1445 let status = resp.status();
1446 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1447 .await
1448 .unwrap();
1449 let value = if bytes.is_empty() {
1450 serde_json::Value::Null
1451 } else {
1452 serde_json::from_slice(&bytes).unwrap()
1453 };
1454 (status, value)
1455 }
1456
1457 let deploy = DeployStore::new(
1458 Arc::new(FakeStorage { present: true }),
1459 Arc::new(MemoryKv::new()),
1460 );
1461 let v1 = "a".repeat(64);
1462 let v2 = "b".repeat(64);
1463
1464 let (st, body) = body_json(
1466 deploy_function(
1467 State(deploy.clone()),
1468 axum::extract::Extension(crate::ProjectContext::default()),
1469 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1470 axum::extract::Query(DeployFunctionQuery::default()),
1471 Path("greeter".to_string()),
1472 Json(FunctionUpsert {
1473 component: v1.clone(),
1474 config: Default::default(),
1475 lifecycle: Lifecycle::Independent,
1476 }),
1477 )
1478 .await,
1479 )
1480 .await;
1481 assert_eq!(st, StatusCode::OK);
1482 assert_eq!(body["active"], v1);
1483
1484 let (_, body) = body_json(
1486 deploy_function(
1487 State(deploy.clone()),
1488 axum::extract::Extension(crate::ProjectContext::default()),
1489 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1490 axum::extract::Query(DeployFunctionQuery::default()),
1491 Path("greeter".to_string()),
1492 Json(FunctionUpsert {
1493 component: v2.clone(),
1494 config: Default::default(),
1495 lifecycle: Lifecycle::Independent,
1496 }),
1497 )
1498 .await,
1499 )
1500 .await;
1501 assert_eq!(body["active"], v2);
1502 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1503
1504 let (st, body) = body_json(
1506 rollback_function(
1507 State(deploy.clone()),
1508 axum::extract::Extension(crate::ProjectContext::default()),
1509 Path("greeter".to_string()),
1510 Json(RollbackBody { to: v1.clone() }),
1511 )
1512 .await,
1513 )
1514 .await;
1515 assert_eq!(st, StatusCode::OK);
1516 assert_eq!(body["active"], v1);
1517
1518 let resp = rollback_function(
1520 State(deploy.clone()),
1521 axum::extract::Extension(crate::ProjectContext::default()),
1522 Path("greeter".to_string()),
1523 Json(RollbackBody { to: "c".repeat(64) }),
1524 )
1525 .await;
1526 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1527
1528 let (st, body) = body_json(
1530 alias_function(
1531 State(deploy.clone()),
1532 axum::extract::Extension(crate::ProjectContext::default()),
1533 Path(("greeter".to_string(), "prod".to_string())),
1534 Json(AliasBody {
1535 version: v2.clone(),
1536 }),
1537 )
1538 .await,
1539 )
1540 .await;
1541 assert_eq!(st, StatusCode::OK);
1542 assert_eq!(body["aliases"]["prod"], v2);
1543
1544 let (st, _) = body_json(
1546 remove_function(
1547 State(deploy.clone()),
1548 axum::extract::Extension(crate::ProjectContext::default()),
1549 Path("greeter".to_string()),
1550 )
1551 .await,
1552 )
1553 .await;
1554 assert_eq!(st, StatusCode::NO_CONTENT);
1555 assert!(deploy
1556 .get_function(ProjectRef::DEFAULT, "greeter")
1557 .await
1558 .unwrap()
1559 .is_none());
1560
1561 let empty = DeployStore::new(
1563 Arc::new(FakeStorage { present: false }),
1564 Arc::new(MemoryKv::new()),
1565 );
1566 let resp = deploy_function(
1567 State(empty),
1568 axum::extract::Extension(crate::ProjectContext::default()),
1569 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1570 axum::extract::Query(DeployFunctionQuery::default()),
1571 Path("orphan".to_string()),
1572 Json(FunctionUpsert {
1573 component: v1.clone(),
1574 config: Default::default(),
1575 lifecycle: Lifecycle::default(),
1576 }),
1577 )
1578 .await;
1579 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1580 }
1581
1582 struct StubControl {
1586 admits: std::sync::Mutex<Vec<(String, String)>>,
1587 respond: StubJoin,
1588 }
1589 #[derive(Clone, Copy)]
1590 enum StubJoin {
1591 Admit,
1592 Spent,
1593 Invalid,
1594 Revoked,
1595 }
1596
1597 #[async_trait::async_trait]
1598 impl MeshControl for StubControl {
1599 async fn admit(
1600 &self,
1601 mesh_pubkey_hex: &str,
1602 jti: &str,
1603 _proof: &[u8],
1604 _proof_iat: u64,
1605 _now: u64,
1606 _advertise_addr: Option<&str>,
1607 ) -> Result<JoinOutcome, String> {
1608 self.admits
1609 .lock()
1610 .unwrap()
1611 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1612 Ok(match self.respond {
1613 StubJoin::Admit => JoinOutcome::Admitted {
1614 members: vec!["signed-member".to_string()],
1615 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1616 },
1617 StubJoin::Spent => JoinOutcome::TokenSpent,
1618 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1619 StubJoin::Revoked => JoinOutcome::Revoked,
1620 })
1621 }
1622 async fn rotate_key(&self) -> Result<String, String> {
1623 Ok("cafe".to_string())
1624 }
1625 async fn revoke(&self, _node: u64) -> Result<(), String> {
1626 Ok(())
1627 }
1628 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1629 Ok(Vec::new())
1630 }
1631 async fn promote(&self, _node: u64) -> Result<(), String> {
1632 Ok(())
1633 }
1634 }
1635
1636 #[tokio::test]
1640 async fn cluster_join_dispatches_and_maps_outcomes() {
1641 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1642 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1643 let auth = Auth::with_key(keys.public_key(), kv);
1644 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1645 let req = |proof: &str| JoinRequest {
1646 token: token.clone(),
1647 mesh_pubkey: "302a300506032b6570032100feed".into(),
1648 possession_proof: proof.to_string(),
1649 proof_iat: now_unix(),
1650 advertise_addr: Some("https://joiner:7000".into()),
1651 };
1652
1653 let admitter = Arc::new(StubControl {
1655 admits: std::sync::Mutex::new(Vec::new()),
1656 respond: StubJoin::Admit,
1657 });
1658 let resp = cluster_join(
1659 Extension(auth.clone()),
1660 Extension(MeshControlHandle(Some(admitter.clone()))),
1661 Json(req("aa01")),
1662 )
1663 .await;
1664 assert_eq!(resp.status(), StatusCode::OK);
1665 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1666
1667 let spent = Arc::new(StubControl {
1669 admits: std::sync::Mutex::new(Vec::new()),
1670 respond: StubJoin::Spent,
1671 });
1672 assert_eq!(
1673 cluster_join(
1674 Extension(auth.clone()),
1675 Extension(MeshControlHandle(Some(spent))),
1676 Json(req("aa01")),
1677 )
1678 .await
1679 .status(),
1680 StatusCode::CONFLICT
1681 );
1682 let invalid = Arc::new(StubControl {
1683 admits: std::sync::Mutex::new(Vec::new()),
1684 respond: StubJoin::Invalid,
1685 });
1686 assert_eq!(
1687 cluster_join(
1688 Extension(auth.clone()),
1689 Extension(MeshControlHandle(Some(invalid))),
1690 Json(req("aa01")),
1691 )
1692 .await
1693 .status(),
1694 StatusCode::FORBIDDEN
1695 );
1696 let revoked = Arc::new(StubControl {
1698 admits: std::sync::Mutex::new(Vec::new()),
1699 respond: StubJoin::Revoked,
1700 });
1701 assert_eq!(
1702 cluster_join(
1703 Extension(auth.clone()),
1704 Extension(MeshControlHandle(Some(revoked))),
1705 Json(req("aa01")),
1706 )
1707 .await
1708 .status(),
1709 StatusCode::FORBIDDEN
1710 );
1711
1712 let ok = Arc::new(StubControl {
1714 admits: std::sync::Mutex::new(Vec::new()),
1715 respond: StubJoin::Admit,
1716 });
1717 assert_eq!(
1718 cluster_join(
1719 Extension(auth.clone()),
1720 Extension(MeshControlHandle(Some(ok))),
1721 Json(req("not-hex")),
1722 )
1723 .await
1724 .status(),
1725 StatusCode::BAD_REQUEST
1726 );
1727
1728 let none = cluster_join(
1730 Extension(auth),
1731 Extension(MeshControlHandle(None)),
1732 Json(req("aa01")),
1733 )
1734 .await;
1735 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1736 }
1737
1738 #[tokio::test]
1742 async fn bootstrap_mints_the_first_token_once() {
1743 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1744 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1745 let public = keys.public_key();
1746 let deploy = DeployStore::new(
1747 Arc::new(MemStorage::default()),
1748 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1749 );
1750 let secret = "s3cr3t-bootstrap-value";
1751 let gate = BootstrapGate::new(Some(secret));
1752 let issuer = Issuer(Some(keys.clone()));
1753 let bearer = |s: &str| {
1754 let mut h = HeaderMap::new();
1755 h.insert(
1756 AUTHORIZATION,
1757 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1758 );
1759 h
1760 };
1761 let req = || BootstrapRequest {
1762 roles: vec!["admin".to_string()],
1763 ttl_secs: None,
1764 };
1765
1766 let bad = bootstrap_token(
1768 State(deploy.clone()),
1769 Extension(issuer.clone()),
1770 Extension(gate.clone()),
1771 bearer("wrong"),
1772 Json(req()),
1773 )
1774 .await;
1775 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1776
1777 let ok = bootstrap_token(
1779 State(deploy.clone()),
1780 Extension(issuer.clone()),
1781 Extension(gate.clone()),
1782 bearer(secret),
1783 Json(req()),
1784 )
1785 .await;
1786 assert_eq!(ok.status(), StatusCode::CREATED);
1787 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1788 .await
1789 .unwrap();
1790 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1791 let token = json["token"].as_str().unwrap();
1792 let id = json["id"].as_str().unwrap();
1793 let verified = cose::verify(token, &public, now_unix()).unwrap();
1794 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1795 assert!(deploy
1796 .list_token_meta()
1797 .await
1798 .unwrap()
1799 .iter()
1800 .any(|m| m.revocation_id == id));
1801
1802 let reuse = bootstrap_token(
1804 State(deploy.clone()),
1805 Extension(issuer.clone()),
1806 Extension(gate),
1807 bearer(secret),
1808 Json(req()),
1809 )
1810 .await;
1811 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1812
1813 let disabled = bootstrap_token(
1815 State(deploy),
1816 Extension(issuer),
1817 Extension(BootstrapGate(None)),
1818 bearer(secret),
1819 Json(req()),
1820 )
1821 .await;
1822 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1823 }
1824
1825 #[tokio::test]
1828 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1829 let control = Arc::new(StubControl {
1830 admits: std::sync::Mutex::new(Vec::new()),
1831 respond: StubJoin::Admit,
1832 });
1833 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1834 assert_eq!(resp.status(), StatusCode::OK);
1835 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1836 .await
1837 .unwrap();
1838 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1839 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1840
1841 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1842 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1843 }
1844
1845 #[test]
1846 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1847 use boatramp_core::security::SecurityProfile;
1848 let strict = SecurityProfile::MultiTenant.preset();
1849 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1853 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1854 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1855
1856 assert!(gateway_addr_allowed(public, &strict));
1858 assert!(!gateway_addr_allowed(private, &strict));
1859 assert!(!gateway_addr_allowed(loopback, &strict));
1860 assert!(!gateway_addr_allowed(metadata, &strict));
1861
1862 assert!(gateway_addr_allowed(public, &loose));
1865 assert!(gateway_addr_allowed(private, &loose));
1866 assert!(gateway_addr_allowed(loopback, &loose));
1867 assert!(!gateway_addr_allowed(metadata, &loose));
1868 }
1869
1870 #[test]
1871 fn resolve_env_merges_static_and_host_secrets() {
1872 use boatramp_core::config::HandlersSiteConfig;
1873
1874 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1876
1877 let deploy_env = std::collections::BTreeMap::from([
1878 ("GREETING".to_string(), "hi".to_string()),
1879 ("OVERRIDE_ME".to_string(), "static".to_string()),
1880 ]);
1881 let site_handlers = HandlersSiteConfig {
1882 enabled: true,
1883 secrets: std::collections::BTreeMap::from([
1884 (
1886 "SECRET_TOKEN".to_string(),
1887 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1888 ),
1889 (
1890 "OVERRIDE_ME".to_string(),
1891 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1892 ),
1893 (
1894 "MISSING".to_string(),
1895 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1896 ),
1897 ]),
1898 ..Default::default()
1899 };
1900 let env = resolve_env("blog", &deploy_env, &site_handlers);
1901
1902 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1906 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1907 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1908 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1909
1910 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1911 }
1912
1913 fn req() -> Request {
1914 Request::builder()
1915 .uri("/")
1916 .header(header::HOST, "example.com")
1917 .body(Body::empty())
1918 .unwrap()
1919 }
1920
1921 #[test]
1922 fn forwarded_headers_set_standard_triple() {
1923 let mut request = req();
1924 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1925 let h = request.headers();
1926 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
1927 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
1928 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
1929 }
1930
1931 #[test]
1932 fn forwarded_for_overwrites_spoofed_value() {
1933 let mut request = Request::builder()
1936 .uri("/")
1937 .header(header::HOST, "example.com")
1938 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
1939 .body(Body::empty())
1940 .unwrap();
1941 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1942 let values: Vec<_> = request
1943 .headers()
1944 .get_all("x-forwarded-for")
1945 .iter()
1946 .collect();
1947 assert_eq!(values.len(), 1);
1948 assert_eq!(values[0], "203.0.113.7");
1949 }
1950
1951 #[test]
1952 fn forwarded_proto_preserves_upstream_tls() {
1953 let mut request = Request::builder()
1955 .uri("/")
1956 .header(header::HOST, "example.com")
1957 .header("x-forwarded-proto", "https")
1958 .body(Body::empty())
1959 .unwrap();
1960 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1961 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
1962 }
1963
1964 #[test]
1965 fn forwarded_host_absent_when_no_host_header() {
1966 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
1967 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1968 assert!(request.headers().get("x-forwarded-host").is_none());
1969 assert_eq!(
1970 request.headers().get("x-forwarded-for").unwrap(),
1971 "203.0.113.7"
1972 );
1973 }
1974
1975 use boatramp_core::kv::{KvStore, MemoryKv};
1978 use boatramp_core::messaging::{LogMessaging, Messaging};
1979 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
1980
1981 const EVENT_CONSUMER: &[u8] =
1982 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
1983
1984 #[derive(Default)]
1985 struct MemStorage {
1986 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
1987 }
1988
1989 #[async_trait::async_trait]
1990 impl boatramp_core::Storage for MemStorage {
1991 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
1992 let bytes = self
1993 .objects
1994 .lock()
1995 .unwrap()
1996 .get(key)
1997 .cloned()
1998 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
1999 let body: ByteStream =
2000 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2001 Ok(GetObject {
2002 meta: ObjectMeta {
2003 key: key.to_string(),
2004 ..Default::default()
2005 },
2006 body,
2007 })
2008 }
2009 async fn get_range(
2010 &self,
2011 key: &str,
2012 _: u64,
2013 _: Option<u64>,
2014 ) -> Result<GetObject, StorageError> {
2015 self.get(key).await
2016 }
2017 async fn put(
2018 &self,
2019 key: &str,
2020 mut body: ByteStream,
2021 _: PutMeta,
2022 ) -> Result<ObjectMeta, StorageError> {
2023 use futures::StreamExt;
2024 let mut buf = Vec::new();
2025 while let Some(chunk) = body.next().await {
2026 buf.extend_from_slice(&chunk?);
2027 }
2028 self.objects.lock().unwrap().insert(key.to_string(), buf);
2029 Ok(ObjectMeta {
2030 key: key.to_string(),
2031 ..Default::default()
2032 })
2033 }
2034 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2035 self.objects
2036 .lock()
2037 .unwrap()
2038 .get(key)
2039 .map(|_| ObjectMeta {
2040 key: key.to_string(),
2041 ..Default::default()
2042 })
2043 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2044 }
2045 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2046 self.objects.lock().unwrap().remove(key);
2047 Ok(())
2048 }
2049 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2050 Ok(Vec::new())
2051 }
2052 }
2053
2054 fn observed_state(
2056 workload: &str,
2057 healthy: bool,
2058 phase: boatramp_core::compute::ReplicaPhase,
2059 ) -> boatramp_core::compute::ObservedInstance {
2060 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2061 boatramp_core::compute::ObservedInstance {
2062 handle: InstanceHandle {
2063 workload: workload.into(),
2064 replica: 0,
2065 backend_ref: "ref-0".into(),
2066 },
2067 node: 1,
2068 backend: "vmm".into(),
2069 endpoint: Endpoint {
2070 scheme: Scheme::Http,
2071 host: "10.0.0.2".into(),
2072 port: 80,
2073 },
2074 region: None,
2075 healthy,
2076 phase,
2077 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2078 workload: workload.into(),
2079 replica: 0,
2080 data_ref: "snap-0".into(),
2081 }),
2082 }
2083 }
2084
2085 #[tokio::test]
2086 async fn has_parked_replica_detects_a_zeroed_replica() {
2087 use boatramp_core::compute::ReplicaPhase;
2088 let storage = Arc::new(MemStorage::default());
2089 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2090 let deploy = DeployStore::new(storage, kv);
2091
2092 assert!(!has_parked_replica(&deploy, "w").await);
2094 deploy
2096 .set_replica_state(
2097 ProjectRef::DEFAULT,
2098 &observed_state("w", true, ReplicaPhase::Running),
2099 )
2100 .await
2101 .unwrap();
2102 assert!(!has_parked_replica(&deploy, "w").await);
2103 deploy
2105 .set_replica_state(
2106 ProjectRef::DEFAULT,
2107 &observed_state("w", false, ReplicaPhase::Zero),
2108 )
2109 .await
2110 .unwrap();
2111 assert!(has_parked_replica(&deploy, "w").await);
2112 }
2113
2114 #[tokio::test]
2115 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2116 use boatramp_core::compute::ReplicaPhase;
2117 let storage = Arc::new(MemStorage::default());
2118 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2119 let deploy = DeployStore::new(storage, kv);
2120
2121 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2123 assert!(empty.is_empty());
2124
2125 deploy
2127 .set_replica_state(
2128 ProjectRef::DEFAULT,
2129 &observed_state("w", true, ReplicaPhase::Running),
2130 )
2131 .await
2132 .unwrap();
2133 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2134 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2135 }
2136
2137 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2141 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2142 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2143 let storage = Arc::new(MemStorage::default());
2144 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2145 let mq = LogMessaging::new(storage, kv.clone());
2146 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2147 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2148 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2149 let topic = "blog/orders/created";
2150
2151 for _ in 0..3 {
2153 mq.publish(topic, b"ok").await.unwrap();
2154 }
2155 loop {
2156 let acked = dispatch_consumer_batch(
2157 &engine,
2158 &mq,
2159 &metrics::Metrics::default(),
2160 "blog",
2161 topic,
2162 "blog/",
2163 &hash,
2164 EVENT_CONSUMER,
2165 &bindings,
2166 Limits::default(),
2167 Duration::from_secs(30),
2168 5,
2169 10,
2170 )
2171 .await;
2172 if acked == 0 {
2173 break;
2174 }
2175 }
2176 assert_eq!(
2177 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2178 Some(b"3".to_vec())
2179 );
2180
2181 mq.publish(topic, b"fail").await.unwrap();
2184 for _ in 0..5 {
2185 dispatch_consumer_batch(
2186 &engine,
2187 &mq,
2188 &metrics::Metrics::default(),
2189 "blog",
2190 topic,
2191 "blog/",
2192 &hash,
2193 EVENT_CONSUMER,
2194 &bindings,
2195 Limits::default(),
2196 Duration::ZERO,
2197 2,
2198 10,
2199 )
2200 .await;
2201 }
2202 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2203 assert_eq!(
2205 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2206 Some(b"3".to_vec())
2207 );
2208 }
2209
2210 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2214 async fn scheduler_runs_current_consumers_not_previews() {
2215 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2216 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2217 use boatramp_handlers::{HandlerEngine, Limits};
2218 use futures::StreamExt;
2219
2220 let storage = Arc::new(MemStorage::default());
2221 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2222 let deploy = DeployStore::new(storage.clone(), kv.clone());
2223 let messaging: Arc<dyn Messaging> =
2224 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2225
2226 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2228 let stream: ByteStream =
2229 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2230 .boxed();
2231 deploy.put_blob(&hash, stream).await.unwrap();
2232 let mut files = std::collections::BTreeMap::new();
2233 files.insert(
2234 "consumer.wasm".to_string(),
2235 FileEntry {
2236 hash: hash.clone(),
2237 size: EVENT_CONSUMER.len() as u64,
2238 content_type: None,
2239 variants: std::collections::BTreeMap::new(),
2240 },
2241 );
2242 let manifest = Manifest {
2243 files,
2244 config: DeployConfig {
2245 consumers: vec![ConsumerConfig {
2246 topic: "orders/created".into(),
2247 component: "consumer.wasm".into(),
2248 imports: vec!["wasi:keyvalue".into()],
2249 }],
2250 ..Default::default()
2251 },
2252 ..Default::default()
2253 };
2254 let id = deploy.put_manifest(&manifest).await.unwrap();
2255 deploy
2256 .activate(ProjectRef::DEFAULT, "blog", &id)
2257 .await
2258 .unwrap();
2259 deploy
2260 .set_site_config(
2261 ProjectRef::DEFAULT,
2262 "blog",
2263 &SiteConfig {
2264 handlers: Some(HandlersSiteConfig {
2265 enabled: true,
2266 allow_imports: vec!["wasi:keyvalue".into()],
2267 ..Default::default()
2268 }),
2269 ..Default::default()
2270 },
2271 )
2272 .await
2273 .unwrap();
2274
2275 messaging
2277 .publish("blog/orders/created", b"live")
2278 .await
2279 .unwrap();
2280 messaging
2281 .publish("blog/_preview/abc/orders/created", b"preview")
2282 .await
2283 .unwrap();
2284
2285 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2286 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2287 let inner = rt.inner.clone().unwrap();
2288 let mut cache = std::collections::HashMap::new();
2289 let mut crons = std::collections::HashMap::new();
2290 let now = CronNow {
2291 minute: 0,
2292 hour: 0,
2293 dom: 1,
2294 month: 1,
2295 dow: 0,
2296 minute_stamp: 0,
2297 };
2298 for _ in 0..3 {
2299 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, now)
2300 .await
2301 .unwrap();
2302 }
2303
2304 assert_eq!(
2306 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2307 Some(b"1".to_vec())
2308 );
2309 assert_eq!(
2312 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2313 .await
2314 .unwrap(),
2315 None
2316 );
2317 }
2318
2319 const KV_COUNTER: &[u8] =
2324 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2325
2326 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2331 async fn function_invoker_runs_target_buffers_and_meters() {
2332 use boatramp_core::deploy::DeployStore;
2333 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2334 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2335 use futures::StreamExt;
2336
2337 const HTTP_200: &[u8] =
2340 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2341
2342 let storage = Arc::new(MemStorage::default());
2343 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2344 let deploy = DeployStore::new(storage.clone(), kv.clone());
2345
2346 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2347 let stream: ByteStream =
2348 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2349 deploy.put_blob(&hash, stream).await.unwrap();
2350 let function = Function {
2351 name: "target".into(),
2352 owner: Owner::Project("default".into()),
2353 versions: vec![FunctionVersion {
2354 id: "v1".into(),
2355 component: hash.clone(),
2356 created: 0,
2357 lifecycle: Lifecycle::Independent,
2358 }],
2359 active: "v1".into(),
2360 aliases: Default::default(),
2361 config: Default::default(),
2362 };
2363 deploy
2364 .put_function(ProjectRef::DEFAULT, &function)
2365 .await
2366 .unwrap();
2367
2368 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2369 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2370 rt.set_invoker(deploy.clone());
2371 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2372
2373 let request = || InvokeRequest {
2374 method: "GET".into(),
2375 path: "/".into(),
2376 headers: vec![],
2377 body: vec![],
2378 };
2379
2380 let response = invoker.invoke("target", request(), 1).await.unwrap();
2382 assert_eq!(response.status, 200);
2383
2384 let metering = deploy
2386 .get_metering(ProjectRef::DEFAULT, "target")
2387 .await
2388 .unwrap()
2389 .unwrap();
2390 assert_eq!(metering.invocations, 1);
2391
2392 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2394 assert!(matches!(err, InvokeError::NotFound));
2395 }
2396
2397 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2403 async fn federation_runner_enforces_the_safelist_before_planning() {
2404 use boatramp_core::deploy::DeployStore;
2405 use boatramp_core::project::ProjectRef;
2406 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2407
2408 let storage = Arc::new(MemStorage::default());
2409 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2410 let deploy = DeployStore::new(storage.clone(), kv.clone());
2411 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2412 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2413 rt.set_invoker(deploy.clone());
2414 let runner = rt
2415 .inner
2416 .as_ref()
2417 .unwrap()
2418 .federation_runner
2419 .get()
2420 .unwrap()
2421 .scoped(ProjectRef::new("default"));
2422
2423 let req = |query: &str| GraphqlRequest {
2424 query: Some(query.to_string()),
2425 persisted_hash: None,
2426 variables: "{}".to_string(),
2427 operation_name: None,
2428 authorization: None,
2429 };
2430
2431 assert!(matches!(
2433 runner.run(req("{ me { id } }"), 1).await,
2434 Err(SupergraphRunError::NotSafelisted)
2435 ));
2436
2437 let query = "{ me { id } }";
2440 let hash = crate::graphql_apq::sha256_hex(query);
2441 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2442 .await
2443 .unwrap();
2444 assert!(matches!(
2445 runner.run(req(query), 1).await,
2446 Err(SupergraphRunError::PlanFailed(_))
2447 ));
2448
2449 let persisted = GraphqlRequest {
2451 query: None,
2452 persisted_hash: Some("deadbeef".to_string()),
2453 variables: "{}".to_string(),
2454 operation_name: None,
2455 authorization: None,
2456 };
2457 assert!(matches!(
2458 runner.run(persisted, 1).await,
2459 Err(SupergraphRunError::NotSafelisted)
2460 ));
2461 }
2462
2463 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2469 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2470 use crate::scheduler::{run_scheduler_tick, CronNow};
2471 use boatramp_core::deploy::DeployStore;
2472 use boatramp_core::function::{
2473 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2474 };
2475 use boatramp_handlers::{HandlerEngine, Limits};
2476 use futures::StreamExt;
2477
2478 const HTTP_200: &[u8] =
2479 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2480
2481 let storage = Arc::new(MemStorage::default());
2482 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2483 let deploy = DeployStore::new(storage.clone(), kv.clone());
2484
2485 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2486 let stream: ByteStream =
2487 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2488 deploy.put_blob(&hash, stream).await.unwrap();
2489
2490 let acme = ProjectRef::new("acme");
2492 let function = Function {
2493 name: "worker".into(),
2494 owner: Owner::Project("acme".into()),
2495 versions: vec![FunctionVersion {
2496 id: "v1".into(),
2497 component: hash.clone(),
2498 created: 0,
2499 lifecycle: Lifecycle::Independent,
2500 }],
2501 active: "v1".into(),
2502 aliases: Default::default(),
2503 config: Default::default(),
2504 };
2505 deploy.put_function(acme, &function).await.unwrap();
2506 let inv = Invocation {
2507 id: "inv1".into(),
2508 function: "worker".into(),
2509 version: "v1".into(),
2510 mode: InvokeMode::Async,
2511 status: InvocationStatus::Queued,
2512 idempotency_key: None,
2513 attempts: 0,
2514 request_b64: None,
2515 request_content_type: None,
2516 result: None,
2517 created: 0,
2518 updated: 0,
2519 };
2520 deploy.put_invocation(acme, &inv).await.unwrap();
2521
2522 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2523 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2524 let inner = rt.inner.as_ref().unwrap();
2525
2526 let mut wasm_cache = std::collections::HashMap::new();
2529 let mut cron_state = std::collections::HashMap::new();
2530 let now = CronNow {
2531 minute: 0,
2532 hour: 0,
2533 dom: 1,
2534 month: 1,
2535 dow: 0,
2536 minute_stamp: 0,
2537 };
2538 run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
2539 .await
2540 .unwrap();
2541
2542 let settled = deploy
2544 .get_invocation(acme, "worker", "inv1")
2545 .await
2546 .unwrap()
2547 .expect("invocation still present in acme");
2548 assert_eq!(settled.status, InvocationStatus::Succeeded);
2549 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2551 assert_eq!(metering.invocations, 1);
2552 assert!(deploy
2554 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2555 .await
2556 .unwrap()
2557 .is_none());
2558 assert!(deploy
2559 .get_metering(ProjectRef::DEFAULT, "worker")
2560 .await
2561 .unwrap()
2562 .is_none());
2563 }
2564
2565 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2580 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2581 use boatramp_core::deploy::DeployStore;
2582 use boatramp_core::function::{
2583 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2584 };
2585 use boatramp_handlers::{HandlerEngine, Limits};
2586 use futures::StreamExt;
2587
2588 let storage = Arc::new(MemStorage::default());
2589 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2590 let deploy = DeployStore::new(storage.clone(), kv.clone());
2591
2592 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2595 let stream: ByteStream =
2596 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2597 deploy.put_blob(&hash, stream).await.unwrap();
2598
2599 let store = Function {
2604 name: "store".into(),
2605 owner: Owner::Project("default".into()),
2606 versions: vec![FunctionVersion {
2607 id: "v1".into(),
2608 component: hash.clone(),
2609 created: 0,
2610 lifecycle: Lifecycle::Independent,
2611 }],
2612 active: "v1".into(),
2613 aliases: Default::default(),
2614 config: FunctionConfig {
2615 imports: vec!["wasi:keyvalue".into()],
2616 ..Default::default()
2617 },
2618 };
2619 let acme = ProjectRef::new("acme");
2620 let globex = ProjectRef::new("globex");
2621
2622 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2623 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2624 let inner = rt.inner.as_ref().unwrap();
2625
2626 let request = || {
2627 axum::http::Request::builder()
2628 .method("GET")
2629 .uri("/")
2630 .body(axum::body::Body::empty())
2631 .unwrap()
2632 };
2633
2634 let component = store.resolve(&store.active).unwrap().to_owned();
2637 for project in [acme, globex, ProjectRef::DEFAULT] {
2638 let (response, _) =
2639 execute_function(inner, &deploy, project, &store, &component, request(), 0).await;
2640 assert!(response.status().is_success(), "invocation should succeed");
2641 }
2642
2643 assert_eq!(
2646 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2647 Some(b"1".to_vec()),
2648 "acme's write must be tenant-qualified"
2649 );
2650 assert_eq!(
2651 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2652 Some(b"1".to_vec()),
2653 "globex's write must be tenant-qualified"
2654 );
2655 assert_eq!(
2656 kv.get("hkv/fn/store/hits").await.unwrap(),
2657 Some(b"1".to_vec()),
2658 "the default project must keep the byte-identical pre-project key"
2659 );
2660 }
2663
2664 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2668 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2669 use boatramp_core::config::{
2670 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2671 };
2672 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2673 use boatramp_handlers::{HandlerEngine, Limits};
2674 use futures::StreamExt;
2675 use std::sync::atomic::Ordering;
2676
2677 let storage = Arc::new(MemStorage::default());
2678 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2679 let deploy = DeployStore::new(storage.clone(), kv.clone());
2680
2681 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2682 let stream: ByteStream =
2683 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2684 deploy.put_blob(&hash, stream).await.unwrap();
2685 let mut files = std::collections::BTreeMap::new();
2686 files.insert(
2687 "counter.wasm".to_string(),
2688 FileEntry {
2689 hash: hash.clone(),
2690 size: KV_COUNTER.len() as u64,
2691 content_type: None,
2692 variants: std::collections::BTreeMap::new(),
2693 },
2694 );
2695 let manifest = Manifest {
2696 files,
2697 config: DeployConfig {
2698 handlers: vec![HandlerConfig {
2699 route: "/".into(),
2700 methods: Vec::new(),
2701 component: "counter.wasm".into(),
2702 imports: vec!["wasi:keyvalue".into()],
2703 limits: None,
2704 env: std::collections::BTreeMap::new(),
2705 invoke_targets: Vec::new(),
2706 }],
2707 crons: vec![CronConfig {
2708 schedule: "* * * * *".into(),
2709 route: "/".into(),
2710 overlap: Overlap::Skip,
2711 }],
2712 ..Default::default()
2713 },
2714 ..Default::default()
2715 };
2716 let id = deploy.put_manifest(&manifest).await.unwrap();
2717 deploy
2718 .activate(ProjectRef::DEFAULT, "blog", &id)
2719 .await
2720 .unwrap();
2721 deploy
2722 .set_site_config(
2723 ProjectRef::DEFAULT,
2724 "blog",
2725 &SiteConfig {
2726 handlers: Some(HandlersSiteConfig {
2727 enabled: true,
2728 allow_imports: vec!["wasi:keyvalue".into()],
2729 ..Default::default()
2730 }),
2731 ..Default::default()
2732 },
2733 )
2734 .await
2735 .unwrap();
2736
2737 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2738 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2739 let inner = rt.inner.clone().unwrap();
2740 let mut wasm = std::collections::HashMap::new();
2741 let mut crons = std::collections::HashMap::new();
2742 let at = |stamp| CronNow {
2743 minute: 0,
2744 hour: 0,
2745 dom: 1,
2746 month: 1,
2747 dow: 0,
2748 minute_stamp: stamp,
2749 };
2750
2751 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2753 .await
2754 .unwrap();
2755 for h in handles {
2756 h.await.unwrap();
2757 }
2758 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2759
2760 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2762 .await
2763 .unwrap();
2764 assert!(handles.is_empty());
2765 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2766
2767 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(101))
2769 .await
2770 .unwrap();
2771 for h in handles {
2772 h.await.unwrap();
2773 }
2774 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2775
2776 crons
2780 .get("default|blog|cron|0")
2781 .unwrap()
2782 .running
2783 .store(true, Ordering::Release);
2784 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(102))
2785 .await
2786 .unwrap();
2787 assert!(handles.is_empty());
2788 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2789 }
2790
2791 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2795 async fn cron_leader_gate_suppresses_crons_off_leader() {
2796 use boatramp_core::config::{
2797 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2798 };
2799 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2800 use boatramp_handlers::{HandlerEngine, Limits};
2801 use futures::StreamExt;
2802
2803 let storage = Arc::new(MemStorage::default());
2804 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2805 let deploy = DeployStore::new(storage.clone(), kv.clone());
2806
2807 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2808 let stream: ByteStream =
2809 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2810 deploy.put_blob(&hash, stream).await.unwrap();
2811 let mut files = std::collections::BTreeMap::new();
2812 files.insert(
2813 "counter.wasm".to_string(),
2814 FileEntry {
2815 hash: hash.clone(),
2816 size: KV_COUNTER.len() as u64,
2817 content_type: None,
2818 variants: std::collections::BTreeMap::new(),
2819 },
2820 );
2821 let manifest = Manifest {
2822 files,
2823 config: DeployConfig {
2824 handlers: vec![HandlerConfig {
2825 route: "/".into(),
2826 methods: Vec::new(),
2827 component: "counter.wasm".into(),
2828 imports: vec!["wasi:keyvalue".into()],
2829 limits: None,
2830 env: std::collections::BTreeMap::new(),
2831 invoke_targets: Vec::new(),
2832 }],
2833 crons: vec![CronConfig {
2834 schedule: "* * * * *".into(),
2835 route: "/".into(),
2836 overlap: Overlap::Skip,
2837 }],
2838 ..Default::default()
2839 },
2840 ..Default::default()
2841 };
2842 let id = deploy.put_manifest(&manifest).await.unwrap();
2843 deploy
2844 .activate(ProjectRef::DEFAULT, "blog", &id)
2845 .await
2846 .unwrap();
2847 deploy
2848 .set_site_config(
2849 ProjectRef::DEFAULT,
2850 "blog",
2851 &SiteConfig {
2852 handlers: Some(HandlersSiteConfig {
2853 enabled: true,
2854 allow_imports: vec!["wasi:keyvalue".into()],
2855 ..Default::default()
2856 }),
2857 ..Default::default()
2858 },
2859 )
2860 .await
2861 .unwrap();
2862
2863 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2864 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2865 rt.set_cron_leader_gate(Arc::new(|| false));
2867 let inner = rt.inner.clone().unwrap();
2868 let mut wasm = std::collections::HashMap::new();
2869 let mut crons = std::collections::HashMap::new();
2870 let now = CronNow {
2871 minute: 0,
2872 hour: 0,
2873 dom: 1,
2874 month: 1,
2875 dow: 0,
2876 minute_stamp: 100,
2877 };
2878
2879 let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, now)
2880 .await
2881 .unwrap();
2882 assert!(handles.is_empty(), "a non-leader must not fire crons");
2884 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
2885 }
2886}