1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, create_deployment, current_deployment, delete_compute,
44 delete_site, get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
45 list_aliases, list_compute, list_deployments, list_sites, prune_delete, prune_report, put_blob,
46 put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
47 scrub_blobs, set_alias,
48};
49#[cfg(feature = "handlers")]
50pub(crate) use admin_api::{
51 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
52 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
53 put_graphql_subgraph, register_graphql_safelist,
54};
55mod auth;
56#[cfg(feature = "console")]
57pub mod console;
58mod content;
59mod control_api;
60#[cfg(feature = "compression")]
61pub(crate) use content::maybe_compress;
62pub(crate) use content::multipart_byteranges;
63pub(crate) use content::{
64 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
65};
66pub(crate) use control_api::{
67 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
68 cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
69 list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
70};
71#[cfg(all(test, feature = "handlers"))]
72use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
73mod domain_verify;
74pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
75pub mod envelope;
76#[cfg(feature = "handlers")]
77mod graphql_apq;
78#[cfg(feature = "handlers")]
79mod graphql_cache;
80#[cfg(feature = "handlers")]
81mod graphql_data;
82#[cfg(feature = "handlers")]
83mod graphql_federation;
84#[cfg(feature = "handlers")]
85mod graphql_gateway;
86#[cfg(feature = "handlers")]
87mod graphql_graphiql;
88#[cfg(feature = "handlers")]
89mod graphql_guard;
90#[cfg(feature = "handlers")]
91mod graphql_plan;
92#[cfg(feature = "handlers")]
93mod graphql_registry;
94#[cfg(feature = "handlers")]
95mod graphql_subscription;
96#[cfg(feature = "handlers")]
97mod handler_cache;
98#[cfg(feature = "handlers")]
99mod handler_dispatch;
100#[cfg(feature = "handlers")]
101pub(crate) use handler_dispatch::{
102 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
103 read_blob_fully,
104};
105#[cfg(all(feature = "handlers", test))]
106use handler_dispatch::{resolve_env, set_forwarded_headers};
107mod function_api;
108pub(crate) use function_api::{
109 alias_function, deploy_function, list_functions, remove_function, rollback_function,
110};
111#[cfg(all(test, feature = "handlers"))]
112use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
113mod gateway;
114mod host;
115pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
116#[cfg(feature = "http3")]
117mod http3;
118mod limits;
119#[cfg(feature = "handlers")]
120mod logs;
121#[cfg(feature = "handlers")]
122mod metrics;
123#[cfg(feature = "oidc")]
124mod oidc;
125mod operator;
126pub(crate) use operator::prometheus_metrics;
127#[cfg(feature = "handlers")]
128pub(crate) use operator::{
129 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
130};
131mod proxy;
132pub use proxy::spawn_compute_reconcile;
133pub(crate) use proxy::{
134 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
135 proxy, COMPUTE_WAKE_TIMEOUT,
136};
137mod splice;
138mod http_serve;
141pub use http_serve::{
142 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
143 serve_tls_listener, ReloadableTls,
144};
145#[cfg(feature = "handlers")]
147pub(crate) use proxy::is_upgrade_request;
148#[cfg(all(test, feature = "handlers"))]
149use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
150mod project_api;
151pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
152mod project_scope;
153pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
154mod ratelimit;
155mod routes;
156pub use routes::{router, router_with};
157#[cfg(feature = "mcp")]
158mod mcp_http;
159#[cfg(feature = "handlers")]
160mod scheduler;
161mod serve_pipeline;
162pub use serve_pipeline::http_redirect_router;
163#[cfg(all(test, feature = "handlers"))]
164use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
165pub(crate) use serve_pipeline::{
166 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
167 BootstrapAttestation,
168};
169pub mod signer;
172mod srvmetrics;
173#[cfg(all(feature = "handlers", test))]
174use scheduler::run_scheduler_tick;
175#[cfg(feature = "handlers")]
176pub(crate) use scheduler::{
177 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
178};
179#[cfg(feature = "handlers")]
180use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
181#[cfg(feature = "handlers")]
182mod function_runtime;
183#[cfg(feature = "handlers")]
184pub(crate) use function_runtime::{
185 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
186 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
187 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
188 put_trigger_handler, webhook_ingress,
189};
190#[cfg(feature = "handlers")]
191mod stream;
192#[cfg(feature = "handlers")]
193mod workflow;
194pub use auth::{require_auth, Auth};
195#[cfg(feature = "http3")]
196pub use http3::{
197 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
198 Http3Error,
199};
200pub use limits::{ServerLimits, UploadGuard};
201#[cfg(feature = "oidc")]
202pub use oidc::{OidcConfig, OidcError, OidcVerifier};
203use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
204#[cfg(feature = "handlers")]
205pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
206#[cfg(feature = "handlers")]
207pub(crate) use workflow::{
208 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
209 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
210};
211pub use srvmetrics::{server_metrics, ServerMetrics};
214
215#[derive(Clone, Default)]
220pub struct HandlerRuntime {
221 #[cfg(feature = "handlers")]
222 inner: Option<Arc<HandlerRuntimeInner>>,
223}
224
225#[cfg(feature = "handlers")]
226struct HandlerRuntimeInner {
227 engine: boatramp_handlers::HandlerEngine,
228 async_drain_gate: Arc<tokio::sync::Semaphore>,
234 kv: Arc<dyn boatramp_core::kv::KvStore>,
235 storage: Arc<dyn boatramp_core::Storage>,
236 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
240 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
243 site_semaphores:
246 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
247 stream_semaphores:
251 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
252 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
255 metrics: metrics::Metrics,
258 logs: Arc<logs::LogStore>,
260 #[cfg(feature = "handlers")]
264 graphql_cache: graphql_cache::GraphqlCache,
265 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
271 max_blob_bytes: std::sync::OnceLock<u64>,
275 max_component_bytes: std::sync::OnceLock<u64>,
280 function_meter_locks:
284 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
285 function_semaphores:
288 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
289 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
294 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
298 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
306 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
311}
312
313pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
316
317impl HandlerRuntime {
318 pub fn disabled() -> Self {
320 Self::default()
321 }
322
323 #[cfg(feature = "handlers")]
329 pub fn new(
330 engine: boatramp_handlers::HandlerEngine,
331 kv: Arc<dyn boatramp_core::kv::KvStore>,
332 storage: Arc<dyn boatramp_core::Storage>,
333 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
334 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
335 ) -> Self {
336 let async_drain_slots = engine.async_max_concurrency().max(1);
339 Self {
340 inner: Some(Arc::new(HandlerRuntimeInner {
341 engine,
342 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
343 kv,
344 storage,
345 sql,
346 messaging,
347 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
348 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
349 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
350 metrics: metrics::Metrics::default(),
351 logs: Arc::new(logs::LogStore::default()),
352 #[cfg(feature = "handlers")]
353 graphql_cache: graphql_cache::GraphqlCache::default(),
354 cron_leader_gate: std::sync::OnceLock::new(),
355 max_blob_bytes: std::sync::OnceLock::new(),
356 max_component_bytes: std::sync::OnceLock::new(),
357 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
358 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
359 watch_provider: std::sync::OnceLock::new(),
360 provision_tier: std::sync::OnceLock::new(),
361 invoker: std::sync::OnceLock::new(),
362 federation_runner: std::sync::OnceLock::new(),
363 })),
364 }
365 }
366
367 #[cfg(feature = "handlers")]
370 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
371 self.inner.as_ref().and_then(|inner| inner.sql.clone())
372 }
373
374 #[cfg(feature = "handlers")]
378 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
379 self.inner
380 .as_ref()
381 .and_then(|inner| inner.invoker.get().cloned())
382 }
383
384 #[cfg(feature = "handlers")]
388 pub(crate) async fn introspect_subgraph_sdl(
389 &self,
390 deploy: &DeployStore,
391 project: boatramp_core::project::ProjectRef<'_>,
392 function: &boatramp_core::function::Function,
393 component: &str,
394 ) -> Result<String, function_runtime::SubgraphSdlError> {
395 match self.inner.as_ref() {
396 Some(inner) => {
397 function_runtime::introspect_service_sdl(
398 inner, deploy, project, function, component,
399 )
400 .await
401 }
402 None => Err(function_runtime::SubgraphSdlError::Unavailable),
403 }
404 }
405
406 #[cfg(feature = "handlers")]
412 pub fn set_invoker(&self, deploy: DeployStore) {
413 if let Some(inner) = self.inner.as_ref() {
414 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
415 deploy,
416 Arc::downgrade(inner),
417 ));
418 let _ = inner.invoker.set(invoker);
419 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
422 inner,
423 )));
424 let _ = inner.federation_runner.set(runner);
425 }
426 }
427
428 #[cfg(feature = "handlers")]
432 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
433 self.inner.as_ref().and_then(|inner| inner.sql.clone())
434 }
435
436 #[cfg(feature = "handlers")]
440 pub fn set_watch_provider(
441 &self,
442 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
443 ) {
444 if let Some(inner) = self.inner.as_ref() {
445 let _ = inner.watch_provider.set(provider);
446 }
447 }
448
449 #[cfg(feature = "handlers")]
453 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
454 if let Some(inner) = self.inner.as_ref() {
455 let _ = inner.provision_tier.set(tier);
456 }
457 }
458
459 #[cfg(feature = "handlers")]
463 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
464 if let Some(inner) = self.inner.as_ref() {
465 let _ = inner.max_blob_bytes.set(max_bytes);
466 }
467 }
468
469 #[cfg(feature = "handlers")]
472 pub fn set_max_component_bytes(&self, max_bytes: u64) {
473 if let Some(inner) = self.inner.as_ref() {
474 let _ = inner.max_component_bytes.set(max_bytes);
475 }
476 }
477
478 #[cfg(feature = "handlers")]
483 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
484 if let Some(inner) = self.inner.as_ref() {
485 let _ = inner.cron_leader_gate.set(gate);
486 }
487 }
488
489 #[cfg(feature = "handlers")]
496 async fn precheck_activation(
497 &self,
498 deploy: &DeployStore,
499 manifest: &Manifest,
500 site_config: Option<&SiteConfig>,
501 ) -> Result<(), String> {
502 let Some(inner) = self.inner.as_ref() else {
503 return Ok(());
504 };
505 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
508 return Ok(());
509 }
510 let site_handlers = site_config
512 .and_then(|c| c.handlers.as_ref())
513 .filter(|h| h.enabled)
514 .ok_or_else(|| {
515 "deployment ships handlers/consumers but the site has them disabled".to_string()
516 })?;
517 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
518
519 let sync_ceiling = inner.engine.sync_timeout_ms();
525 let async_ceiling = inner.engine.async_timeout_ms();
526 if let Some(ms) = site_handlers.max_timeout_ms {
527 if u64::from(ms) > sync_ceiling {
528 tracing::warn!(
529 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
530 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
531 applies only to async calls (?mode=async / triggers), capped at \
532 async_max_timeout_ms={async_ceiling}"
533 );
534 }
535 }
536
537 for handler in &manifest.config.handlers {
539 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
540 if u64::from(ms) > sync_ceiling {
541 let route = &handler.route;
542 tracing::warn!(
543 "route {route:?} declares limits.timeout_ms={ms}, above \
544 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
545 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
546 (?mode=async / a queue trigger / a #[consumer]), capped at \
547 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
548 that isn't possible — move the work to the async lane"
549 );
550 }
551 }
552 precheck_component(
553 deploy,
554 manifest,
555 site_handlers,
556 inner,
557 max_component,
558 &handler.imports,
559 &handler.component,
560 &format!("handler {:?}", handler.route),
561 false,
562 )
563 .await?;
564 }
565 for consumer in &manifest.config.consumers {
566 precheck_component(
567 deploy,
568 manifest,
569 site_handlers,
570 inner,
571 max_component,
572 &consumer.imports,
573 &consumer.component,
574 &format!("consumer {:?}", consumer.topic),
575 true,
576 )
577 .await?;
578 }
579 Ok(())
580 }
581
582 #[cfg(not(feature = "handlers"))]
583 async fn precheck_activation(
584 &self,
585 _deploy: &DeployStore,
586 _manifest: &Manifest,
587 _site_config: Option<&SiteConfig>,
588 ) -> Result<(), String> {
589 Ok(())
590 }
591}
592
593#[derive(Default, Clone)]
599pub struct ServerOptions {
600 pub limits: ServerLimits,
602 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
605 pub default_site: Option<String>,
608 pub implicit_routing: bool,
614 pub protect_previews: bool,
619 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
623 pub issuer: Option<Arc<dyn Signer>>,
627 pub bootstrap_secret: Option<String>,
632 pub bootstrap_attestation: Option<String>,
638 pub mesh_control: Option<Arc<dyn MeshControl>>,
642 pub cors_allowed_origins: Vec<String>,
652 #[cfg(feature = "oidc")]
655 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
656 pub posture: boatramp_core::security::SecurityPosture,
660 pub served_over_tls: bool,
665 pub pop_origin: Option<String>,
672 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
676 #[cfg(feature = "console")]
680 pub console: Option<console::ConsoleMount>,
681}
682
683#[derive(Clone, Copy)]
687struct ServedOverTls(bool);
688
689#[derive(Clone, Copy, Default)]
694struct ImplicitRouting(bool);
695
696const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
708
709pub struct DaemonRuntime {
710 baseline: boatramp_core::daemon_config::ConfigBaseline,
711 state: std::sync::RwLock<DaemonState>,
712 reload: tokio::sync::Notify,
715}
716
717struct DaemonState {
718 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
719 generation: Option<String>,
720}
721
722pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
727 #[cfg(feature = "console")]
731 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
732 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
733 None => (false, None, None),
734 };
735 #[cfg(not(feature = "console"))]
736 let (console_enabled, console_host, console_path) = (false, None, None);
737 boatramp_core::daemon_config::ConfigBaseline {
738 default_site: options.default_site.clone(),
739 protect_previews: options.protect_previews,
740 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
741 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
742 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
743 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
744 compute_vcpus: 0,
745 compute_mem_mib: 0,
746 console_enabled,
747 console_host,
748 console_path,
749 max_upload_ceiling: options.posture.max_upload_bytes,
750 max_concurrent_uploads_ceiling: None,
751 posture: options.posture,
752 }
753}
754
755impl DaemonRuntime {
756 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
760 let effective =
761 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
762 Self {
763 baseline,
764 state: std::sync::RwLock::new(DaemonState {
765 effective,
766 generation: None,
767 }),
768 reload: tokio::sync::Notify::new(),
769 }
770 }
771
772 pub fn notify_reload(&self) {
776 self.reload.notify_one();
777 }
778
779 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
781 self.state
782 .read()
783 .expect("daemon config lock")
784 .effective
785 .clone()
786 }
787
788 pub fn generation(&self) -> Option<String> {
791 self.state
792 .read()
793 .expect("daemon config lock")
794 .generation
795 .clone()
796 }
797
798 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
800 &self.baseline
801 }
802
803 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
806 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
807 let generation = deploy.daemon_config_generation().await?;
808 let effective = Arc::new(cfg.resolve(&self.baseline));
809 *self.state.write().expect("daemon config lock") = DaemonState {
810 effective,
811 generation,
812 };
813 Ok(())
814 }
815}
816
817#[derive(Clone, Copy, Default)]
820struct PreviewPolicy {
821 protect: bool,
822}
823
824#[derive(Clone, Default)]
829struct Issuer(Option<Arc<dyn Signer>>);
830
831#[derive(Clone, Default)]
836struct BootstrapGate(Option<Arc<BootstrapInner>>);
837
838struct BootstrapInner {
839 secret_hash: String,
842 lock: tokio::sync::Mutex<()>,
845}
846
847impl BootstrapGate {
848 fn new(secret: Option<&str>) -> Self {
849 Self(secret.filter(|s| !s.is_empty()).map(|s| {
850 Arc::new(BootstrapInner {
851 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
852 lock: tokio::sync::Mutex::new(()),
853 })
854 }))
855 }
856}
857
858#[async_trait::async_trait]
862pub trait MeshControl: Send + Sync {
863 async fn admit(
871 &self,
872 mesh_pubkey_hex: &str,
873 jti: &str,
874 possession_proof: &[u8],
875 proof_iat: u64,
876 now: u64,
877 advertise_addr: Option<&str>,
878 ) -> Result<JoinOutcome, String>;
879
880 async fn rotate_key(&self) -> Result<String, String>;
884
885 async fn revoke(&self, node: u64) -> Result<(), String>;
889
890 async fn members(&self) -> Result<Vec<MeshMember>, String>;
894
895 async fn promote(&self, node: u64) -> Result<(), String>;
898}
899
900pub enum JoinOutcome {
902 Admitted {
905 members: Vec<String>,
907 addrs: std::collections::BTreeMap<u64, String>,
909 },
910 TokenSpent,
912 ProofInvalid,
914 Revoked,
917}
918
919#[derive(Debug, Clone, Serialize)]
921pub struct MeshMember {
922 pub node: u64,
924 pub voter: bool,
926 pub caught_up: bool,
928 pub leader: bool,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub addr: Option<String>,
935}
936
937#[derive(Clone, Default)]
940struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
941
942#[cfg(feature = "oidc")]
944#[derive(Clone, Default)]
945struct OidcState(Option<Arc<oidc::OidcVerifier>>);
946
947#[cfg(feature = "oidc")]
950const EXCHANGE_TTL_SECS: u64 = 3600;
951
952use boatramp_core::time::now_unix;
953
954#[derive(Clone)]
956struct CorsState(Arc<Vec<String>>);
957
958const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
960const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
963const CORS_MAX_AGE: &str = "600";
965
966fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
970 allowed.iter().any(|a| a == "*" || a == origin)
971}
972
973async fn cors(
980 State(allowed): State<CorsState>,
981 request: Request,
982 next: axum::middleware::Next,
983) -> Response {
984 let origin = request
985 .headers()
986 .get(header::ORIGIN)
987 .and_then(|v| v.to_str().ok())
988 .filter(|o| cors_origin_allowed(&allowed.0, o))
989 .map(str::to_string);
990 let is_preflight = request.method() == Method::OPTIONS
992 && request
993 .headers()
994 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
995 if is_preflight {
996 let allow_headers = request
998 .headers()
999 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1000 .and_then(|v| v.to_str().ok())
1001 .map(str::to_string)
1002 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1003 let mut response = Response::new(Body::empty());
1004 *response.status_mut() = StatusCode::NO_CONTENT;
1005 if let Some(origin) = origin {
1006 let headers = response.headers_mut();
1007 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1008 set_header(headers, header::VARY, "Origin");
1009 set_header(
1010 headers,
1011 header::ACCESS_CONTROL_ALLOW_METHODS,
1012 CORS_ALLOW_METHODS,
1013 );
1014 set_header(
1015 headers,
1016 header::ACCESS_CONTROL_ALLOW_HEADERS,
1017 &allow_headers,
1018 );
1019 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1020 }
1021 return response;
1022 }
1023 let mut response = next.run(request).await;
1024 if let Some(origin) = origin {
1025 let headers = response.headers_mut();
1026 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1027 if let Ok(value) = HeaderValue::from_str("Origin") {
1030 headers.append(header::VARY, value);
1031 }
1032 }
1033 response
1034}
1035
1036const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1041
1042#[derive(Debug, thiserror::Error)]
1044pub enum ServeError {
1045 #[error("server I/O: {0}")]
1047 Io(#[from] std::io::Error),
1048}
1049
1050pub async fn serve(
1053 addr: SocketAddr,
1054 deploy: DeployStore,
1055 auth: Auth,
1056 handlers: HandlerRuntime,
1057) -> Result<(), ServeError> {
1058 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1059}
1060
1061pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1071 if let Err(err) = stream.set_nodelay(true) {
1072 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1073 }
1074}
1075
1076pub async fn serve_with(
1078 addr: SocketAddr,
1079 deploy: DeployStore,
1080 auth: Auth,
1081 handlers: HandlerRuntime,
1082 options: ServerOptions,
1083) -> Result<(), ServeError> {
1084 let tcp = tokio::net::TcpListener::bind(addr).await?;
1085 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1086 let splice_ctx = splice::SpliceCtx {
1092 deploy: deploy.clone(),
1093 posture: options.posture,
1094 daemon: options.daemon_runtime.clone(),
1095 };
1096 #[cfg(feature = "handlers")]
1099 let scheduler = handlers.spawn_scheduler(deploy.clone());
1100 let gateway_prober = gateway::spawn_active_health_prober();
1104 let router = router_with(deploy, auth, handlers, options);
1107
1108 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1112 let server = splice::serve(tcp, splice_ctx, router, async move {
1116 shutdown_signal().await;
1117 let _ = signalled_tx.send(true);
1118 });
1119 let signalled = {
1120 let mut rx = signalled_rx;
1121 async move {
1122 let _ = rx.wait_for(|fired| *fired).await;
1123 }
1124 };
1125 let result = serve_with_drain_deadline(
1126 async move { server.await.map_err(ServeError::from) },
1127 signalled,
1128 DRAIN_DEADLINE,
1129 )
1130 .await;
1131 #[cfg(feature = "handlers")]
1133 if let Some(handle) = scheduler {
1134 handle.abort();
1135 }
1136 gateway_prober.abort();
1137 result
1138}
1139
1140async fn serve_with_drain_deadline<Srv, Sig>(
1145 server: Srv,
1146 signalled: Sig,
1147 deadline: Duration,
1148) -> Result<(), ServeError>
1149where
1150 Srv: Future<Output = Result<(), ServeError>>,
1151 Sig: Future<Output = ()>,
1152{
1153 tokio::pin!(server);
1154 let drain_cap = async move {
1155 signalled.await;
1156 tokio::time::sleep(deadline).await;
1157 };
1158 tokio::select! {
1159 result = &mut server => result,
1160 _ = drain_cap => {
1161 tracing::warn!(
1162 deadline_s = deadline.as_secs(),
1163 "drain deadline exceeded; forcing shutdown with requests still in flight"
1164 );
1165 Ok(())
1166 }
1167 }
1168}
1169
1170pub async fn shutdown_signal() {
1173 let ctrl_c = async {
1174 let _ = tokio::signal::ctrl_c().await;
1175 };
1176 #[cfg(unix)]
1177 let terminate = async {
1178 if let Ok(mut sig) =
1179 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1180 {
1181 sig.recv().await;
1182 }
1183 };
1184 #[cfg(not(unix))]
1185 let terminate = std::future::pending::<()>();
1186
1187 tokio::select! {
1188 _ = ctrl_c => {}
1189 _ = terminate => {}
1190 }
1191 tracing::info!("shutdown signal received; draining");
1192}
1193
1194async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1198 match daemon.generation() {
1199 Some(gen) => format!("ok gen={gen}"),
1200 None => "ok".to_string(),
1201 }
1202}
1203
1204async fn readyz(State(deploy): State<DeployStore>) -> Response {
1206 match deploy.ready().await {
1207 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1208 Err(err) => {
1209 tracing::warn!(error = %err, "readiness probe failed");
1210 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1211 }
1212 }
1213}
1214
1215#[derive(Clone)]
1220pub struct RequestId(pub String);
1221
1222fn request_id_for(headers: &HeaderMap) -> String {
1225 if let Some(id) = headers
1226 .get("x-request-id")
1227 .and_then(|v| v.to_str().ok())
1228 .map(str::trim)
1229 .filter(|s| !s.is_empty())
1230 {
1231 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1232 }
1233 use std::sync::atomic::{AtomicU64, Ordering};
1234 static SEQ: AtomicU64 = AtomicU64::new(0);
1235 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1236 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1237}
1238
1239struct AccessLog {
1243 request_id: String,
1244 method: Method,
1245 path: String,
1246 host: String,
1247 client: String,
1248 status: u16,
1249 encoding: String,
1251 start: std::time::Instant,
1252 bytes: std::sync::atomic::AtomicU64,
1253}
1254
1255impl Drop for AccessLog {
1256 fn drop(&mut self) {
1257 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1258 srvmetrics::server_metrics().record_request(self.status, bytes);
1261 tracing::info!(
1262 target: "boatramp::access",
1263 request_id = %self.request_id,
1264 method = %self.method,
1265 path = %self.path,
1266 host = %self.host,
1267 client = %self.client,
1268 status = self.status,
1269 bytes = bytes,
1270 encoding = %self.encoding,
1271 cache_result = srvmetrics::cache_result(self.status),
1272 elapsed_ms = self.start.elapsed().as_millis() as u64,
1273 "request"
1274 );
1275 }
1276}
1277
1278async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1282 let request_id = request_id_for(request.headers());
1285 request
1286 .extensions_mut()
1287 .insert(RequestId(request_id.clone()));
1288 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1293 return next.run(request).await;
1294 }
1295 let method = request.method().clone();
1296 let path = request.uri().path().to_string();
1297 let host = request
1298 .headers()
1299 .get(header::HOST)
1300 .and_then(|value| value.to_str().ok())
1301 .or_else(|| request.uri().host()) .unwrap_or("-")
1303 .to_string();
1304 let client = request
1305 .extensions()
1306 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1307 .map(|info| info.0.ip().to_string())
1308 .unwrap_or_else(|| "-".to_string());
1309
1310 let start = std::time::Instant::now();
1311 let response = next.run(request).await;
1312 let encoding = response
1313 .headers()
1314 .get(header::CONTENT_ENCODING)
1315 .and_then(|v| v.to_str().ok())
1316 .unwrap_or("identity")
1317 .to_string();
1318 let log = AccessLog {
1319 request_id,
1320 method,
1321 path,
1322 host,
1323 client,
1324 status: response.status().as_u16(),
1325 encoding,
1326 start,
1327 bytes: std::sync::atomic::AtomicU64::new(0),
1328 };
1329
1330 let (parts, body) = response.into_parts();
1334 let counted = body.into_data_stream().map(move |chunk| {
1335 if let Ok(bytes) = &chunk {
1336 log.bytes
1337 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1338 }
1339 chunk
1340 });
1341 Response::from_parts(parts, Body::from_stream(counted))
1342}
1343
1344fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1346 req_headers
1347 .get(header::IF_NONE_MATCH)
1348 .and_then(|value| value.to_str().ok())
1349 .is_some_and(|value| {
1350 value
1351 .split(',')
1352 .map(str::trim)
1353 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1354 })
1355}
1356
1357fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1358 if let Ok(value) = HeaderValue::from_str(value) {
1359 headers.insert(name, value);
1360 }
1361}
1362
1363fn not_found() -> Response {
1364 (StatusCode::NOT_FOUND, "not found\n").into_response()
1365}
1366
1367fn redirect(status: u16, location: &str) -> Response {
1368 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1369 match HeaderValue::from_str(location) {
1370 Ok(location) => {
1371 let mut headers = HeaderMap::new();
1372 headers.insert(header::LOCATION, location);
1373 (status, headers).into_response()
1374 }
1375 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1376 }
1377}
1378
1379fn deploy_error_response(err: DeployError) -> Response {
1381 let status = match &err {
1382 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1383 StatusCode::NOT_FOUND
1384 }
1385 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1386 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1387 DeployError::Conflict(_) => StatusCode::CONFLICT,
1389 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1391 _ => StatusCode::INTERNAL_SERVER_ERROR,
1392 };
1393 tracing::warn!(error = %err, "request failed");
1394 (status, format!("{err}\n")).into_response()
1395}
1396
1397fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1402 boatramp_core::project::validate_resource_name(kind, value)
1403 .err()
1404 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1405}
1406
1407#[cfg(test)]
1408mod drain_tests {
1409 use super::*;
1410
1411 #[tokio::test]
1412 async fn deadline_forces_shutdown_after_signal() {
1413 let server = std::future::pending::<Result<(), ServeError>>();
1416 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1418 assert!(result.is_ok());
1419 }
1420
1421 #[tokio::test]
1422 async fn server_finishing_first_wins() {
1423 let server = async { Ok(()) };
1426 let signalled = std::future::pending::<()>();
1427 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1428 assert!(result.is_ok());
1429 }
1430
1431 #[tokio::test]
1432 async fn deadline_does_not_trip_before_signal() {
1433 let server = async {
1437 tokio::time::sleep(Duration::from_millis(40)).await;
1438 Err(ServeError::Io(std::io::Error::other("server error")))
1439 };
1440 let signalled = std::future::pending::<()>();
1441 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1442 assert!(result.is_err());
1443 }
1444}
1445
1446#[cfg(all(test, feature = "handlers"))]
1447mod tests {
1448 use super::*;
1449 use boatramp_core::cose::{LocalSigner, TokenAlg};
1450 use boatramp_core::project::ProjectRef;
1451
1452 #[test]
1453 fn query_string_parses_and_url_decodes() {
1454 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1455 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1456 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")); }
1460
1461 #[test]
1462 fn cookie_header_parses_pairs() {
1463 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1464 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1465 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1466 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1467 }
1468
1469 #[test]
1470 fn apply_vary_merges_without_duplicates() {
1471 let base = (StatusCode::OK, "x").into_response();
1472 let r = apply_vary(base, &["accept-language".into()]);
1473 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1474 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1476 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1477 assert!(v.contains("accept-language") && v.contains("cookie"));
1478 assert_eq!(v.matches("accept-language").count(), 1);
1479 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1481 assert!(plain.headers().get(header::VARY).is_none());
1482 }
1483
1484 #[tokio::test]
1488 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1489 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1490 let public = keys.public_key();
1491
1492 let resp = create_join_token(
1494 Extension(Issuer(Some(keys.clone()))),
1495 Json(CreateJoinTokenRequest {
1496 ttl_secs: Some(600),
1497 }),
1498 )
1499 .await;
1500 assert_eq!(resp.status(), StatusCode::CREATED);
1501 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1502 .await
1503 .unwrap();
1504 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1505 let token = parsed["token"].as_str().unwrap();
1506 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1507 assert!(!jti.is_empty());
1508
1509 let no_issuer = create_join_token(
1511 Extension(Issuer(None)),
1512 Json(CreateJoinTokenRequest { ttl_secs: None }),
1513 )
1514 .await;
1515 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1516 }
1517
1518 #[tokio::test]
1523 async fn function_write_path_deploy_rollback_alias_remove() {
1524 use boatramp_core::function::Lifecycle;
1525 use boatramp_core::kv::MemoryKv;
1526 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1527
1528 struct FakeStorage {
1531 present: bool,
1532 }
1533 #[async_trait::async_trait]
1534 impl Storage for FakeStorage {
1535 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1536 Err(StorageError::NotFound(String::new()))
1537 }
1538 async fn get_range(
1539 &self,
1540 _: &str,
1541 _: u64,
1542 _: Option<u64>,
1543 ) -> Result<GetObject, StorageError> {
1544 Err(StorageError::NotFound(String::new()))
1545 }
1546 async fn put(
1547 &self,
1548 _: &str,
1549 _: ByteStream,
1550 _: PutMeta,
1551 ) -> Result<ObjectMeta, StorageError> {
1552 Err(StorageError::unsupported("fake"))
1553 }
1554 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1555 if self.present {
1556 Ok(ObjectMeta {
1557 key: key.to_string(),
1558 ..Default::default()
1559 })
1560 } else {
1561 Err(StorageError::NotFound(key.to_string()))
1562 }
1563 }
1564 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1565 Ok(())
1566 }
1567 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1568 Ok(Vec::new())
1569 }
1570 }
1571
1572 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1573 let status = resp.status();
1574 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1575 .await
1576 .unwrap();
1577 let value = if bytes.is_empty() {
1578 serde_json::Value::Null
1579 } else {
1580 serde_json::from_slice(&bytes).unwrap()
1581 };
1582 (status, value)
1583 }
1584
1585 let deploy = DeployStore::new(
1586 Arc::new(FakeStorage { present: true }),
1587 Arc::new(MemoryKv::new()),
1588 );
1589 let v1 = "a".repeat(64);
1590 let v2 = "b".repeat(64);
1591
1592 let (st, body) = body_json(
1594 deploy_function(
1595 State(deploy.clone()),
1596 axum::extract::Extension(crate::ProjectContext::default()),
1597 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1598 axum::extract::Query(DeployFunctionQuery::default()),
1599 Path("greeter".to_string()),
1600 Json(FunctionUpsert {
1601 component: v1.clone(),
1602 config: Default::default(),
1603 lifecycle: Lifecycle::Independent,
1604 }),
1605 )
1606 .await,
1607 )
1608 .await;
1609 assert_eq!(st, StatusCode::OK);
1610 assert_eq!(body["active"], v1);
1611
1612 let (_, body) = body_json(
1614 deploy_function(
1615 State(deploy.clone()),
1616 axum::extract::Extension(crate::ProjectContext::default()),
1617 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1618 axum::extract::Query(DeployFunctionQuery::default()),
1619 Path("greeter".to_string()),
1620 Json(FunctionUpsert {
1621 component: v2.clone(),
1622 config: Default::default(),
1623 lifecycle: Lifecycle::Independent,
1624 }),
1625 )
1626 .await,
1627 )
1628 .await;
1629 assert_eq!(body["active"], v2);
1630 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1631
1632 let (st, body) = body_json(
1634 rollback_function(
1635 State(deploy.clone()),
1636 axum::extract::Extension(crate::ProjectContext::default()),
1637 Path("greeter".to_string()),
1638 Json(RollbackBody { to: v1.clone() }),
1639 )
1640 .await,
1641 )
1642 .await;
1643 assert_eq!(st, StatusCode::OK);
1644 assert_eq!(body["active"], v1);
1645
1646 let resp = rollback_function(
1648 State(deploy.clone()),
1649 axum::extract::Extension(crate::ProjectContext::default()),
1650 Path("greeter".to_string()),
1651 Json(RollbackBody { to: "c".repeat(64) }),
1652 )
1653 .await;
1654 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1655
1656 let (st, body) = body_json(
1658 alias_function(
1659 State(deploy.clone()),
1660 axum::extract::Extension(crate::ProjectContext::default()),
1661 Path(("greeter".to_string(), "prod".to_string())),
1662 Json(AliasBody {
1663 version: v2.clone(),
1664 }),
1665 )
1666 .await,
1667 )
1668 .await;
1669 assert_eq!(st, StatusCode::OK);
1670 assert_eq!(body["aliases"]["prod"], v2);
1671
1672 let (st, _) = body_json(
1674 remove_function(
1675 State(deploy.clone()),
1676 axum::extract::Extension(crate::ProjectContext::default()),
1677 Path("greeter".to_string()),
1678 )
1679 .await,
1680 )
1681 .await;
1682 assert_eq!(st, StatusCode::NO_CONTENT);
1683 assert!(deploy
1684 .get_function(ProjectRef::DEFAULT, "greeter")
1685 .await
1686 .unwrap()
1687 .is_none());
1688
1689 let empty = DeployStore::new(
1691 Arc::new(FakeStorage { present: false }),
1692 Arc::new(MemoryKv::new()),
1693 );
1694 let resp = deploy_function(
1695 State(empty),
1696 axum::extract::Extension(crate::ProjectContext::default()),
1697 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1698 axum::extract::Query(DeployFunctionQuery::default()),
1699 Path("orphan".to_string()),
1700 Json(FunctionUpsert {
1701 component: v1.clone(),
1702 config: Default::default(),
1703 lifecycle: Lifecycle::default(),
1704 }),
1705 )
1706 .await;
1707 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1708 }
1709
1710 struct StubControl {
1714 admits: std::sync::Mutex<Vec<(String, String)>>,
1715 respond: StubJoin,
1716 }
1717 #[derive(Clone, Copy)]
1718 enum StubJoin {
1719 Admit,
1720 Spent,
1721 Invalid,
1722 Revoked,
1723 }
1724
1725 #[async_trait::async_trait]
1726 impl MeshControl for StubControl {
1727 async fn admit(
1728 &self,
1729 mesh_pubkey_hex: &str,
1730 jti: &str,
1731 _proof: &[u8],
1732 _proof_iat: u64,
1733 _now: u64,
1734 _advertise_addr: Option<&str>,
1735 ) -> Result<JoinOutcome, String> {
1736 self.admits
1737 .lock()
1738 .unwrap()
1739 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1740 Ok(match self.respond {
1741 StubJoin::Admit => JoinOutcome::Admitted {
1742 members: vec!["signed-member".to_string()],
1743 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1744 },
1745 StubJoin::Spent => JoinOutcome::TokenSpent,
1746 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1747 StubJoin::Revoked => JoinOutcome::Revoked,
1748 })
1749 }
1750 async fn rotate_key(&self) -> Result<String, String> {
1751 Ok("cafe".to_string())
1752 }
1753 async fn revoke(&self, _node: u64) -> Result<(), String> {
1754 Ok(())
1755 }
1756 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1757 Ok(Vec::new())
1758 }
1759 async fn promote(&self, _node: u64) -> Result<(), String> {
1760 Ok(())
1761 }
1762 }
1763
1764 #[tokio::test]
1768 async fn cluster_join_dispatches_and_maps_outcomes() {
1769 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1770 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1771 let auth = Auth::with_key(keys.public_key(), kv);
1772 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1773 let req = |proof: &str| JoinRequest {
1774 token: token.clone(),
1775 mesh_pubkey: "302a300506032b6570032100feed".into(),
1776 possession_proof: proof.to_string(),
1777 proof_iat: now_unix(),
1778 advertise_addr: Some("https://joiner:7000".into()),
1779 };
1780
1781 let admitter = Arc::new(StubControl {
1783 admits: std::sync::Mutex::new(Vec::new()),
1784 respond: StubJoin::Admit,
1785 });
1786 let resp = cluster_join(
1787 Extension(auth.clone()),
1788 Extension(MeshControlHandle(Some(admitter.clone()))),
1789 Json(req("aa01")),
1790 )
1791 .await;
1792 assert_eq!(resp.status(), StatusCode::OK);
1793 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1794
1795 let spent = Arc::new(StubControl {
1797 admits: std::sync::Mutex::new(Vec::new()),
1798 respond: StubJoin::Spent,
1799 });
1800 assert_eq!(
1801 cluster_join(
1802 Extension(auth.clone()),
1803 Extension(MeshControlHandle(Some(spent))),
1804 Json(req("aa01")),
1805 )
1806 .await
1807 .status(),
1808 StatusCode::CONFLICT
1809 );
1810 let invalid = Arc::new(StubControl {
1811 admits: std::sync::Mutex::new(Vec::new()),
1812 respond: StubJoin::Invalid,
1813 });
1814 assert_eq!(
1815 cluster_join(
1816 Extension(auth.clone()),
1817 Extension(MeshControlHandle(Some(invalid))),
1818 Json(req("aa01")),
1819 )
1820 .await
1821 .status(),
1822 StatusCode::FORBIDDEN
1823 );
1824 let revoked = Arc::new(StubControl {
1826 admits: std::sync::Mutex::new(Vec::new()),
1827 respond: StubJoin::Revoked,
1828 });
1829 assert_eq!(
1830 cluster_join(
1831 Extension(auth.clone()),
1832 Extension(MeshControlHandle(Some(revoked))),
1833 Json(req("aa01")),
1834 )
1835 .await
1836 .status(),
1837 StatusCode::FORBIDDEN
1838 );
1839
1840 let ok = Arc::new(StubControl {
1842 admits: std::sync::Mutex::new(Vec::new()),
1843 respond: StubJoin::Admit,
1844 });
1845 assert_eq!(
1846 cluster_join(
1847 Extension(auth.clone()),
1848 Extension(MeshControlHandle(Some(ok))),
1849 Json(req("not-hex")),
1850 )
1851 .await
1852 .status(),
1853 StatusCode::BAD_REQUEST
1854 );
1855
1856 let none = cluster_join(
1858 Extension(auth),
1859 Extension(MeshControlHandle(None)),
1860 Json(req("aa01")),
1861 )
1862 .await;
1863 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1864 }
1865
1866 #[tokio::test]
1870 async fn bootstrap_mints_the_first_token_once() {
1871 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1872 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1873 let public = keys.public_key();
1874 let deploy = DeployStore::new(
1875 Arc::new(MemStorage::default()),
1876 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1877 );
1878 let secret = "s3cr3t-bootstrap-value";
1879 let gate = BootstrapGate::new(Some(secret));
1880 let issuer = Issuer(Some(keys.clone()));
1881 let bearer = |s: &str| {
1882 let mut h = HeaderMap::new();
1883 h.insert(
1884 AUTHORIZATION,
1885 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1886 );
1887 h
1888 };
1889 let req = || BootstrapRequest {
1890 roles: vec!["admin".to_string()],
1891 ttl_secs: None,
1892 };
1893
1894 let bad = bootstrap_token(
1896 State(deploy.clone()),
1897 Extension(issuer.clone()),
1898 Extension(gate.clone()),
1899 bearer("wrong"),
1900 Json(req()),
1901 )
1902 .await;
1903 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1904
1905 let ok = bootstrap_token(
1907 State(deploy.clone()),
1908 Extension(issuer.clone()),
1909 Extension(gate.clone()),
1910 bearer(secret),
1911 Json(req()),
1912 )
1913 .await;
1914 assert_eq!(ok.status(), StatusCode::CREATED);
1915 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1916 .await
1917 .unwrap();
1918 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1919 let token = json["token"].as_str().unwrap();
1920 let id = json["id"].as_str().unwrap();
1921 let verified = cose::verify(token, &public, now_unix()).unwrap();
1922 assert!(verified.roles.iter().any(|r| r.name == "admin"));
1923 assert!(deploy
1924 .list_token_meta()
1925 .await
1926 .unwrap()
1927 .iter()
1928 .any(|m| m.revocation_id == id));
1929
1930 let reuse = bootstrap_token(
1932 State(deploy.clone()),
1933 Extension(issuer.clone()),
1934 Extension(gate),
1935 bearer(secret),
1936 Json(req()),
1937 )
1938 .await;
1939 assert_eq!(reuse.status(), StatusCode::CONFLICT);
1940
1941 let disabled = bootstrap_token(
1943 State(deploy),
1944 Extension(issuer),
1945 Extension(BootstrapGate(None)),
1946 bearer(secret),
1947 Json(req()),
1948 )
1949 .await;
1950 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1951 }
1952
1953 #[tokio::test]
1956 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1957 let control = Arc::new(StubControl {
1958 admits: std::sync::Mutex::new(Vec::new()),
1959 respond: StubJoin::Admit,
1960 });
1961 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1962 assert_eq!(resp.status(), StatusCode::OK);
1963 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1964 .await
1965 .unwrap();
1966 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1967 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1968
1969 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1970 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1971 }
1972
1973 #[test]
1974 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1975 use boatramp_core::security::SecurityProfile;
1976 let strict = SecurityProfile::MultiTenant.preset();
1977 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
1981 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1982 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1983
1984 assert!(gateway_addr_allowed(public, &strict));
1986 assert!(!gateway_addr_allowed(private, &strict));
1987 assert!(!gateway_addr_allowed(loopback, &strict));
1988 assert!(!gateway_addr_allowed(metadata, &strict));
1989
1990 assert!(gateway_addr_allowed(public, &loose));
1993 assert!(gateway_addr_allowed(private, &loose));
1994 assert!(gateway_addr_allowed(loopback, &loose));
1995 assert!(!gateway_addr_allowed(metadata, &loose));
1996 }
1997
1998 #[test]
1999 fn resolve_env_merges_static_and_host_secrets() {
2000 use boatramp_core::config::HandlersSiteConfig;
2001
2002 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2004
2005 let deploy_env = std::collections::BTreeMap::from([
2006 ("GREETING".to_string(), "hi".to_string()),
2007 ("OVERRIDE_ME".to_string(), "static".to_string()),
2008 ]);
2009 let site_handlers = HandlersSiteConfig {
2010 enabled: true,
2011 secrets: std::collections::BTreeMap::from([
2012 (
2014 "SECRET_TOKEN".to_string(),
2015 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2016 ),
2017 (
2018 "OVERRIDE_ME".to_string(),
2019 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2020 ),
2021 (
2022 "MISSING".to_string(),
2023 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2024 ),
2025 ]),
2026 ..Default::default()
2027 };
2028 let env = resolve_env("blog", &deploy_env, &site_handlers);
2029
2030 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2034 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2035 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2036 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2037
2038 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2039 }
2040
2041 fn req() -> Request {
2042 Request::builder()
2043 .uri("/")
2044 .header(header::HOST, "example.com")
2045 .body(Body::empty())
2046 .unwrap()
2047 }
2048
2049 #[test]
2050 fn forwarded_headers_set_standard_triple() {
2051 let mut request = req();
2052 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2053 let h = request.headers();
2054 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2055 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2056 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2057 }
2058
2059 #[test]
2060 fn forwarded_for_overwrites_spoofed_value() {
2061 let mut request = Request::builder()
2064 .uri("/")
2065 .header(header::HOST, "example.com")
2066 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2067 .body(Body::empty())
2068 .unwrap();
2069 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2070 let values: Vec<_> = request
2071 .headers()
2072 .get_all("x-forwarded-for")
2073 .iter()
2074 .collect();
2075 assert_eq!(values.len(), 1);
2076 assert_eq!(values[0], "203.0.113.7");
2077 }
2078
2079 #[test]
2080 fn forwarded_proto_preserves_upstream_tls() {
2081 let mut request = Request::builder()
2083 .uri("/")
2084 .header(header::HOST, "example.com")
2085 .header("x-forwarded-proto", "https")
2086 .body(Body::empty())
2087 .unwrap();
2088 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2089 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2090 }
2091
2092 #[test]
2093 fn forwarded_host_absent_when_no_host_header() {
2094 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2095 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2096 assert!(request.headers().get("x-forwarded-host").is_none());
2097 assert_eq!(
2098 request.headers().get("x-forwarded-for").unwrap(),
2099 "203.0.113.7"
2100 );
2101 }
2102
2103 use boatramp_core::kv::{KvStore, MemoryKv};
2106 use boatramp_core::messaging::{LogMessaging, Messaging};
2107 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2108
2109 const EVENT_CONSUMER: &[u8] =
2110 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2111
2112 #[derive(Default)]
2113 struct MemStorage {
2114 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2115 }
2116
2117 #[async_trait::async_trait]
2118 impl boatramp_core::Storage for MemStorage {
2119 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2120 let bytes = self
2121 .objects
2122 .lock()
2123 .unwrap()
2124 .get(key)
2125 .cloned()
2126 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2127 let body: ByteStream =
2128 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2129 Ok(GetObject {
2130 meta: ObjectMeta {
2131 key: key.to_string(),
2132 ..Default::default()
2133 },
2134 body,
2135 })
2136 }
2137 async fn get_range(
2138 &self,
2139 key: &str,
2140 _: u64,
2141 _: Option<u64>,
2142 ) -> Result<GetObject, StorageError> {
2143 self.get(key).await
2144 }
2145 async fn put(
2146 &self,
2147 key: &str,
2148 mut body: ByteStream,
2149 _: PutMeta,
2150 ) -> Result<ObjectMeta, StorageError> {
2151 use futures::StreamExt;
2152 let mut buf = Vec::new();
2153 while let Some(chunk) = body.next().await {
2154 buf.extend_from_slice(&chunk?);
2155 }
2156 self.objects.lock().unwrap().insert(key.to_string(), buf);
2157 Ok(ObjectMeta {
2158 key: key.to_string(),
2159 ..Default::default()
2160 })
2161 }
2162 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2163 self.objects
2164 .lock()
2165 .unwrap()
2166 .get(key)
2167 .map(|_| ObjectMeta {
2168 key: key.to_string(),
2169 ..Default::default()
2170 })
2171 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2172 }
2173 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2174 self.objects.lock().unwrap().remove(key);
2175 Ok(())
2176 }
2177 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2178 Ok(Vec::new())
2179 }
2180 }
2181
2182 fn observed_state(
2184 workload: &str,
2185 healthy: bool,
2186 phase: boatramp_core::compute::ReplicaPhase,
2187 ) -> boatramp_core::compute::ObservedInstance {
2188 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2189 boatramp_core::compute::ObservedInstance {
2190 handle: InstanceHandle {
2191 workload: workload.into(),
2192 replica: 0,
2193 backend_ref: "ref-0".into(),
2194 },
2195 node: 1,
2196 backend: "vmm".into(),
2197 endpoint: Endpoint {
2198 scheme: Scheme::Http,
2199 host: "10.0.0.2".into(),
2200 port: 80,
2201 },
2202 region: None,
2203 healthy,
2204 phase,
2205 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2206 workload: workload.into(),
2207 replica: 0,
2208 data_ref: "snap-0".into(),
2209 }),
2210 }
2211 }
2212
2213 #[tokio::test]
2214 async fn has_parked_replica_detects_a_zeroed_replica() {
2215 use boatramp_core::compute::ReplicaPhase;
2216 let storage = Arc::new(MemStorage::default());
2217 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2218 let deploy = DeployStore::new(storage, kv);
2219
2220 assert!(!has_parked_replica(&deploy, "w").await);
2222 deploy
2224 .set_replica_state(
2225 ProjectRef::DEFAULT,
2226 &observed_state("w", true, ReplicaPhase::Running),
2227 )
2228 .await
2229 .unwrap();
2230 assert!(!has_parked_replica(&deploy, "w").await);
2231 deploy
2233 .set_replica_state(
2234 ProjectRef::DEFAULT,
2235 &observed_state("w", false, ReplicaPhase::Zero),
2236 )
2237 .await
2238 .unwrap();
2239 assert!(has_parked_replica(&deploy, "w").await);
2240 }
2241
2242 #[tokio::test]
2243 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2244 use boatramp_core::compute::ReplicaPhase;
2245 let storage = Arc::new(MemStorage::default());
2246 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2247 let deploy = DeployStore::new(storage, kv);
2248
2249 let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2251 assert!(empty.is_empty());
2252
2253 deploy
2255 .set_replica_state(
2256 ProjectRef::DEFAULT,
2257 &observed_state("w", true, ReplicaPhase::Running),
2258 )
2259 .await
2260 .unwrap();
2261 let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2262 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2263 }
2264
2265 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2269 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2270 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2271 let storage = Arc::new(MemStorage::default());
2272 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2273 let mq = LogMessaging::new(storage, kv.clone());
2274 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2275 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2276 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2277 let topic = "blog/orders/created";
2278
2279 for _ in 0..3 {
2281 mq.publish(topic, b"ok").await.unwrap();
2282 }
2283 loop {
2284 let acked = dispatch_consumer_batch(
2285 &engine,
2286 &mq,
2287 &metrics::Metrics::default(),
2288 "blog",
2289 topic,
2290 "blog/",
2291 "",
2292 boatramp_core::messaging::StartPosition::Latest,
2293 &hash,
2294 EVENT_CONSUMER,
2295 &bindings,
2296 Limits::default(),
2297 Duration::from_secs(30),
2298 5,
2299 10,
2300 )
2301 .await;
2302 if acked == 0 {
2303 break;
2304 }
2305 }
2306 assert_eq!(
2307 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2308 Some(b"3".to_vec())
2309 );
2310
2311 mq.publish(topic, b"fail").await.unwrap();
2314 for _ in 0..5 {
2315 dispatch_consumer_batch(
2316 &engine,
2317 &mq,
2318 &metrics::Metrics::default(),
2319 "blog",
2320 topic,
2321 "blog/",
2322 "",
2323 boatramp_core::messaging::StartPosition::Latest,
2324 &hash,
2325 EVENT_CONSUMER,
2326 &bindings,
2327 Limits::default(),
2328 Duration::ZERO,
2329 2,
2330 10,
2331 )
2332 .await;
2333 }
2334 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2335 assert_eq!(
2337 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2338 Some(b"3".to_vec())
2339 );
2340 }
2341
2342 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2347 async fn consumer_groups_fan_out_through_the_dispatcher() {
2348 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2349 let storage = Arc::new(MemStorage::default());
2350 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2351 let mq = LogMessaging::new(storage, kv.clone());
2352 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2353 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2354 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2355 let topic = "blog/orders/created";
2356 let start = boatramp_core::messaging::StartPosition::Latest;
2357
2358 for g in ["billing", "audit"] {
2361 let n = dispatch_consumer_batch(
2362 &engine,
2363 &mq,
2364 &metrics::Metrics::default(),
2365 "blog",
2366 topic,
2367 "blog/",
2368 g,
2369 start,
2370 &hash,
2371 EVENT_CONSUMER,
2372 &bindings,
2373 Limits::default(),
2374 Duration::from_secs(30),
2375 5,
2376 10,
2377 )
2378 .await;
2379 assert_eq!(n, 0, "no events yet for group {g}");
2380 }
2381 mq.publish(topic, b"ok").await.unwrap();
2382
2383 for g in ["billing", "audit"] {
2385 let n = dispatch_consumer_batch(
2386 &engine,
2387 &mq,
2388 &metrics::Metrics::default(),
2389 "blog",
2390 topic,
2391 "blog/",
2392 g,
2393 start,
2394 &hash,
2395 EVENT_CONSUMER,
2396 &bindings,
2397 Limits::default(),
2398 Duration::from_secs(30),
2399 5,
2400 10,
2401 )
2402 .await;
2403 assert_eq!(n, 1, "group {g} should receive the message");
2404 }
2405 assert_eq!(
2407 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2408 Some(b"2".to_vec())
2409 );
2410 }
2411
2412 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2416 async fn scheduler_runs_current_consumers_not_previews() {
2417 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2418 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2419 use boatramp_handlers::{HandlerEngine, Limits};
2420 use futures::StreamExt;
2421
2422 let storage = Arc::new(MemStorage::default());
2423 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2424 let deploy = DeployStore::new(storage.clone(), kv.clone());
2425 let messaging: Arc<dyn Messaging> =
2426 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2427
2428 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2430 let stream: ByteStream =
2431 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2432 .boxed();
2433 deploy.put_blob(&hash, stream).await.unwrap();
2434 let mut files = std::collections::BTreeMap::new();
2435 files.insert(
2436 "consumer.wasm".to_string(),
2437 FileEntry {
2438 hash: hash.clone(),
2439 size: EVENT_CONSUMER.len() as u64,
2440 content_type: None,
2441 variants: std::collections::BTreeMap::new(),
2442 },
2443 );
2444 let manifest = Manifest {
2445 files,
2446 config: DeployConfig {
2447 consumers: vec![ConsumerConfig {
2448 topic: "orders/created".into(),
2449 component: "consumer.wasm".into(),
2450 imports: vec!["wasi:keyvalue".into()],
2451 group: String::new(),
2452 start: Default::default(),
2453 }],
2454 ..Default::default()
2455 },
2456 ..Default::default()
2457 };
2458 let id = deploy.put_manifest(&manifest).await.unwrap();
2459 deploy
2460 .activate(ProjectRef::DEFAULT, "blog", &id)
2461 .await
2462 .unwrap();
2463 deploy
2464 .set_site_config(
2465 ProjectRef::DEFAULT,
2466 "blog",
2467 &SiteConfig {
2468 handlers: Some(HandlersSiteConfig {
2469 enabled: true,
2470 allow_imports: vec!["wasi:keyvalue".into()],
2471 ..Default::default()
2472 }),
2473 ..Default::default()
2474 },
2475 )
2476 .await
2477 .unwrap();
2478
2479 messaging
2481 .publish("blog/orders/created", b"live")
2482 .await
2483 .unwrap();
2484 messaging
2485 .publish("blog/_preview/abc/orders/created", b"preview")
2486 .await
2487 .unwrap();
2488
2489 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2490 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2491 let inner = rt.inner.clone().unwrap();
2492 let mut cache = std::collections::HashMap::new();
2493 let mut crons = std::collections::HashMap::new();
2494 let mut sweep = std::collections::HashMap::new();
2495 let now = CronNow {
2496 minute: 0,
2497 hour: 0,
2498 dom: 1,
2499 month: 1,
2500 dow: 0,
2501 minute_stamp: 0,
2502 };
2503 for _ in 0..3 {
2504 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
2505 .await
2506 .unwrap();
2507 }
2508
2509 assert_eq!(
2511 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2512 Some(b"1".to_vec())
2513 );
2514 assert_eq!(
2517 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2518 .await
2519 .unwrap(),
2520 None
2521 );
2522 }
2523
2524 const KV_COUNTER: &[u8] =
2529 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2530
2531 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2536 async fn function_invoker_runs_target_buffers_and_meters() {
2537 use boatramp_core::deploy::DeployStore;
2538 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2539 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2540 use futures::StreamExt;
2541
2542 const HTTP_200: &[u8] =
2545 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2546
2547 let storage = Arc::new(MemStorage::default());
2548 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2549 let deploy = DeployStore::new(storage.clone(), kv.clone());
2550
2551 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2552 let stream: ByteStream =
2553 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2554 deploy.put_blob(&hash, stream).await.unwrap();
2555 let function = Function {
2556 name: "target".into(),
2557 owner: Owner::Project("default".into()),
2558 versions: vec![FunctionVersion {
2559 id: "v1".into(),
2560 component: hash.clone(),
2561 created: 0,
2562 lifecycle: Lifecycle::Independent,
2563 }],
2564 active: "v1".into(),
2565 aliases: Default::default(),
2566 config: Default::default(),
2567 };
2568 deploy
2569 .put_function(ProjectRef::DEFAULT, &function)
2570 .await
2571 .unwrap();
2572
2573 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2574 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2575 rt.set_invoker(deploy.clone());
2576 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2577
2578 let request = || InvokeRequest {
2579 method: "GET".into(),
2580 path: "/".into(),
2581 headers: vec![],
2582 body: vec![],
2583 };
2584
2585 let response = invoker.invoke("target", request(), 1).await.unwrap();
2587 assert_eq!(response.status, 200);
2588
2589 let metering = deploy
2591 .get_metering(ProjectRef::DEFAULT, "target")
2592 .await
2593 .unwrap()
2594 .unwrap();
2595 assert_eq!(metering.invocations, 1);
2596
2597 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2599 assert!(matches!(err, InvokeError::NotFound));
2600 }
2601
2602 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2608 async fn federation_runner_enforces_the_safelist_before_planning() {
2609 use boatramp_core::deploy::DeployStore;
2610 use boatramp_core::project::ProjectRef;
2611 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2612
2613 let storage = Arc::new(MemStorage::default());
2614 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2615 let deploy = DeployStore::new(storage.clone(), kv.clone());
2616 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2617 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2618 rt.set_invoker(deploy.clone());
2619 let runner = rt
2620 .inner
2621 .as_ref()
2622 .unwrap()
2623 .federation_runner
2624 .get()
2625 .unwrap()
2626 .scoped(ProjectRef::new("default"));
2627
2628 let req = |query: &str| GraphqlRequest {
2629 query: Some(query.to_string()),
2630 persisted_hash: None,
2631 variables: "{}".to_string(),
2632 operation_name: None,
2633 authorization: None,
2634 };
2635
2636 assert!(matches!(
2638 runner.run(req("{ me { id } }"), 1).await,
2639 Err(SupergraphRunError::NotSafelisted)
2640 ));
2641
2642 let query = "{ me { id } }";
2645 let hash = crate::graphql_apq::sha256_hex(query);
2646 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2647 .await
2648 .unwrap();
2649 assert!(matches!(
2650 runner.run(req(query), 1).await,
2651 Err(SupergraphRunError::PlanFailed(_))
2652 ));
2653
2654 let persisted = GraphqlRequest {
2656 query: None,
2657 persisted_hash: Some("deadbeef".to_string()),
2658 variables: "{}".to_string(),
2659 operation_name: None,
2660 authorization: None,
2661 };
2662 assert!(matches!(
2663 runner.run(persisted, 1).await,
2664 Err(SupergraphRunError::NotSafelisted)
2665 ));
2666 }
2667
2668 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2674 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2675 use crate::scheduler::{run_scheduler_tick, CronNow};
2676 use boatramp_core::deploy::DeployStore;
2677 use boatramp_core::function::{
2678 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2679 };
2680 use boatramp_handlers::{HandlerEngine, Limits};
2681 use futures::StreamExt;
2682
2683 const HTTP_200: &[u8] =
2684 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2685
2686 let storage = Arc::new(MemStorage::default());
2687 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2688 let deploy = DeployStore::new(storage.clone(), kv.clone());
2689
2690 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2691 let stream: ByteStream =
2692 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2693 deploy.put_blob(&hash, stream).await.unwrap();
2694
2695 let acme = ProjectRef::new("acme");
2697 let function = Function {
2698 name: "worker".into(),
2699 owner: Owner::Project("acme".into()),
2700 versions: vec![FunctionVersion {
2701 id: "v1".into(),
2702 component: hash.clone(),
2703 created: 0,
2704 lifecycle: Lifecycle::Independent,
2705 }],
2706 active: "v1".into(),
2707 aliases: Default::default(),
2708 config: Default::default(),
2709 };
2710 deploy.put_function(acme, &function).await.unwrap();
2711 let inv = Invocation {
2712 id: "inv1".into(),
2713 function: "worker".into(),
2714 version: "v1".into(),
2715 mode: InvokeMode::Async,
2716 status: InvocationStatus::Queued,
2717 idempotency_key: None,
2718 attempts: 0,
2719 lease_expires: None,
2720 request_b64: None,
2721 request_content_type: None,
2722 result: None,
2723 created: 0,
2724 updated: 0,
2725 };
2726 deploy.put_invocation(acme, &inv).await.unwrap();
2727
2728 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2729 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2730 let inner = rt.inner.as_ref().unwrap();
2731
2732 let mut wasm_cache = std::collections::HashMap::new();
2735 let mut cron_state = std::collections::HashMap::new();
2736 let mut sweep = std::collections::HashMap::new();
2737 let now = CronNow {
2738 minute: 0,
2739 hour: 0,
2740 dom: 1,
2741 month: 1,
2742 dow: 0,
2743 minute_stamp: 0,
2744 };
2745 run_scheduler_tick(
2746 inner,
2747 &deploy,
2748 &mut wasm_cache,
2749 &mut cron_state,
2750 &mut sweep,
2751 now,
2752 )
2753 .await
2754 .unwrap();
2755
2756 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
2759 assert_eq!(settled.status, InvocationStatus::Succeeded);
2761 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2763 assert_eq!(metering.invocations, 1);
2764 assert!(deploy
2766 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2767 .await
2768 .unwrap()
2769 .is_none());
2770 assert!(deploy
2771 .get_metering(ProjectRef::DEFAULT, "worker")
2772 .await
2773 .unwrap()
2774 .is_none());
2775 }
2776
2777 #[cfg(feature = "handlers")]
2781 async fn poll_invocation_settled(
2782 deploy: &boatramp_core::deploy::DeployStore,
2783 project: ProjectRef<'_>,
2784 function: &str,
2785 id: &str,
2786 ) -> boatramp_core::function::Invocation {
2787 use boatramp_core::function::InvocationStatus;
2788 for _ in 0..200 {
2789 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
2790 if matches!(
2791 inv.status,
2792 InvocationStatus::Succeeded | InvocationStatus::Failed
2793 ) {
2794 return inv;
2795 }
2796 }
2797 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2798 }
2799 panic!("invocation {function}/{id} never settled");
2800 }
2801
2802 #[cfg(feature = "handlers")]
2807 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2808 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
2809 use crate::scheduler::{run_scheduler_tick, CronNow};
2810 use boatramp_core::deploy::DeployStore;
2811 use boatramp_core::function::{
2812 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2813 };
2814 use boatramp_handlers::{HandlerEngine, Limits};
2815 use futures::StreamExt;
2816
2817 const HTTP_200: &[u8] =
2818 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2819
2820 let storage = Arc::new(MemStorage::default());
2821 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2822 let deploy = DeployStore::new(storage.clone(), kv.clone());
2823 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2824 let stream: ByteStream =
2825 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2826 deploy.put_blob(&hash, stream).await.unwrap();
2827
2828 let function = Function {
2829 name: "worker".into(),
2830 owner: Owner::Project("default".into()),
2831 versions: vec![FunctionVersion {
2832 id: "v1".into(),
2833 component: hash.clone(),
2834 created: 0,
2835 lifecycle: Lifecycle::Independent,
2836 }],
2837 active: "v1".into(),
2838 aliases: Default::default(),
2839 config: Default::default(),
2840 };
2841 deploy
2842 .put_function(ProjectRef::DEFAULT, &function)
2843 .await
2844 .unwrap();
2845
2846 let base = Invocation {
2849 id: String::new(),
2850 function: "worker".into(),
2851 version: "v1".into(),
2852 mode: InvokeMode::Async,
2853 status: InvocationStatus::Running,
2854 idempotency_key: None,
2855 attempts: 1,
2856 lease_expires: None,
2857 request_b64: None,
2858 request_content_type: None,
2859 result: None,
2860 created: 0,
2861 updated: 0,
2862 };
2863 let orphan = Invocation {
2864 id: "orphan".into(),
2865 lease_expires: Some(1),
2866 ..base.clone()
2867 };
2868 deploy
2869 .put_invocation(ProjectRef::DEFAULT, &orphan)
2870 .await
2871 .unwrap();
2872 let live = Invocation {
2873 id: "live".into(),
2874 lease_expires: Some(u64::MAX),
2875 ..base.clone()
2876 };
2877 deploy
2878 .put_invocation(ProjectRef::DEFAULT, &live)
2879 .await
2880 .unwrap();
2881
2882 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2883 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2884 let inner = rt.inner.as_ref().unwrap();
2885
2886 let now = CronNow {
2887 minute: 0,
2888 hour: 0,
2889 dom: 1,
2890 month: 1,
2891 dow: 0,
2892 minute_stamp: 0,
2893 };
2894 let mut wasm_cache = std::collections::HashMap::new();
2895 let mut cron_state = std::collections::HashMap::new();
2896 let mut sweep = std::collections::HashMap::new();
2897 run_scheduler_tick(
2898 inner,
2899 &deploy,
2900 &mut wasm_cache,
2901 &mut cron_state,
2902 &mut sweep,
2903 now,
2904 )
2905 .await
2906 .unwrap();
2907
2908 let settled =
2910 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
2911 assert_eq!(settled.status, InvocationStatus::Succeeded);
2912 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
2913 assert_eq!(
2914 settled.lease_expires, None,
2915 "a settled invocation drops its lease"
2916 );
2917 let live_after = deploy
2919 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
2920 .await
2921 .unwrap()
2922 .unwrap();
2923 assert_eq!(live_after.status, InvocationStatus::Running);
2924 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
2925 assert_eq!(live_after.lease_expires, Some(u64::MAX));
2926 }
2927
2928 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2943 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2944 use boatramp_core::deploy::DeployStore;
2945 use boatramp_core::function::{
2946 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2947 };
2948 use boatramp_handlers::{HandlerEngine, Limits};
2949 use futures::StreamExt;
2950
2951 let storage = Arc::new(MemStorage::default());
2952 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2953 let deploy = DeployStore::new(storage.clone(), kv.clone());
2954
2955 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2958 let stream: ByteStream =
2959 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2960 deploy.put_blob(&hash, stream).await.unwrap();
2961
2962 let store = Function {
2967 name: "store".into(),
2968 owner: Owner::Project("default".into()),
2969 versions: vec![FunctionVersion {
2970 id: "v1".into(),
2971 component: hash.clone(),
2972 created: 0,
2973 lifecycle: Lifecycle::Independent,
2974 }],
2975 active: "v1".into(),
2976 aliases: Default::default(),
2977 config: FunctionConfig {
2978 imports: vec!["wasi:keyvalue".into()],
2979 ..Default::default()
2980 },
2981 };
2982 let acme = ProjectRef::new("acme");
2983 let globex = ProjectRef::new("globex");
2984
2985 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2986 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2987 let inner = rt.inner.as_ref().unwrap();
2988
2989 let request = || {
2990 axum::http::Request::builder()
2991 .method("GET")
2992 .uri("/")
2993 .body(axum::body::Body::empty())
2994 .unwrap()
2995 };
2996
2997 let component = store.resolve(&store.active).unwrap().to_owned();
3000 for project in [acme, globex, ProjectRef::DEFAULT] {
3001 let (response, _) = execute_function(
3002 inner,
3003 &deploy,
3004 project,
3005 &store,
3006 &component,
3007 request(),
3008 0,
3009 boatramp_handlers::Lane::Sync,
3010 )
3011 .await;
3012 assert!(response.status().is_success(), "invocation should succeed");
3013 }
3014
3015 assert_eq!(
3018 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3019 Some(b"1".to_vec()),
3020 "acme's write must be tenant-qualified"
3021 );
3022 assert_eq!(
3023 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3024 Some(b"1".to_vec()),
3025 "globex's write must be tenant-qualified"
3026 );
3027 assert_eq!(
3028 kv.get("hkv/fn/store/hits").await.unwrap(),
3029 Some(b"1".to_vec()),
3030 "the default project must keep the byte-identical pre-project key"
3031 );
3032 }
3035
3036 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3040 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3041 use boatramp_core::config::{
3042 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3043 };
3044 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3045 use boatramp_handlers::{HandlerEngine, Limits};
3046 use futures::StreamExt;
3047 use std::sync::atomic::Ordering;
3048
3049 let storage = Arc::new(MemStorage::default());
3050 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3051 let deploy = DeployStore::new(storage.clone(), kv.clone());
3052
3053 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3054 let stream: ByteStream =
3055 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3056 deploy.put_blob(&hash, stream).await.unwrap();
3057 let mut files = std::collections::BTreeMap::new();
3058 files.insert(
3059 "counter.wasm".to_string(),
3060 FileEntry {
3061 hash: hash.clone(),
3062 size: KV_COUNTER.len() as u64,
3063 content_type: None,
3064 variants: std::collections::BTreeMap::new(),
3065 },
3066 );
3067 let manifest = Manifest {
3068 files,
3069 config: DeployConfig {
3070 handlers: vec![HandlerConfig {
3071 route: "/".into(),
3072 methods: Vec::new(),
3073 component: "counter.wasm".into(),
3074 imports: vec!["wasi:keyvalue".into()],
3075 limits: None,
3076 env: std::collections::BTreeMap::new(),
3077 invoke_targets: Vec::new(),
3078 }],
3079 crons: vec![CronConfig {
3080 schedule: "* * * * *".into(),
3081 route: "/".into(),
3082 overlap: Overlap::Skip,
3083 }],
3084 ..Default::default()
3085 },
3086 ..Default::default()
3087 };
3088 let id = deploy.put_manifest(&manifest).await.unwrap();
3089 deploy
3090 .activate(ProjectRef::DEFAULT, "blog", &id)
3091 .await
3092 .unwrap();
3093 deploy
3094 .set_site_config(
3095 ProjectRef::DEFAULT,
3096 "blog",
3097 &SiteConfig {
3098 handlers: Some(HandlersSiteConfig {
3099 enabled: true,
3100 allow_imports: vec!["wasi:keyvalue".into()],
3101 ..Default::default()
3102 }),
3103 ..Default::default()
3104 },
3105 )
3106 .await
3107 .unwrap();
3108
3109 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3110 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3111 let inner = rt.inner.clone().unwrap();
3112 let mut wasm = std::collections::HashMap::new();
3113 let mut crons = std::collections::HashMap::new();
3114 let mut sweep = std::collections::HashMap::new();
3115 let at = |stamp| CronNow {
3116 minute: 0,
3117 hour: 0,
3118 dom: 1,
3119 month: 1,
3120 dow: 0,
3121 minute_stamp: stamp,
3122 };
3123
3124 let (_, handles) =
3126 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3127 .await
3128 .unwrap();
3129 for h in handles {
3130 h.await.unwrap();
3131 }
3132 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3133
3134 let (_, handles) =
3136 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3137 .await
3138 .unwrap();
3139 assert!(handles.is_empty());
3140 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3141
3142 let (_, handles) =
3144 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3145 .await
3146 .unwrap();
3147 for h in handles {
3148 h.await.unwrap();
3149 }
3150 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3151
3152 crons
3156 .get("default|blog|cron|0")
3157 .unwrap()
3158 .running
3159 .store(true, Ordering::Release);
3160 let (_, handles) =
3161 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3162 .await
3163 .unwrap();
3164 assert!(handles.is_empty());
3165 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3166 }
3167
3168 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3172 async fn cron_leader_gate_suppresses_crons_off_leader() {
3173 use boatramp_core::config::{
3174 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3175 };
3176 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3177 use boatramp_handlers::{HandlerEngine, Limits};
3178 use futures::StreamExt;
3179
3180 let storage = Arc::new(MemStorage::default());
3181 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3182 let deploy = DeployStore::new(storage.clone(), kv.clone());
3183
3184 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3185 let stream: ByteStream =
3186 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3187 deploy.put_blob(&hash, stream).await.unwrap();
3188 let mut files = std::collections::BTreeMap::new();
3189 files.insert(
3190 "counter.wasm".to_string(),
3191 FileEntry {
3192 hash: hash.clone(),
3193 size: KV_COUNTER.len() as u64,
3194 content_type: None,
3195 variants: std::collections::BTreeMap::new(),
3196 },
3197 );
3198 let manifest = Manifest {
3199 files,
3200 config: DeployConfig {
3201 handlers: vec![HandlerConfig {
3202 route: "/".into(),
3203 methods: Vec::new(),
3204 component: "counter.wasm".into(),
3205 imports: vec!["wasi:keyvalue".into()],
3206 limits: None,
3207 env: std::collections::BTreeMap::new(),
3208 invoke_targets: Vec::new(),
3209 }],
3210 crons: vec![CronConfig {
3211 schedule: "* * * * *".into(),
3212 route: "/".into(),
3213 overlap: Overlap::Skip,
3214 }],
3215 ..Default::default()
3216 },
3217 ..Default::default()
3218 };
3219 let id = deploy.put_manifest(&manifest).await.unwrap();
3220 deploy
3221 .activate(ProjectRef::DEFAULT, "blog", &id)
3222 .await
3223 .unwrap();
3224 deploy
3225 .set_site_config(
3226 ProjectRef::DEFAULT,
3227 "blog",
3228 &SiteConfig {
3229 handlers: Some(HandlersSiteConfig {
3230 enabled: true,
3231 allow_imports: vec!["wasi:keyvalue".into()],
3232 ..Default::default()
3233 }),
3234 ..Default::default()
3235 },
3236 )
3237 .await
3238 .unwrap();
3239
3240 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3241 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3242 rt.set_cron_leader_gate(Arc::new(|| false));
3244 let inner = rt.inner.clone().unwrap();
3245 let mut wasm = std::collections::HashMap::new();
3246 let mut crons = std::collections::HashMap::new();
3247 let mut sweep = std::collections::HashMap::new();
3248 let now = CronNow {
3249 minute: 0,
3250 hour: 0,
3251 dom: 1,
3252 month: 1,
3253 dow: 0,
3254 minute_stamp: 100,
3255 };
3256
3257 let (_, handles) =
3258 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3259 .await
3260 .unwrap();
3261 assert!(handles.is_empty(), "a non-leader must not fire crons");
3263 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3264 }
3265
3266 #[tokio::test]
3273 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3274 use boatramp_core::config::HandlersSiteConfig;
3275 use boatramp_core::project::ProjectRef;
3276 use boatramp_handlers::{HandlerEngine, Limits};
3277
3278 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3279 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3280 let sql_dir =
3282 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3283 let _ = std::fs::remove_dir_all(&sql_dir);
3284 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3285 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3286
3287 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3288 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3289 let inner = rt.inner.as_ref().unwrap();
3290
3291 let site = HandlersSiteConfig {
3293 enabled: true,
3294 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3295 ..Default::default()
3296 };
3297 let env = std::collections::BTreeMap::new();
3298 let build = |imports: &[&str]| {
3299 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3300 let site = &site;
3301 let env = &env;
3302 async move {
3303 crate::handler_dispatch::build_bindings(
3304 inner,
3305 ProjectRef::new("default"),
3306 "shop",
3307 "shop",
3308 None,
3309 &imports,
3310 site,
3311 env,
3312 &[],
3313 0,
3314 None,
3315 )
3316 .await
3317 .sql_database_names()
3318 }
3319 };
3320
3321 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3324 assert_eq!(
3326 build(&["sql", "sql:*"]).await,
3327 vec!["", "privileged", "product"]
3328 );
3329 assert!(build(&["sql:secret"]).await.is_empty());
3331 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3333
3334 let _ = std::fs::remove_dir_all(&sql_dir);
3335 }
3336}