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, compute_exec, create_deployment, current_deployment,
44 delete_compute, delete_compute_volume, delete_site, get_compute, get_daemon_config,
45 get_deployment, get_site_config, invalidate_cache, list_aliases, list_compute,
46 list_compute_volumes, list_deployments, list_sites, prune_delete, prune_report, put_blob,
47 put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
48 scrub_blobs, set_alias, sql_exec, sql_query,
49};
50#[cfg(feature = "handlers")]
51pub(crate) use admin_api::{
52 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
53 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
54 put_graphql_subgraph, register_graphql_safelist,
55};
56mod auth;
57#[cfg(feature = "console")]
58pub mod console;
59mod content;
60mod control_api;
61#[cfg(feature = "compression")]
62pub(crate) use content::maybe_compress;
63pub(crate) use content::multipart_byteranges;
64pub(crate) use content::{
65 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
66};
67pub(crate) use control_api::{
68 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
69 cluster_revoke, cluster_rotate_key, create_join_token, create_token, delete_secret,
70 get_authz_policy, list_root_anchors, list_secrets, list_tokens, put_authz_policy,
71 remove_root_anchor, revoke_token, set_secret,
72};
73#[cfg(all(test, feature = "handlers"))]
74use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
75mod domain_verify;
76pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
77pub mod envelope;
78#[cfg(feature = "handlers")]
79mod graphql_apq;
80#[cfg(feature = "handlers")]
81mod graphql_cache;
82#[cfg(feature = "handlers")]
83mod graphql_data;
84#[cfg(feature = "handlers")]
85mod graphql_federation;
86#[cfg(feature = "handlers")]
87mod graphql_gateway;
88#[cfg(feature = "handlers")]
89mod graphql_graphiql;
90#[cfg(feature = "handlers")]
91mod graphql_guard;
92#[cfg(feature = "handlers")]
93mod graphql_plan;
94#[cfg(feature = "handlers")]
95mod graphql_registry;
96#[cfg(feature = "handlers")]
97mod graphql_subscription;
98#[cfg(feature = "handlers")]
99mod handler_cache;
100#[cfg(feature = "handlers")]
101mod handler_dispatch;
102#[cfg(feature = "handlers")]
103pub(crate) use handler_dispatch::{
104 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
105 read_blob_fully, resolve_secret_env,
106};
107#[cfg(all(feature = "handlers", test))]
108use handler_dispatch::{resolve_env, set_forwarded_headers};
109mod function_api;
110pub(crate) use function_api::{
111 alias_function, deploy_function, list_functions, remove_function, rollback_function,
112};
113#[cfg(feature = "handlers")]
118pub use function_api::{
119 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
120};
121#[cfg(all(test, feature = "handlers"))]
122use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
123pub use function_api::{CapabilityFeature, Lifecycle};
126mod gateway;
127mod host;
128pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
129#[cfg(feature = "http3")]
130mod http3;
131mod limits;
132#[cfg(feature = "handlers")]
133mod logs;
134#[cfg(feature = "handlers")]
135mod metrics;
136#[cfg(feature = "oidc")]
137mod oidc;
138mod operator;
139pub(crate) use operator::prometheus_metrics;
140#[cfg(feature = "handlers")]
141pub(crate) use operator::{
142 operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
143};
144mod proxy;
145pub use proxy::spawn_compute_reconcile;
146pub(crate) use proxy::{
147 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
148 proxy, COMPUTE_WAKE_TIMEOUT,
149};
150mod splice;
151mod http_serve;
154pub use http_serve::{
155 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
156 serve_tls_listener, ReloadableTls, ServeInput,
157};
158#[cfg(feature = "handlers")]
160pub(crate) use proxy::is_upgrade_request;
161#[cfg(all(test, feature = "handlers"))]
162use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
163mod project_api;
164pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
165mod project_scope;
166pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
167mod ratelimit;
168mod routes;
169pub use routes::{router, router_with, router_with_fast};
170#[cfg(feature = "mcp")]
171mod mcp_http;
172#[cfg(feature = "handlers")]
173mod scheduler;
174mod serve_pipeline;
175pub use serve_pipeline::{http_redirect_router, FastServe};
176#[cfg(test)]
177mod hotpath_test;
178#[cfg(all(test, feature = "handlers"))]
179use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
180pub(crate) use serve_pipeline::{
181 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
182 BootstrapAttestation,
183};
184pub mod signer;
187mod srvmetrics;
188#[cfg(all(feature = "handlers", test))]
189use scheduler::run_scheduler_tick;
190#[cfg(feature = "handlers")]
191pub(crate) use scheduler::{
192 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
193};
194#[cfg(feature = "handlers")]
195use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
196#[cfg(feature = "handlers")]
197mod function_runtime;
198#[cfg(feature = "handlers")]
199pub(crate) use function_runtime::{
200 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
201 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
202 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
203 put_trigger_handler, webhook_ingress,
204};
205#[cfg(feature = "handlers")]
206mod stream;
207#[cfg(feature = "handlers")]
208mod workflow;
209pub use auth::{require_auth, Auth};
210#[cfg(feature = "http3")]
211pub use http3::{
212 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
213 Http3Error,
214};
215pub use limits::{ServerLimits, UploadGuard};
216#[cfg(feature = "oidc")]
217pub use oidc::{OidcConfig, OidcError, OidcVerifier};
218use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
219#[cfg(feature = "handlers")]
220pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
221#[cfg(feature = "handlers")]
222pub(crate) use workflow::{
223 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
224 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
225};
226pub use srvmetrics::{server_metrics, ServerMetrics};
229
230#[derive(Clone, Default)]
235pub struct HandlerRuntime {
236 #[cfg(feature = "handlers")]
237 inner: Option<Arc<HandlerRuntimeInner>>,
238}
239
240#[cfg(feature = "handlers")]
241struct HandlerRuntimeInner {
242 engine: boatramp_handlers::HandlerEngine,
243 async_drain_gate: Arc<tokio::sync::Semaphore>,
249 kv: Arc<dyn boatramp_core::kv::KvStore>,
250 storage: Arc<dyn boatramp_core::Storage>,
251 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
255 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
258 site_semaphores:
261 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
262 stream_semaphores:
266 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
267 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
270 metrics: metrics::Metrics,
273 logs: Arc<logs::LogStore>,
275 #[cfg(feature = "handlers")]
279 graphql_cache: graphql_cache::GraphqlCache,
280 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
286 max_blob_bytes: std::sync::OnceLock<u64>,
290 max_component_bytes: std::sync::OnceLock<u64>,
295 allow_env_secret_refs: std::sync::OnceLock<bool>,
304 secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
310 function_meter_locks:
314 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
315 function_semaphores:
318 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
319 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
324 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
328 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
336 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
341}
342
343pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
346
347impl HandlerRuntime {
348 pub fn disabled() -> Self {
350 Self::default()
351 }
352
353 #[cfg(feature = "handlers")]
359 pub fn new(
360 engine: boatramp_handlers::HandlerEngine,
361 kv: Arc<dyn boatramp_core::kv::KvStore>,
362 storage: Arc<dyn boatramp_core::Storage>,
363 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
364 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
365 ) -> Self {
366 let async_drain_slots = engine.async_max_concurrency().max(1);
369 Self {
370 inner: Some(Arc::new(HandlerRuntimeInner {
371 engine,
372 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
373 kv,
374 storage,
375 sql,
376 messaging,
377 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
378 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
379 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
380 metrics: metrics::Metrics::default(),
381 logs: Arc::new(logs::LogStore::default()),
382 #[cfg(feature = "handlers")]
383 graphql_cache: graphql_cache::GraphqlCache::default(),
384 cron_leader_gate: std::sync::OnceLock::new(),
385 max_blob_bytes: std::sync::OnceLock::new(),
386 max_component_bytes: std::sync::OnceLock::new(),
387 allow_env_secret_refs: std::sync::OnceLock::new(),
388 secret_store: std::sync::OnceLock::new(),
389 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
390 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
391 watch_provider: std::sync::OnceLock::new(),
392 provision_tier: std::sync::OnceLock::new(),
393 invoker: std::sync::OnceLock::new(),
394 federation_runner: std::sync::OnceLock::new(),
395 })),
396 }
397 }
398
399 #[cfg(feature = "handlers")]
402 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
403 self.inner.as_ref().and_then(|inner| inner.sql.clone())
404 }
405
406 #[cfg(feature = "handlers")]
410 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
411 self.inner
412 .as_ref()
413 .and_then(|inner| inner.invoker.get().cloned())
414 }
415
416 #[cfg(feature = "handlers")]
420 pub(crate) async fn introspect_subgraph_sdl(
421 &self,
422 deploy: &DeployStore,
423 project: boatramp_core::project::ProjectRef<'_>,
424 function: &boatramp_core::function::Function,
425 component: &str,
426 ) -> Result<String, function_runtime::SubgraphSdlError> {
427 match self.inner.as_ref() {
428 Some(inner) => {
429 function_runtime::introspect_service_sdl(
430 inner, deploy, project, function, component,
431 )
432 .await
433 }
434 None => Err(function_runtime::SubgraphSdlError::Unavailable),
435 }
436 }
437
438 #[cfg(feature = "handlers")]
444 pub fn set_invoker(&self, deploy: DeployStore) {
445 if let Some(inner) = self.inner.as_ref() {
446 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
447 deploy,
448 Arc::downgrade(inner),
449 ));
450 let _ = inner.invoker.set(invoker);
451 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
454 inner,
455 )));
456 let _ = inner.federation_runner.set(runner);
457 }
458 }
459
460 #[cfg(feature = "handlers")]
464 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
465 self.inner.as_ref().and_then(|inner| inner.sql.clone())
466 }
467
468 #[cfg(feature = "handlers")]
472 pub fn set_watch_provider(
473 &self,
474 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
475 ) {
476 if let Some(inner) = self.inner.as_ref() {
477 let _ = inner.watch_provider.set(provider);
478 }
479 }
480
481 #[cfg(feature = "handlers")]
485 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
486 if let Some(inner) = self.inner.as_ref() {
487 let _ = inner.provision_tier.set(tier);
488 }
489 }
490
491 #[cfg(feature = "handlers")]
495 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
496 if let Some(inner) = self.inner.as_ref() {
497 let _ = inner.max_blob_bytes.set(max_bytes);
498 }
499 }
500
501 #[cfg(feature = "handlers")]
504 pub fn set_max_component_bytes(&self, max_bytes: u64) {
505 if let Some(inner) = self.inner.as_ref() {
506 let _ = inner.max_component_bytes.set(max_bytes);
507 }
508 }
509
510 #[cfg(feature = "handlers")]
518 pub fn set_allow_env_secret_refs(&self, allow: bool) {
519 if let Some(inner) = self.inner.as_ref() {
520 let _ = inner.allow_env_secret_refs.set(allow);
521 }
522 }
523
524 #[cfg(feature = "handlers")]
529 pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
530 if let Some(inner) = self.inner.as_ref() {
531 let _ = inner.secret_store.set(store);
532 }
533 }
534
535 #[cfg(feature = "handlers")]
540 pub(crate) fn allow_env_secret_refs(&self) -> bool {
541 self.inner
542 .as_ref()
543 .and_then(|inner| inner.allow_env_secret_refs.get().copied())
544 .unwrap_or(false)
545 }
546
547 #[cfg(feature = "handlers")]
552 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
553 if let Some(inner) = self.inner.as_ref() {
554 let _ = inner.cron_leader_gate.set(gate);
555 }
556 }
557
558 #[cfg(feature = "handlers")]
565 async fn precheck_activation(
566 &self,
567 deploy: &DeployStore,
568 manifest: &Manifest,
569 site_config: Option<&SiteConfig>,
570 ) -> Result<(), String> {
571 let Some(inner) = self.inner.as_ref() else {
572 return Ok(());
573 };
574 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
577 return Ok(());
578 }
579 let site_handlers = site_config
581 .and_then(|c| c.handlers.as_ref())
582 .filter(|h| h.enabled)
583 .ok_or_else(|| {
584 "deployment ships handlers/consumers but the site has them disabled".to_string()
585 })?;
586 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
587
588 let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
595 crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
596 .map_err(|err| format!("handler secrets: {err}"))?;
597
598 let sync_ceiling = inner.engine.sync_timeout_ms();
604 let async_ceiling = inner.engine.async_timeout_ms();
605 if let Some(ms) = site_handlers.max_timeout_ms {
606 if u64::from(ms) > sync_ceiling {
607 tracing::warn!(
608 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
609 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
610 applies only to async calls (?mode=async / triggers), capped at \
611 async_max_timeout_ms={async_ceiling}"
612 );
613 }
614 }
615
616 for handler in &manifest.config.handlers {
618 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
619 if u64::from(ms) > sync_ceiling {
620 let route = &handler.route;
621 tracing::warn!(
622 "route {route:?} declares limits.timeout_ms={ms}, above \
623 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
624 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
625 (?mode=async / a queue trigger / a #[consumer]), capped at \
626 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
627 that isn't possible — move the work to the async lane"
628 );
629 }
630 }
631 if !handler.streaming {
636 if let Some(entry) = manifest.files.get(&handler.component) {
637 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
638 if crate::function_api::component_declares_streaming_route(
639 &bytes,
640 &handler.route,
641 ) {
642 let route = &handler.route;
643 tracing::warn!(
644 "route {route:?} is a streaming handler (#[handler(stream)]) but \
645 its config lacks streaming = true: it will run on the sync request \
646 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
647 streaming = true so it serves on the dedicated streaming lane (its \
648 own concurrency budget + a much larger wall-clock)."
649 );
650 }
651 }
652 }
653 }
654 if let Some(entry) = manifest.files.get(&handler.component) {
658 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
659 let unmet = crate::function_api::unmet_requires(&bytes);
660 if !unmet.is_empty() {
661 return Err(format!(
662 "route {:?} [{}] requires capabilities this host does not implement: \
663 {}. Upgrade boatramp or enable those features — see `boatramp \
664 capabilities`.",
665 handler.route,
666 handler.methods.join(","),
667 unmet.join(", ")
668 ));
669 }
670 }
671 }
672 precheck_component(
673 deploy,
674 manifest,
675 site_handlers,
676 inner,
677 max_component,
678 &handler.imports,
679 &handler.component,
680 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
683 false,
684 )
685 .await?;
686 }
687 for consumer in &manifest.config.consumers {
688 precheck_component(
689 deploy,
690 manifest,
691 site_handlers,
692 inner,
693 max_component,
694 &consumer.imports,
695 &consumer.component,
696 &format!("consumer {:?}", consumer.topic),
697 true,
698 )
699 .await?;
700 }
701 Ok(())
702 }
703
704 #[cfg(not(feature = "handlers"))]
705 async fn precheck_activation(
706 &self,
707 _deploy: &DeployStore,
708 _manifest: &Manifest,
709 _site_config: Option<&SiteConfig>,
710 ) -> Result<(), String> {
711 Ok(())
712 }
713}
714
715#[derive(Default, Clone)]
721pub struct ServerOptions {
722 pub limits: ServerLimits,
724 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
727 pub default_site: Option<String>,
730 pub implicit_routing: bool,
736 pub protect_previews: bool,
741 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
745 pub issuer: Option<Arc<dyn Signer>>,
749 pub bootstrap_secret: Option<String>,
754 pub bootstrap_attestation: Option<String>,
760 pub mesh_control: Option<Arc<dyn MeshControl>>,
764 pub cors_allowed_origins: Vec<String>,
774 #[cfg(feature = "oidc")]
777 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
778 pub posture: boatramp_core::security::SecurityPosture,
782 pub served_over_tls: bool,
787 pub pop_origin: Option<String>,
794 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
798 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
802 pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
807 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
811 pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
816 pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
824 #[cfg(feature = "console")]
828 pub console: Option<console::ConsoleMount>,
829}
830
831#[derive(Clone, Copy)]
835struct ServedOverTls(bool);
836
837#[derive(Clone, Copy, Default)]
842struct ImplicitRouting(bool);
843
844const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
856
857pub struct DaemonRuntime {
858 baseline: boatramp_core::daemon_config::ConfigBaseline,
859 state: std::sync::RwLock<DaemonState>,
860 reload: tokio::sync::Notify,
863}
864
865struct DaemonState {
866 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
867 generation: Option<String>,
868}
869
870pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
875 #[cfg(feature = "console")]
879 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
880 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
881 None => (false, None, None),
882 };
883 #[cfg(not(feature = "console"))]
884 let (console_enabled, console_host, console_path) = (false, None, None);
885 boatramp_core::daemon_config::ConfigBaseline {
886 default_site: options.default_site.clone(),
887 protect_previews: options.protect_previews,
888 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
889 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
890 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
891 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
892 compute_vcpus: 0,
893 compute_mem_mib: 0,
894 console_enabled,
895 console_host,
896 console_path,
897 max_upload_ceiling: options.posture.max_upload_bytes,
898 max_concurrent_uploads_ceiling: None,
899 posture: options.posture,
900 }
901}
902
903impl DaemonRuntime {
904 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
908 let effective =
909 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
910 Self {
911 baseline,
912 state: std::sync::RwLock::new(DaemonState {
913 effective,
914 generation: None,
915 }),
916 reload: tokio::sync::Notify::new(),
917 }
918 }
919
920 pub fn notify_reload(&self) {
924 self.reload.notify_one();
925 }
926
927 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
929 self.state
930 .read()
931 .expect("daemon config lock")
932 .effective
933 .clone()
934 }
935
936 pub fn generation(&self) -> Option<String> {
939 self.state
940 .read()
941 .expect("daemon config lock")
942 .generation
943 .clone()
944 }
945
946 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
948 &self.baseline
949 }
950
951 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
954 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
955 let generation = deploy.daemon_config_generation().await?;
956 let effective = Arc::new(cfg.resolve(&self.baseline));
957 *self.state.write().expect("daemon config lock") = DaemonState {
958 effective,
959 generation,
960 };
961 Ok(())
962 }
963}
964
965#[derive(Clone, Copy, Default)]
968struct PreviewPolicy {
969 protect: bool,
970}
971
972#[derive(Clone, Default)]
977struct Issuer(Option<Arc<dyn Signer>>);
978
979#[derive(Clone, Default)]
984struct BootstrapGate(Option<Arc<BootstrapInner>>);
985
986struct BootstrapInner {
987 secret_hash: String,
990 lock: tokio::sync::Mutex<()>,
993}
994
995impl BootstrapGate {
996 fn new(secret: Option<&str>) -> Self {
997 Self(secret.filter(|s| !s.is_empty()).map(|s| {
998 Arc::new(BootstrapInner {
999 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1000 lock: tokio::sync::Mutex::new(()),
1001 })
1002 }))
1003 }
1004}
1005
1006#[async_trait::async_trait]
1010pub trait MeshControl: Send + Sync {
1011 async fn admit(
1019 &self,
1020 mesh_pubkey_hex: &str,
1021 jti: &str,
1022 possession_proof: &[u8],
1023 proof_iat: u64,
1024 now: u64,
1025 advertise_addr: Option<&str>,
1026 ) -> Result<JoinOutcome, String>;
1027
1028 async fn rotate_key(&self) -> Result<String, String>;
1032
1033 async fn revoke(&self, node: u64) -> Result<(), String>;
1037
1038 async fn members(&self) -> Result<Vec<MeshMember>, String>;
1042
1043 async fn promote(&self, node: u64) -> Result<(), String>;
1046}
1047
1048pub enum JoinOutcome {
1050 Admitted {
1053 members: Vec<String>,
1055 addrs: std::collections::BTreeMap<u64, String>,
1057 },
1058 TokenSpent,
1060 ProofInvalid,
1062 Revoked,
1065}
1066
1067#[derive(Debug, Clone, Serialize)]
1069pub struct MeshMember {
1070 pub node: u64,
1072 pub voter: bool,
1074 pub caught_up: bool,
1076 pub leader: bool,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub addr: Option<String>,
1083}
1084
1085#[derive(Clone, Default)]
1088struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1089
1090#[cfg(feature = "oidc")]
1092#[derive(Clone, Default)]
1093struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1094
1095#[cfg(feature = "oidc")]
1098const EXCHANGE_TTL_SECS: u64 = 3600;
1099
1100use boatramp_core::time::now_unix;
1101
1102#[derive(Clone)]
1104struct CorsState(Arc<Vec<String>>);
1105
1106const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1108const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1111const CORS_MAX_AGE: &str = "600";
1113
1114fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1118 allowed.iter().any(|a| a == "*" || a == origin)
1119}
1120
1121async fn cors(
1128 State(allowed): State<CorsState>,
1129 request: Request,
1130 next: axum::middleware::Next,
1131) -> Response {
1132 let origin = request
1133 .headers()
1134 .get(header::ORIGIN)
1135 .and_then(|v| v.to_str().ok())
1136 .filter(|o| cors_origin_allowed(&allowed.0, o))
1137 .map(str::to_string);
1138 let is_preflight = request.method() == Method::OPTIONS
1140 && request
1141 .headers()
1142 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1143 if is_preflight {
1144 let allow_headers = request
1146 .headers()
1147 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1148 .and_then(|v| v.to_str().ok())
1149 .map(str::to_string)
1150 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1151 let mut response = Response::new(Body::empty());
1152 *response.status_mut() = StatusCode::NO_CONTENT;
1153 if let Some(origin) = origin {
1154 let headers = response.headers_mut();
1155 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1156 set_header(headers, header::VARY, "Origin");
1157 set_header(
1158 headers,
1159 header::ACCESS_CONTROL_ALLOW_METHODS,
1160 CORS_ALLOW_METHODS,
1161 );
1162 set_header(
1163 headers,
1164 header::ACCESS_CONTROL_ALLOW_HEADERS,
1165 &allow_headers,
1166 );
1167 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1168 }
1169 return response;
1170 }
1171 let mut response = next.run(request).await;
1172 if let Some(origin) = origin {
1173 let headers = response.headers_mut();
1174 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1175 if let Ok(value) = HeaderValue::from_str("Origin") {
1178 headers.append(header::VARY, value);
1179 }
1180 }
1181 response
1182}
1183
1184const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1189
1190#[derive(Debug, thiserror::Error)]
1192pub enum ServeError {
1193 #[error("server I/O: {0}")]
1195 Io(#[from] std::io::Error),
1196}
1197
1198pub async fn serve(
1201 addr: SocketAddr,
1202 deploy: DeployStore,
1203 auth: Auth,
1204 handlers: HandlerRuntime,
1205) -> Result<(), ServeError> {
1206 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1207}
1208
1209pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1219 if let Err(err) = stream.set_nodelay(true) {
1220 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1221 }
1222}
1223
1224pub async fn serve_with(
1226 addr: SocketAddr,
1227 deploy: DeployStore,
1228 auth: Auth,
1229 handlers: HandlerRuntime,
1230 options: ServerOptions,
1231) -> Result<(), ServeError> {
1232 let tcp = tokio::net::TcpListener::bind(addr).await?;
1233 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1234 let splice_ctx = splice::SpliceCtx {
1240 deploy: deploy.clone(),
1241 posture: options.posture,
1242 daemon: options.daemon_runtime.clone(),
1243 };
1244 #[cfg(feature = "handlers")]
1247 let scheduler = handlers.spawn_scheduler(deploy.clone());
1248 let gateway_prober = gateway::spawn_active_health_prober();
1252 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1255
1256 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1260 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1264 shutdown_signal().await;
1265 let _ = signalled_tx.send(true);
1266 });
1267 let signalled = {
1268 let mut rx = signalled_rx;
1269 async move {
1270 let _ = rx.wait_for(|fired| *fired).await;
1271 }
1272 };
1273 let result = serve_with_drain_deadline(
1274 async move { server.await.map_err(ServeError::from) },
1275 signalled,
1276 DRAIN_DEADLINE,
1277 )
1278 .await;
1279 #[cfg(feature = "handlers")]
1281 if let Some(handle) = scheduler {
1282 handle.abort();
1283 }
1284 gateway_prober.abort();
1285 result
1286}
1287
1288async fn serve_with_drain_deadline<Srv, Sig>(
1293 server: Srv,
1294 signalled: Sig,
1295 deadline: Duration,
1296) -> Result<(), ServeError>
1297where
1298 Srv: Future<Output = Result<(), ServeError>>,
1299 Sig: Future<Output = ()>,
1300{
1301 tokio::pin!(server);
1302 let drain_cap = async move {
1303 signalled.await;
1304 tokio::time::sleep(deadline).await;
1305 };
1306 tokio::select! {
1307 result = &mut server => result,
1308 _ = drain_cap => {
1309 tracing::warn!(
1310 deadline_s = deadline.as_secs(),
1311 "drain deadline exceeded; forcing shutdown with requests still in flight"
1312 );
1313 Ok(())
1314 }
1315 }
1316}
1317
1318pub async fn shutdown_signal() {
1321 let ctrl_c = async {
1322 let _ = tokio::signal::ctrl_c().await;
1323 };
1324 #[cfg(unix)]
1325 let terminate = async {
1326 if let Ok(mut sig) =
1327 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1328 {
1329 sig.recv().await;
1330 }
1331 };
1332 #[cfg(not(unix))]
1333 let terminate = std::future::pending::<()>();
1334
1335 tokio::select! {
1336 _ = ctrl_c => {}
1337 _ = terminate => {}
1338 }
1339 tracing::info!("shutdown signal received; draining");
1340}
1341
1342async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1346 match daemon.generation() {
1347 Some(gen) => format!("ok gen={gen}"),
1348 None => "ok".to_string(),
1349 }
1350}
1351
1352async fn readyz(State(deploy): State<DeployStore>) -> Response {
1354 match deploy.ready().await {
1355 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1356 Err(err) => {
1357 tracing::warn!(error = %err, "readiness probe failed");
1358 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1359 }
1360 }
1361}
1362
1363#[derive(Clone)]
1368pub struct RequestId(pub String);
1369
1370fn request_id_for(headers: &HeaderMap) -> String {
1373 if let Some(id) = headers
1374 .get("x-request-id")
1375 .and_then(|v| v.to_str().ok())
1376 .map(str::trim)
1377 .filter(|s| !s.is_empty())
1378 {
1379 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1380 }
1381 use std::sync::atomic::{AtomicU64, Ordering};
1382 static SEQ: AtomicU64 = AtomicU64::new(0);
1383 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1384 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1385}
1386
1387struct AccessLog {
1391 request_id: String,
1392 method: Method,
1393 path: String,
1394 host: String,
1395 client: String,
1396 status: u16,
1397 encoding: String,
1399 start: std::time::Instant,
1400 bytes: std::sync::atomic::AtomicU64,
1401}
1402
1403impl Drop for AccessLog {
1404 fn drop(&mut self) {
1405 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1406 srvmetrics::server_metrics().record_request(self.status, bytes);
1409 tracing::info!(
1410 target: "boatramp::access",
1411 request_id = %self.request_id,
1412 method = %self.method,
1413 path = %self.path,
1414 host = %self.host,
1415 client = %self.client,
1416 status = self.status,
1417 bytes = bytes,
1418 encoding = %self.encoding,
1419 cache_result = srvmetrics::cache_result(self.status),
1420 elapsed_ms = self.start.elapsed().as_millis() as u64,
1421 "request"
1422 );
1423 }
1424}
1425
1426pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1432 let request_id = request_id_for(request.headers());
1433 request
1434 .extensions_mut()
1435 .insert(RequestId(request_id.clone()));
1436 request_id
1437}
1438
1439pub(crate) struct AccessLogCtx {
1444 request_id: String,
1445 method: Method,
1446 path: String,
1447 host: String,
1448 client: String,
1449 start: std::time::Instant,
1450}
1451
1452impl AccessLogCtx {
1453 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1459 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1460 return None;
1461 }
1462 Some(Self {
1463 request_id,
1464 method: request.method().clone(),
1465 path: request.uri().path().to_string(),
1466 host: request
1467 .headers()
1468 .get(header::HOST)
1469 .and_then(|value| value.to_str().ok())
1470 .or_else(|| request.uri().host()) .unwrap_or("-")
1472 .to_string(),
1473 client: request
1474 .extensions()
1475 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1476 .map(|info| info.0.ip().to_string())
1477 .unwrap_or_else(|| "-".to_string()),
1478 start: std::time::Instant::now(),
1479 })
1480 }
1481
1482 pub(crate) fn finish(self, response: Response) -> Response {
1486 let encoding = response
1487 .headers()
1488 .get(header::CONTENT_ENCODING)
1489 .and_then(|v| v.to_str().ok())
1490 .unwrap_or("identity")
1491 .to_string();
1492 let log = AccessLog {
1493 request_id: self.request_id,
1494 method: self.method,
1495 path: self.path,
1496 host: self.host,
1497 client: self.client,
1498 status: response.status().as_u16(),
1499 encoding,
1500 start: self.start,
1501 bytes: std::sync::atomic::AtomicU64::new(0),
1502 };
1503 let (parts, body) = response.into_parts();
1504 let counted = body.into_data_stream().map(move |chunk| {
1505 if let Ok(bytes) = &chunk {
1506 log.bytes
1507 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1508 }
1509 chunk
1510 });
1511 Response::from_parts(parts, Body::from_stream(counted))
1512 }
1513}
1514
1515async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1520 let request_id = assign_request_id(&mut request);
1521 match AccessLogCtx::capture(&request, request_id) {
1522 None => next.run(request).await,
1523 Some(ctx) => ctx.finish(next.run(request).await),
1524 }
1525}
1526
1527fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1529 req_headers
1530 .get(header::IF_NONE_MATCH)
1531 .and_then(|value| value.to_str().ok())
1532 .is_some_and(|value| {
1533 value
1534 .split(',')
1535 .map(str::trim)
1536 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1537 })
1538}
1539
1540fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1541 if let Ok(value) = HeaderValue::from_str(value) {
1542 headers.insert(name, value);
1543 }
1544}
1545
1546fn not_found() -> Response {
1547 (StatusCode::NOT_FOUND, "not found\n").into_response()
1548}
1549
1550fn redirect(status: u16, location: &str) -> Response {
1551 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1552 match HeaderValue::from_str(location) {
1553 Ok(location) => {
1554 let mut headers = HeaderMap::new();
1555 headers.insert(header::LOCATION, location);
1556 (status, headers).into_response()
1557 }
1558 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1559 }
1560}
1561
1562fn deploy_error_response(err: DeployError) -> Response {
1564 let status = match &err {
1565 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1566 StatusCode::NOT_FOUND
1567 }
1568 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1569 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1570 DeployError::Conflict(_) => StatusCode::CONFLICT,
1572 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1574 _ => StatusCode::INTERNAL_SERVER_ERROR,
1575 };
1576 tracing::warn!(error = %err, "request failed");
1577 (status, format!("{err}\n")).into_response()
1578}
1579
1580fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1585 boatramp_core::project::validate_resource_name(kind, value)
1586 .err()
1587 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1588}
1589
1590#[cfg(test)]
1591mod drain_tests {
1592 use super::*;
1593
1594 #[tokio::test]
1595 async fn deadline_forces_shutdown_after_signal() {
1596 let server = std::future::pending::<Result<(), ServeError>>();
1599 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1601 assert!(result.is_ok());
1602 }
1603
1604 #[tokio::test]
1605 async fn server_finishing_first_wins() {
1606 let server = async { Ok(()) };
1609 let signalled = std::future::pending::<()>();
1610 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1611 assert!(result.is_ok());
1612 }
1613
1614 #[tokio::test]
1615 async fn deadline_does_not_trip_before_signal() {
1616 let server = async {
1620 tokio::time::sleep(Duration::from_millis(40)).await;
1621 Err(ServeError::Io(std::io::Error::other("server error")))
1622 };
1623 let signalled = std::future::pending::<()>();
1624 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1625 assert!(result.is_err());
1626 }
1627}
1628
1629#[cfg(all(test, feature = "handlers"))]
1630mod tests {
1631 use super::*;
1632 use boatramp_core::cose::{LocalSigner, TokenAlg};
1633 use boatramp_core::project::ProjectRef;
1634
1635 #[test]
1636 fn query_string_parses_and_url_decodes() {
1637 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1638 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1639 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")); }
1643
1644 #[test]
1645 fn cookie_header_parses_pairs() {
1646 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1647 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1648 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1649 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1650 }
1651
1652 #[test]
1653 fn apply_vary_merges_without_duplicates() {
1654 let base = (StatusCode::OK, "x").into_response();
1655 let r = apply_vary(base, &["accept-language".into()]);
1656 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1657 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1659 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1660 assert!(v.contains("accept-language") && v.contains("cookie"));
1661 assert_eq!(v.matches("accept-language").count(), 1);
1662 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1664 assert!(plain.headers().get(header::VARY).is_none());
1665 }
1666
1667 #[tokio::test]
1671 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1672 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1673 let public = keys.public_key();
1674
1675 let resp = create_join_token(
1677 Extension(Issuer(Some(keys.clone()))),
1678 Json(CreateJoinTokenRequest {
1679 ttl_secs: Some(600),
1680 }),
1681 )
1682 .await;
1683 assert_eq!(resp.status(), StatusCode::CREATED);
1684 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1685 .await
1686 .unwrap();
1687 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1688 let token = parsed["token"].as_str().unwrap();
1689 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1690 assert!(!jti.is_empty());
1691
1692 let no_issuer = create_join_token(
1694 Extension(Issuer(None)),
1695 Json(CreateJoinTokenRequest { ttl_secs: None }),
1696 )
1697 .await;
1698 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1699 }
1700
1701 #[tokio::test]
1706 async fn function_write_path_deploy_rollback_alias_remove() {
1707 use boatramp_core::function::Lifecycle;
1708 use boatramp_core::kv::MemoryKv;
1709 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1710
1711 struct FakeStorage {
1714 present: bool,
1715 }
1716 #[async_trait::async_trait]
1717 impl Storage for FakeStorage {
1718 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1719 Err(StorageError::NotFound(String::new()))
1720 }
1721 async fn get_range(
1722 &self,
1723 _: &str,
1724 _: u64,
1725 _: Option<u64>,
1726 ) -> Result<GetObject, StorageError> {
1727 Err(StorageError::NotFound(String::new()))
1728 }
1729 async fn put(
1730 &self,
1731 _: &str,
1732 _: ByteStream,
1733 _: PutMeta,
1734 ) -> Result<ObjectMeta, StorageError> {
1735 Err(StorageError::unsupported("fake"))
1736 }
1737 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1738 if self.present {
1739 Ok(ObjectMeta {
1740 key: key.to_string(),
1741 ..Default::default()
1742 })
1743 } else {
1744 Err(StorageError::NotFound(key.to_string()))
1745 }
1746 }
1747 async fn delete(&self, _: &str) -> Result<(), StorageError> {
1748 Ok(())
1749 }
1750 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1751 Ok(Vec::new())
1752 }
1753 }
1754
1755 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1756 let status = resp.status();
1757 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1758 .await
1759 .unwrap();
1760 let value = if bytes.is_empty() {
1761 serde_json::Value::Null
1762 } else {
1763 serde_json::from_slice(&bytes).unwrap()
1764 };
1765 (status, value)
1766 }
1767
1768 let deploy = DeployStore::new(
1769 Arc::new(FakeStorage { present: true }),
1770 Arc::new(MemoryKv::new()),
1771 );
1772 let v1 = "a".repeat(64);
1773 let v2 = "b".repeat(64);
1774
1775 let (st, body) = body_json(
1777 deploy_function(
1778 State(deploy.clone()),
1779 axum::extract::Extension(crate::ProjectContext::default()),
1780 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1781 axum::extract::Query(DeployFunctionQuery::default()),
1782 Path("greeter".to_string()),
1783 Json(FunctionUpsert {
1784 component: v1.clone(),
1785 config: Default::default(),
1786 lifecycle: Lifecycle::Independent,
1787 }),
1788 )
1789 .await,
1790 )
1791 .await;
1792 assert_eq!(st, StatusCode::OK);
1793 assert_eq!(body["active"], v1);
1794
1795 let (_, body) = body_json(
1797 deploy_function(
1798 State(deploy.clone()),
1799 axum::extract::Extension(crate::ProjectContext::default()),
1800 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1801 axum::extract::Query(DeployFunctionQuery::default()),
1802 Path("greeter".to_string()),
1803 Json(FunctionUpsert {
1804 component: v2.clone(),
1805 config: Default::default(),
1806 lifecycle: Lifecycle::Independent,
1807 }),
1808 )
1809 .await,
1810 )
1811 .await;
1812 assert_eq!(body["active"], v2);
1813 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1814
1815 let (st, body) = body_json(
1817 rollback_function(
1818 State(deploy.clone()),
1819 axum::extract::Extension(crate::ProjectContext::default()),
1820 Path("greeter".to_string()),
1821 Json(RollbackBody { to: v1.clone() }),
1822 )
1823 .await,
1824 )
1825 .await;
1826 assert_eq!(st, StatusCode::OK);
1827 assert_eq!(body["active"], v1);
1828
1829 let resp = rollback_function(
1831 State(deploy.clone()),
1832 axum::extract::Extension(crate::ProjectContext::default()),
1833 Path("greeter".to_string()),
1834 Json(RollbackBody { to: "c".repeat(64) }),
1835 )
1836 .await;
1837 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1838
1839 let (st, body) = body_json(
1841 alias_function(
1842 State(deploy.clone()),
1843 axum::extract::Extension(crate::ProjectContext::default()),
1844 Path(("greeter".to_string(), "prod".to_string())),
1845 Json(AliasBody {
1846 version: v2.clone(),
1847 }),
1848 )
1849 .await,
1850 )
1851 .await;
1852 assert_eq!(st, StatusCode::OK);
1853 assert_eq!(body["aliases"]["prod"], v2);
1854
1855 let (st, _) = body_json(
1857 remove_function(
1858 State(deploy.clone()),
1859 axum::extract::Extension(crate::ProjectContext::default()),
1860 Path("greeter".to_string()),
1861 )
1862 .await,
1863 )
1864 .await;
1865 assert_eq!(st, StatusCode::NO_CONTENT);
1866 assert!(deploy
1867 .get_function(ProjectRef::DEFAULT, "greeter")
1868 .await
1869 .unwrap()
1870 .is_none());
1871
1872 let empty = DeployStore::new(
1874 Arc::new(FakeStorage { present: false }),
1875 Arc::new(MemoryKv::new()),
1876 );
1877 let resp = deploy_function(
1878 State(empty),
1879 axum::extract::Extension(crate::ProjectContext::default()),
1880 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1881 axum::extract::Query(DeployFunctionQuery::default()),
1882 Path("orphan".to_string()),
1883 Json(FunctionUpsert {
1884 component: v1.clone(),
1885 config: Default::default(),
1886 lifecycle: Lifecycle::default(),
1887 }),
1888 )
1889 .await;
1890 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1891 }
1892
1893 struct StubControl {
1897 admits: std::sync::Mutex<Vec<(String, String)>>,
1898 respond: StubJoin,
1899 }
1900 #[derive(Clone, Copy)]
1901 enum StubJoin {
1902 Admit,
1903 Spent,
1904 Invalid,
1905 Revoked,
1906 }
1907
1908 #[async_trait::async_trait]
1909 impl MeshControl for StubControl {
1910 async fn admit(
1911 &self,
1912 mesh_pubkey_hex: &str,
1913 jti: &str,
1914 _proof: &[u8],
1915 _proof_iat: u64,
1916 _now: u64,
1917 _advertise_addr: Option<&str>,
1918 ) -> Result<JoinOutcome, String> {
1919 self.admits
1920 .lock()
1921 .unwrap()
1922 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1923 Ok(match self.respond {
1924 StubJoin::Admit => JoinOutcome::Admitted {
1925 members: vec!["signed-member".to_string()],
1926 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1927 },
1928 StubJoin::Spent => JoinOutcome::TokenSpent,
1929 StubJoin::Invalid => JoinOutcome::ProofInvalid,
1930 StubJoin::Revoked => JoinOutcome::Revoked,
1931 })
1932 }
1933 async fn rotate_key(&self) -> Result<String, String> {
1934 Ok("cafe".to_string())
1935 }
1936 async fn revoke(&self, _node: u64) -> Result<(), String> {
1937 Ok(())
1938 }
1939 async fn members(&self) -> Result<Vec<MeshMember>, String> {
1940 Ok(Vec::new())
1941 }
1942 async fn promote(&self, _node: u64) -> Result<(), String> {
1943 Ok(())
1944 }
1945 }
1946
1947 #[tokio::test]
1951 async fn cluster_join_dispatches_and_maps_outcomes() {
1952 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1953 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1954 let auth = Auth::with_key(keys.public_key(), kv);
1955 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1956 let req = |proof: &str| JoinRequest {
1957 token: token.clone(),
1958 mesh_pubkey: "302a300506032b6570032100feed".into(),
1959 possession_proof: proof.to_string(),
1960 proof_iat: now_unix(),
1961 advertise_addr: Some("https://joiner:7000".into()),
1962 };
1963
1964 let admitter = Arc::new(StubControl {
1966 admits: std::sync::Mutex::new(Vec::new()),
1967 respond: StubJoin::Admit,
1968 });
1969 let resp = cluster_join(
1970 Extension(auth.clone()),
1971 Extension(MeshControlHandle(Some(admitter.clone()))),
1972 Json(req("aa01")),
1973 )
1974 .await;
1975 assert_eq!(resp.status(), StatusCode::OK);
1976 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1977
1978 let spent = Arc::new(StubControl {
1980 admits: std::sync::Mutex::new(Vec::new()),
1981 respond: StubJoin::Spent,
1982 });
1983 assert_eq!(
1984 cluster_join(
1985 Extension(auth.clone()),
1986 Extension(MeshControlHandle(Some(spent))),
1987 Json(req("aa01")),
1988 )
1989 .await
1990 .status(),
1991 StatusCode::CONFLICT
1992 );
1993 let invalid = Arc::new(StubControl {
1994 admits: std::sync::Mutex::new(Vec::new()),
1995 respond: StubJoin::Invalid,
1996 });
1997 assert_eq!(
1998 cluster_join(
1999 Extension(auth.clone()),
2000 Extension(MeshControlHandle(Some(invalid))),
2001 Json(req("aa01")),
2002 )
2003 .await
2004 .status(),
2005 StatusCode::FORBIDDEN
2006 );
2007 let revoked = Arc::new(StubControl {
2009 admits: std::sync::Mutex::new(Vec::new()),
2010 respond: StubJoin::Revoked,
2011 });
2012 assert_eq!(
2013 cluster_join(
2014 Extension(auth.clone()),
2015 Extension(MeshControlHandle(Some(revoked))),
2016 Json(req("aa01")),
2017 )
2018 .await
2019 .status(),
2020 StatusCode::FORBIDDEN
2021 );
2022
2023 let ok = Arc::new(StubControl {
2025 admits: std::sync::Mutex::new(Vec::new()),
2026 respond: StubJoin::Admit,
2027 });
2028 assert_eq!(
2029 cluster_join(
2030 Extension(auth.clone()),
2031 Extension(MeshControlHandle(Some(ok))),
2032 Json(req("not-hex")),
2033 )
2034 .await
2035 .status(),
2036 StatusCode::BAD_REQUEST
2037 );
2038
2039 let none = cluster_join(
2041 Extension(auth),
2042 Extension(MeshControlHandle(None)),
2043 Json(req("aa01")),
2044 )
2045 .await;
2046 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2047 }
2048
2049 #[tokio::test]
2053 async fn bootstrap_mints_the_first_token_once() {
2054 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2055 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2056 let public = keys.public_key();
2057 let deploy = DeployStore::new(
2058 Arc::new(MemStorage::default()),
2059 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2060 );
2061 let secret = "s3cr3t-bootstrap-value";
2062 let gate = BootstrapGate::new(Some(secret));
2063 let issuer = Issuer(Some(keys.clone()));
2064 let bearer = |s: &str| {
2065 let mut h = HeaderMap::new();
2066 h.insert(
2067 AUTHORIZATION,
2068 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2069 );
2070 h
2071 };
2072 let req = || BootstrapRequest {
2073 roles: vec!["admin".to_string()],
2074 ttl_secs: None,
2075 };
2076
2077 let bad = bootstrap_token(
2079 State(deploy.clone()),
2080 Extension(issuer.clone()),
2081 Extension(gate.clone()),
2082 bearer("wrong"),
2083 Json(req()),
2084 )
2085 .await;
2086 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2087
2088 let ok = bootstrap_token(
2090 State(deploy.clone()),
2091 Extension(issuer.clone()),
2092 Extension(gate.clone()),
2093 bearer(secret),
2094 Json(req()),
2095 )
2096 .await;
2097 assert_eq!(ok.status(), StatusCode::CREATED);
2098 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2099 .await
2100 .unwrap();
2101 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2102 let token = json["token"].as_str().unwrap();
2103 let id = json["id"].as_str().unwrap();
2104 let verified = cose::verify(token, &public, now_unix()).unwrap();
2105 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2106 assert!(deploy
2107 .list_token_meta()
2108 .await
2109 .unwrap()
2110 .iter()
2111 .any(|m| m.revocation_id == id));
2112
2113 let reuse = bootstrap_token(
2115 State(deploy.clone()),
2116 Extension(issuer.clone()),
2117 Extension(gate),
2118 bearer(secret),
2119 Json(req()),
2120 )
2121 .await;
2122 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2123
2124 let disabled = bootstrap_token(
2126 State(deploy),
2127 Extension(issuer),
2128 Extension(BootstrapGate(None)),
2129 bearer(secret),
2130 Json(req()),
2131 )
2132 .await;
2133 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2134 }
2135
2136 #[tokio::test]
2139 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2140 let control = Arc::new(StubControl {
2141 admits: std::sync::Mutex::new(Vec::new()),
2142 respond: StubJoin::Admit,
2143 });
2144 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2145 assert_eq!(resp.status(), StatusCode::OK);
2146 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2147 .await
2148 .unwrap();
2149 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2150 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2151
2152 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2153 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2154 }
2155
2156 #[test]
2157 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2158 use boatramp_core::security::SecurityProfile;
2159 let strict = SecurityProfile::MultiTenant.preset();
2160 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2164 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2165 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2166
2167 assert!(gateway_addr_allowed(public, &strict));
2169 assert!(!gateway_addr_allowed(private, &strict));
2170 assert!(!gateway_addr_allowed(loopback, &strict));
2171 assert!(!gateway_addr_allowed(metadata, &strict));
2172
2173 assert!(gateway_addr_allowed(public, &loose));
2176 assert!(gateway_addr_allowed(private, &loose));
2177 assert!(gateway_addr_allowed(loopback, &loose));
2178 assert!(!gateway_addr_allowed(metadata, &loose));
2179 }
2180
2181 #[tokio::test]
2182 async fn resolve_env_merges_static_and_host_secrets() {
2183 use boatramp_core::config::HandlersSiteConfig;
2184
2185 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2187
2188 let deploy_env = std::collections::BTreeMap::from([
2189 ("GREETING".to_string(), "hi".to_string()),
2190 ("OVERRIDE_ME".to_string(), "static".to_string()),
2191 ]);
2192 let site_handlers = HandlersSiteConfig {
2193 enabled: true,
2194 secrets: std::collections::BTreeMap::from([
2195 (
2197 "SECRET_TOKEN".to_string(),
2198 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2199 ),
2200 (
2201 "OVERRIDE_ME".to_string(),
2202 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2203 ),
2204 (
2205 "MISSING".to_string(),
2206 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2207 ),
2208 ]),
2209 ..Default::default()
2210 };
2211 let env = resolve_env(
2214 "blog",
2215 boatramp_core::project::ProjectRef::DEFAULT,
2216 &deploy_env,
2217 &site_handlers,
2218 true,
2219 None,
2220 )
2221 .await
2222 .expect("resolves");
2223
2224 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2228 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2229 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2230 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2231
2232 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2233 }
2234
2235 #[tokio::test]
2236 async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2237 use boatramp_core::config::HandlersSiteConfig;
2238
2239 std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2244 let deploy_env = std::collections::BTreeMap::new();
2245 let bare = HandlersSiteConfig {
2246 enabled: true,
2247 secrets: std::collections::BTreeMap::from([(
2248 "STOLEN".to_string(),
2249 "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2250 )]),
2251 ..Default::default()
2252 };
2253 let err = resolve_env(
2254 "evil",
2255 boatramp_core::project::ProjectRef::DEFAULT,
2256 &deploy_env,
2257 &bare,
2258 false,
2259 None,
2260 )
2261 .await
2262 .expect_err("multi-tenant must refuse a bare host-env ref");
2263 assert!(
2264 err.contains("STOLEN"),
2265 "error names the offending guest var: {err}"
2266 );
2267 assert!(
2268 err.contains("multi-tenant"),
2269 "error steers the tenant: {err}"
2270 );
2271 assert!(
2272 !err.contains("leak-me"),
2273 "the host value must never appear (never read): {err}"
2274 );
2275
2276 let explicit = HandlersSiteConfig {
2278 enabled: true,
2279 secrets: std::collections::BTreeMap::from([(
2280 "STOLEN".to_string(),
2281 "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2282 )]),
2283 ..Default::default()
2284 };
2285 assert!(resolve_env(
2286 "evil",
2287 boatramp_core::project::ProjectRef::DEFAULT,
2288 &deploy_env,
2289 &explicit,
2290 false,
2291 None,
2292 )
2293 .await
2294 .is_err());
2295
2296 let reserved = HandlersSiteConfig {
2298 enabled: true,
2299 secrets: std::collections::BTreeMap::from([(
2300 "TOKEN".to_string(),
2301 "vault:kv/data/app#token".to_string(),
2302 )]),
2303 ..Default::default()
2304 };
2305 let err = resolve_env(
2306 "evil",
2307 boatramp_core::project::ProjectRef::DEFAULT,
2308 &deploy_env,
2309 &reserved,
2310 true,
2311 None,
2312 )
2313 .await
2314 .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2315 assert!(err.contains("not yet supported"), "{err}");
2316
2317 let arbitrary = HandlersSiteConfig {
2321 enabled: true,
2322 secrets: std::collections::BTreeMap::from([(
2323 "KEY".to_string(),
2324 "aws:sm/prod/apikey".to_string(),
2325 )]),
2326 ..Default::default()
2327 };
2328 let err = resolve_env(
2329 "evil",
2330 boatramp_core::project::ProjectRef::DEFAULT,
2331 &deploy_env,
2332 &arbitrary,
2333 true,
2334 None,
2335 )
2336 .await
2337 .expect_err("any unknown scheme is reserved, even under single-tenant");
2338 assert!(
2339 err.contains("not yet supported") && err.contains("aws"),
2340 "provider-neutral reservation names the scheme: {err}"
2341 );
2342
2343 std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2344 }
2345
2346 #[tokio::test]
2347 async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2348 std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2353
2354 let static_env = std::collections::BTreeMap::from([
2355 ("STAGE".to_string(), "prod".to_string()),
2356 ("OVERRIDE_ME".to_string(), "static".to_string()),
2357 ]);
2358 let secrets = std::collections::BTreeMap::from([
2359 ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2361 (
2363 "OVERRIDE_ME".to_string(),
2364 "BOATRAMP_TEST_FN_SECRET".to_string(),
2365 ),
2366 (
2368 "MISSING".to_string(),
2369 "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2370 ),
2371 ]);
2372 let env = resolve_secret_env(
2374 "fn/api",
2375 boatramp_core::project::ProjectRef::DEFAULT,
2376 &static_env,
2377 &secrets,
2378 true,
2379 None,
2380 )
2381 .await
2382 .expect("resolves");
2383
2384 assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2385 assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2387 assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2389 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2391
2392 std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2393 }
2394
2395 #[tokio::test]
2396 async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2397 std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2402 let static_env = std::collections::BTreeMap::new();
2403 let secrets = std::collections::BTreeMap::from([(
2404 "DB_URL".to_string(),
2405 "BOATRAMP_TEST_FN_LEAK".to_string(),
2406 )]);
2407
2408 let err = resolve_secret_env(
2410 "fn/api",
2411 boatramp_core::project::ProjectRef::DEFAULT,
2412 &static_env,
2413 &secrets,
2414 false,
2415 None,
2416 )
2417 .await
2418 .expect_err("multi-tenant must refuse a function host-env ref");
2419 assert!(
2420 err.contains("DB_URL"),
2421 "error names the offending guest var: {err}"
2422 );
2423 assert!(
2424 !err.contains("leak-me"),
2425 "host value must never appear: {err}"
2426 );
2427
2428 let env = resolve_secret_env(
2430 "fn/api",
2431 boatramp_core::project::ProjectRef::DEFAULT,
2432 &static_env,
2433 &secrets,
2434 true,
2435 None,
2436 )
2437 .await
2438 .expect("resolves");
2439 assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2440
2441 std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2442 }
2443
2444 #[tokio::test]
2445 async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2446 use boatramp_core::project::ProjectRef;
2447 use boatramp_core::secret_store::SecretStore;
2448 use std::sync::Arc;
2449
2450 struct XorEnvelope;
2452 #[async_trait::async_trait]
2453 impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2454 async fn wrap(
2455 &self,
2456 p: &[u8],
2457 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2458 Ok(p.iter().map(|b| b ^ 0x5a).collect())
2459 }
2460 async fn unwrap(
2461 &self,
2462 c: &[u8],
2463 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2464 Ok(c.iter().map(|b| b ^ 0x5a).collect())
2465 }
2466 }
2467
2468 let store = SecretStore::new(
2469 Arc::new(boatramp_core::kv::MemoryKv::new()),
2470 Arc::new(XorEnvelope),
2471 );
2472 store
2473 .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2474 .await
2475 .unwrap();
2476
2477 let static_env = std::collections::BTreeMap::new();
2478 let secrets = std::collections::BTreeMap::from([
2479 ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2480 ("MISSING".to_string(), "boatramp:not-set".to_string()),
2481 ]);
2482
2483 let env = resolve_secret_env(
2486 "site",
2487 ProjectRef::new("acme"),
2488 &static_env,
2489 &secrets,
2490 false,
2491 Some(&store),
2492 )
2493 .await
2494 .expect("boatramp refs resolve without the host-env gate");
2495 assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2496 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2498
2499 let other_secrets = std::collections::BTreeMap::from([(
2501 "API_KEY".to_string(),
2502 "boatramp:api-key".to_string(),
2503 )]);
2504 let other = resolve_secret_env(
2505 "site",
2506 ProjectRef::new("globex"),
2507 &static_env,
2508 &other_secrets,
2509 false,
2510 Some(&store),
2511 )
2512 .await
2513 .expect("resolves (a foreign project's secret is simply absent → skipped)");
2514 assert!(
2515 !other.iter().any(|(k, _)| k == "API_KEY"),
2516 "a tenant must not read another project's secret"
2517 );
2518
2519 let err = resolve_secret_env(
2521 "site",
2522 ProjectRef::new("acme"),
2523 &static_env,
2524 &other_secrets,
2525 false,
2526 None,
2527 )
2528 .await
2529 .expect_err("no store configured must fail closed");
2530 assert!(err.contains("no internal secret store"), "{err}");
2531 }
2532
2533 fn req() -> Request {
2534 Request::builder()
2535 .uri("/")
2536 .header(header::HOST, "example.com")
2537 .body(Body::empty())
2538 .unwrap()
2539 }
2540
2541 #[test]
2542 fn forwarded_headers_set_standard_triple() {
2543 let mut request = req();
2544 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2545 let h = request.headers();
2546 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2547 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2548 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2549 }
2550
2551 #[test]
2552 fn forwarded_for_overwrites_spoofed_value() {
2553 let mut request = Request::builder()
2556 .uri("/")
2557 .header(header::HOST, "example.com")
2558 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2559 .body(Body::empty())
2560 .unwrap();
2561 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2562 let values: Vec<_> = request
2563 .headers()
2564 .get_all("x-forwarded-for")
2565 .iter()
2566 .collect();
2567 assert_eq!(values.len(), 1);
2568 assert_eq!(values[0], "203.0.113.7");
2569 }
2570
2571 #[test]
2572 fn forwarded_proto_preserves_upstream_tls() {
2573 let mut request = Request::builder()
2575 .uri("/")
2576 .header(header::HOST, "example.com")
2577 .header("x-forwarded-proto", "https")
2578 .body(Body::empty())
2579 .unwrap();
2580 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2581 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2582 }
2583
2584 #[test]
2585 fn forwarded_host_absent_when_no_host_header() {
2586 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2587 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2588 assert!(request.headers().get("x-forwarded-host").is_none());
2589 assert_eq!(
2590 request.headers().get("x-forwarded-for").unwrap(),
2591 "203.0.113.7"
2592 );
2593 }
2594
2595 use boatramp_core::kv::{KvStore, MemoryKv};
2598 use boatramp_core::messaging::{LogMessaging, Messaging};
2599 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2600
2601 const EVENT_CONSUMER: &[u8] =
2602 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2603
2604 #[derive(Default)]
2605 struct MemStorage {
2606 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2607 }
2608
2609 #[async_trait::async_trait]
2610 impl boatramp_core::Storage for MemStorage {
2611 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2612 let bytes = self
2613 .objects
2614 .lock()
2615 .unwrap()
2616 .get(key)
2617 .cloned()
2618 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2619 let body: ByteStream =
2620 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2621 Ok(GetObject {
2622 meta: ObjectMeta {
2623 key: key.to_string(),
2624 ..Default::default()
2625 },
2626 body,
2627 })
2628 }
2629 async fn get_range(
2630 &self,
2631 key: &str,
2632 _: u64,
2633 _: Option<u64>,
2634 ) -> Result<GetObject, StorageError> {
2635 self.get(key).await
2636 }
2637 async fn put(
2638 &self,
2639 key: &str,
2640 mut body: ByteStream,
2641 _: PutMeta,
2642 ) -> Result<ObjectMeta, StorageError> {
2643 use futures::StreamExt;
2644 let mut buf = Vec::new();
2645 while let Some(chunk) = body.next().await {
2646 buf.extend_from_slice(&chunk?);
2647 }
2648 self.objects.lock().unwrap().insert(key.to_string(), buf);
2649 Ok(ObjectMeta {
2650 key: key.to_string(),
2651 ..Default::default()
2652 })
2653 }
2654 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2655 self.objects
2656 .lock()
2657 .unwrap()
2658 .get(key)
2659 .map(|_| ObjectMeta {
2660 key: key.to_string(),
2661 ..Default::default()
2662 })
2663 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2664 }
2665 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2666 self.objects.lock().unwrap().remove(key);
2667 Ok(())
2668 }
2669 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2670 Ok(Vec::new())
2671 }
2672 }
2673
2674 fn observed_state_in(
2677 project: &str,
2678 workload: &str,
2679 host: &str,
2680 healthy: bool,
2681 phase: boatramp_core::compute::ReplicaPhase,
2682 ) -> boatramp_core::compute::ObservedInstance {
2683 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2684 boatramp_core::compute::ObservedInstance {
2685 handle: InstanceHandle {
2686 project: project.into(),
2687 workload: workload.into(),
2688 replica: 0,
2689 backend_ref: "ref-0".into(),
2690 },
2691 node: 1,
2692 backend: "vmm".into(),
2693 endpoint: Endpoint {
2694 scheme: Scheme::Http,
2695 host: host.into(),
2696 port: 80,
2697 },
2698 region: None,
2699 healthy,
2700 started_at: None,
2701 phase,
2702 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2703 project: project.into(),
2704 workload: workload.into(),
2705 replica: 0,
2706 data_ref: "snap-0".into(),
2707 }),
2708 }
2709 }
2710
2711 fn observed_state(
2713 workload: &str,
2714 healthy: bool,
2715 phase: boatramp_core::compute::ReplicaPhase,
2716 ) -> boatramp_core::compute::ObservedInstance {
2717 observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2718 }
2719
2720 #[tokio::test]
2721 async fn has_parked_replica_detects_a_zeroed_replica() {
2722 use boatramp_core::compute::ReplicaPhase;
2723 let storage = Arc::new(MemStorage::default());
2724 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2725 let deploy = DeployStore::new(storage, kv);
2726
2727 assert!(!has_parked_replica(&deploy, "default", "w").await);
2729 deploy
2731 .set_replica_state(
2732 ProjectRef::DEFAULT,
2733 &observed_state("w", true, ReplicaPhase::Running),
2734 )
2735 .await
2736 .unwrap();
2737 assert!(!has_parked_replica(&deploy, "default", "w").await);
2738 deploy
2740 .set_replica_state(
2741 ProjectRef::DEFAULT,
2742 &observed_state("w", false, ReplicaPhase::Zero),
2743 )
2744 .await
2745 .unwrap();
2746 assert!(has_parked_replica(&deploy, "default", "w").await);
2747 }
2748
2749 #[tokio::test]
2750 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2751 use boatramp_core::compute::ReplicaPhase;
2752 let storage = Arc::new(MemStorage::default());
2753 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2754 let deploy = DeployStore::new(storage, kv);
2755
2756 let empty = await_warm(
2758 &deploy,
2759 "default",
2760 "w",
2761 std::time::Duration::from_millis(150),
2762 )
2763 .await;
2764 assert!(empty.is_empty());
2765
2766 deploy
2768 .set_replica_state(
2769 ProjectRef::DEFAULT,
2770 &observed_state("w", true, ReplicaPhase::Running),
2771 )
2772 .await
2773 .unwrap();
2774 let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
2775 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2776 }
2777
2778 #[tokio::test]
2784 async fn compute_endpoints_are_project_scoped() {
2785 use boatramp_core::compute::ReplicaPhase;
2786 let storage = Arc::new(MemStorage::default());
2787 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2788 let deploy = DeployStore::new(storage, kv);
2789
2790 deploy
2792 .set_replica_state(
2793 ProjectRef::new("acme"),
2794 &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
2795 )
2796 .await
2797 .unwrap();
2798 deploy
2799 .set_replica_state(
2800 ProjectRef::DEFAULT,
2801 &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
2802 )
2803 .await
2804 .unwrap();
2805
2806 assert_eq!(
2808 compute_endpoints(&deploy, "acme", "web").await,
2809 vec!["http://10.0.0.5:80".to_string()],
2810 "acme's web resolves against acme, not default"
2811 );
2812 assert_eq!(
2814 compute_endpoints(&deploy, "default", "web").await,
2815 vec!["http://10.0.0.9:80".to_string()]
2816 );
2817 assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
2820 }
2821
2822 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2826 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2827 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2828 let storage = Arc::new(MemStorage::default());
2829 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2830 let mq = LogMessaging::new(storage, kv.clone());
2831 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2832 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2833 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2834 let topic = "blog/orders/created";
2835
2836 for _ in 0..3 {
2838 mq.publish(topic, b"ok").await.unwrap();
2839 }
2840 loop {
2841 let acked = dispatch_consumer_batch(
2842 &engine,
2843 &mq,
2844 &metrics::Metrics::default(),
2845 "blog",
2846 topic,
2847 "blog/",
2848 "",
2849 boatramp_core::messaging::StartPosition::Latest,
2850 &hash,
2851 EVENT_CONSUMER,
2852 &bindings,
2853 Limits::default(),
2854 Duration::from_secs(30),
2855 5,
2856 10,
2857 )
2858 .await;
2859 if acked == 0 {
2860 break;
2861 }
2862 }
2863 assert_eq!(
2864 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2865 Some(b"3".to_vec())
2866 );
2867
2868 mq.publish(topic, b"fail").await.unwrap();
2871 for _ in 0..5 {
2872 dispatch_consumer_batch(
2873 &engine,
2874 &mq,
2875 &metrics::Metrics::default(),
2876 "blog",
2877 topic,
2878 "blog/",
2879 "",
2880 boatramp_core::messaging::StartPosition::Latest,
2881 &hash,
2882 EVENT_CONSUMER,
2883 &bindings,
2884 Limits::default(),
2885 Duration::ZERO,
2886 2,
2887 10,
2888 )
2889 .await;
2890 }
2891 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2892 assert_eq!(
2894 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2895 Some(b"3".to_vec())
2896 );
2897 }
2898
2899 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2904 async fn consumer_groups_fan_out_through_the_dispatcher() {
2905 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2906 let storage = Arc::new(MemStorage::default());
2907 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2908 let mq = LogMessaging::new(storage, kv.clone());
2909 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2910 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2911 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2912 let topic = "blog/orders/created";
2913 let start = boatramp_core::messaging::StartPosition::Latest;
2914
2915 for g in ["billing", "audit"] {
2918 let n = dispatch_consumer_batch(
2919 &engine,
2920 &mq,
2921 &metrics::Metrics::default(),
2922 "blog",
2923 topic,
2924 "blog/",
2925 g,
2926 start,
2927 &hash,
2928 EVENT_CONSUMER,
2929 &bindings,
2930 Limits::default(),
2931 Duration::from_secs(30),
2932 5,
2933 10,
2934 )
2935 .await;
2936 assert_eq!(n, 0, "no events yet for group {g}");
2937 }
2938 mq.publish(topic, b"ok").await.unwrap();
2939
2940 for g in ["billing", "audit"] {
2942 let n = dispatch_consumer_batch(
2943 &engine,
2944 &mq,
2945 &metrics::Metrics::default(),
2946 "blog",
2947 topic,
2948 "blog/",
2949 g,
2950 start,
2951 &hash,
2952 EVENT_CONSUMER,
2953 &bindings,
2954 Limits::default(),
2955 Duration::from_secs(30),
2956 5,
2957 10,
2958 )
2959 .await;
2960 assert_eq!(n, 1, "group {g} should receive the message");
2961 }
2962 assert_eq!(
2964 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2965 Some(b"2".to_vec())
2966 );
2967 }
2968
2969 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2973 async fn scheduler_runs_current_consumers_not_previews() {
2974 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2975 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2976 use boatramp_handlers::{HandlerEngine, Limits};
2977 use futures::StreamExt;
2978
2979 let storage = Arc::new(MemStorage::default());
2980 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2981 let deploy = DeployStore::new(storage.clone(), kv.clone());
2982 let messaging: Arc<dyn Messaging> =
2983 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2984
2985 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2987 let stream: ByteStream =
2988 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2989 .boxed();
2990 deploy.put_blob(&hash, stream).await.unwrap();
2991 let mut files = std::collections::BTreeMap::new();
2992 files.insert(
2993 "consumer.wasm".to_string(),
2994 FileEntry {
2995 hash: hash.clone(),
2996 size: EVENT_CONSUMER.len() as u64,
2997 content_type: None,
2998 variants: std::collections::BTreeMap::new(),
2999 },
3000 );
3001 let manifest = Manifest {
3002 files,
3003 config: DeployConfig {
3004 consumers: vec![ConsumerConfig {
3005 topic: "orders/created".into(),
3006 component: "consumer.wasm".into(),
3007 imports: vec!["wasi:keyvalue".into()],
3008 group: String::new(),
3009 start: Default::default(),
3010 }],
3011 ..Default::default()
3012 },
3013 ..Default::default()
3014 };
3015 let id = deploy.put_manifest(&manifest).await.unwrap();
3016 deploy
3017 .activate(ProjectRef::DEFAULT, "blog", &id)
3018 .await
3019 .unwrap();
3020 deploy
3021 .set_site_config(
3022 ProjectRef::DEFAULT,
3023 "blog",
3024 &SiteConfig {
3025 handlers: Some(HandlersSiteConfig {
3026 enabled: true,
3027 allow_imports: vec!["wasi:keyvalue".into()],
3028 ..Default::default()
3029 }),
3030 ..Default::default()
3031 },
3032 )
3033 .await
3034 .unwrap();
3035
3036 messaging
3038 .publish("blog/orders/created", b"live")
3039 .await
3040 .unwrap();
3041 messaging
3042 .publish("blog/_preview/abc/orders/created", b"preview")
3043 .await
3044 .unwrap();
3045
3046 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3047 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3048 let inner = rt.inner.clone().unwrap();
3049 let mut cache = std::collections::HashMap::new();
3050 let mut crons = std::collections::HashMap::new();
3051 let mut sweep = std::collections::HashMap::new();
3052 let now = CronNow {
3053 minute: 0,
3054 hour: 0,
3055 dom: 1,
3056 month: 1,
3057 dow: 0,
3058 minute_stamp: 0,
3059 };
3060 for _ in 0..3 {
3061 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3062 .await
3063 .unwrap();
3064 }
3065
3066 assert_eq!(
3068 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3069 Some(b"1".to_vec())
3070 );
3071 assert_eq!(
3074 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3075 .await
3076 .unwrap(),
3077 None
3078 );
3079 }
3080
3081 const KV_COUNTER: &[u8] =
3086 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3087
3088 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3093 async fn function_invoker_runs_target_buffers_and_meters() {
3094 use boatramp_core::deploy::DeployStore;
3095 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3096 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3097 use futures::StreamExt;
3098
3099 const HTTP_200: &[u8] =
3102 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3103
3104 let storage = Arc::new(MemStorage::default());
3105 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3106 let deploy = DeployStore::new(storage.clone(), kv.clone());
3107
3108 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3109 let stream: ByteStream =
3110 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3111 deploy.put_blob(&hash, stream).await.unwrap();
3112 let function = Function {
3113 name: "target".into(),
3114 owner: Owner::Project("default".into()),
3115 versions: vec![FunctionVersion {
3116 id: "v1".into(),
3117 component: hash.clone(),
3118 created: 0,
3119 lifecycle: Lifecycle::Independent,
3120 }],
3121 active: "v1".into(),
3122 aliases: Default::default(),
3123 config: Default::default(),
3124 };
3125 deploy
3126 .put_function(ProjectRef::DEFAULT, &function)
3127 .await
3128 .unwrap();
3129
3130 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3131 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3132 rt.set_invoker(deploy.clone());
3133 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3134
3135 let request = || InvokeRequest {
3136 method: "GET".into(),
3137 path: "/".into(),
3138 headers: vec![],
3139 body: vec![],
3140 };
3141
3142 let response = invoker.invoke("target", request(), 1).await.unwrap();
3144 assert_eq!(response.status, 200);
3145
3146 let metering = deploy
3148 .get_metering(ProjectRef::DEFAULT, "target")
3149 .await
3150 .unwrap()
3151 .unwrap();
3152 assert_eq!(metering.invocations, 1);
3153
3154 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3156 assert!(matches!(err, InvokeError::NotFound));
3157 }
3158
3159 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3165 async fn federation_runner_enforces_the_safelist_before_planning() {
3166 use boatramp_core::deploy::DeployStore;
3167 use boatramp_core::project::ProjectRef;
3168 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3169
3170 let storage = Arc::new(MemStorage::default());
3171 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3172 let deploy = DeployStore::new(storage.clone(), kv.clone());
3173 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3174 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3175 rt.set_invoker(deploy.clone());
3176 let runner = rt
3177 .inner
3178 .as_ref()
3179 .unwrap()
3180 .federation_runner
3181 .get()
3182 .unwrap()
3183 .scoped(ProjectRef::new("default"));
3184
3185 let req = |query: &str| GraphqlRequest {
3186 query: Some(query.to_string()),
3187 persisted_hash: None,
3188 variables: "{}".to_string(),
3189 operation_name: None,
3190 authorization: None,
3191 };
3192
3193 assert!(matches!(
3195 runner.run(req("{ me { id } }"), 1).await,
3196 Err(SupergraphRunError::NotSafelisted)
3197 ));
3198
3199 let query = "{ me { id } }";
3202 let hash = crate::graphql_apq::sha256_hex(query);
3203 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3204 .await
3205 .unwrap();
3206 assert!(matches!(
3207 runner.run(req(query), 1).await,
3208 Err(SupergraphRunError::PlanFailed(_))
3209 ));
3210
3211 let persisted = GraphqlRequest {
3213 query: None,
3214 persisted_hash: Some("deadbeef".to_string()),
3215 variables: "{}".to_string(),
3216 operation_name: None,
3217 authorization: None,
3218 };
3219 assert!(matches!(
3220 runner.run(persisted, 1).await,
3221 Err(SupergraphRunError::NotSafelisted)
3222 ));
3223 }
3224
3225 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3231 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
3232 use crate::scheduler::{run_scheduler_tick, CronNow};
3233 use boatramp_core::deploy::DeployStore;
3234 use boatramp_core::function::{
3235 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3236 };
3237 use boatramp_handlers::{HandlerEngine, Limits};
3238 use futures::StreamExt;
3239
3240 const HTTP_200: &[u8] =
3241 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3242
3243 let storage = Arc::new(MemStorage::default());
3244 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3245 let deploy = DeployStore::new(storage.clone(), kv.clone());
3246
3247 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3248 let stream: ByteStream =
3249 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3250 deploy.put_blob(&hash, stream).await.unwrap();
3251
3252 let acme = ProjectRef::new("acme");
3254 let function = Function {
3255 name: "worker".into(),
3256 owner: Owner::Project("acme".into()),
3257 versions: vec![FunctionVersion {
3258 id: "v1".into(),
3259 component: hash.clone(),
3260 created: 0,
3261 lifecycle: Lifecycle::Independent,
3262 }],
3263 active: "v1".into(),
3264 aliases: Default::default(),
3265 config: Default::default(),
3266 };
3267 deploy.put_function(acme, &function).await.unwrap();
3268 let inv = Invocation {
3269 id: "inv1".into(),
3270 function: "worker".into(),
3271 version: "v1".into(),
3272 mode: InvokeMode::Async,
3273 status: InvocationStatus::Queued,
3274 idempotency_key: None,
3275 attempts: 0,
3276 lease_expires: None,
3277 request_b64: None,
3278 request_content_type: None,
3279 result: None,
3280 created: 0,
3281 updated: 0,
3282 };
3283 deploy.put_invocation(acme, &inv).await.unwrap();
3284
3285 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3286 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3287 let inner = rt.inner.as_ref().unwrap();
3288
3289 let mut wasm_cache = std::collections::HashMap::new();
3292 let mut cron_state = std::collections::HashMap::new();
3293 let mut sweep = std::collections::HashMap::new();
3294 let now = CronNow {
3295 minute: 0,
3296 hour: 0,
3297 dom: 1,
3298 month: 1,
3299 dow: 0,
3300 minute_stamp: 0,
3301 };
3302 run_scheduler_tick(
3303 inner,
3304 &deploy,
3305 &mut wasm_cache,
3306 &mut cron_state,
3307 &mut sweep,
3308 now,
3309 )
3310 .await
3311 .unwrap();
3312
3313 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
3316 assert_eq!(settled.status, InvocationStatus::Succeeded);
3318 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
3320 assert_eq!(metering.invocations, 1);
3321 assert!(deploy
3323 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
3324 .await
3325 .unwrap()
3326 .is_none());
3327 assert!(deploy
3328 .get_metering(ProjectRef::DEFAULT, "worker")
3329 .await
3330 .unwrap()
3331 .is_none());
3332 }
3333
3334 #[cfg(feature = "handlers")]
3338 async fn poll_invocation_settled(
3339 deploy: &boatramp_core::deploy::DeployStore,
3340 project: ProjectRef<'_>,
3341 function: &str,
3342 id: &str,
3343 ) -> boatramp_core::function::Invocation {
3344 use boatramp_core::function::InvocationStatus;
3345 for _ in 0..200 {
3346 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
3347 if matches!(
3348 inv.status,
3349 InvocationStatus::Succeeded | InvocationStatus::Failed
3350 ) {
3351 return inv;
3352 }
3353 }
3354 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3355 }
3356 panic!("invocation {function}/{id} never settled");
3357 }
3358
3359 #[cfg(feature = "handlers")]
3364 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3365 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
3366 use crate::scheduler::{run_scheduler_tick, CronNow};
3367 use boatramp_core::deploy::DeployStore;
3368 use boatramp_core::function::{
3369 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3370 };
3371 use boatramp_handlers::{HandlerEngine, Limits};
3372 use futures::StreamExt;
3373
3374 const HTTP_200: &[u8] =
3375 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3376
3377 let storage = Arc::new(MemStorage::default());
3378 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3379 let deploy = DeployStore::new(storage.clone(), kv.clone());
3380 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3381 let stream: ByteStream =
3382 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3383 deploy.put_blob(&hash, stream).await.unwrap();
3384
3385 let function = Function {
3386 name: "worker".into(),
3387 owner: Owner::Project("default".into()),
3388 versions: vec![FunctionVersion {
3389 id: "v1".into(),
3390 component: hash.clone(),
3391 created: 0,
3392 lifecycle: Lifecycle::Independent,
3393 }],
3394 active: "v1".into(),
3395 aliases: Default::default(),
3396 config: Default::default(),
3397 };
3398 deploy
3399 .put_function(ProjectRef::DEFAULT, &function)
3400 .await
3401 .unwrap();
3402
3403 let base = Invocation {
3406 id: String::new(),
3407 function: "worker".into(),
3408 version: "v1".into(),
3409 mode: InvokeMode::Async,
3410 status: InvocationStatus::Running,
3411 idempotency_key: None,
3412 attempts: 1,
3413 lease_expires: None,
3414 request_b64: None,
3415 request_content_type: None,
3416 result: None,
3417 created: 0,
3418 updated: 0,
3419 };
3420 let orphan = Invocation {
3421 id: "orphan".into(),
3422 lease_expires: Some(1),
3423 ..base.clone()
3424 };
3425 deploy
3426 .put_invocation(ProjectRef::DEFAULT, &orphan)
3427 .await
3428 .unwrap();
3429 let live = Invocation {
3430 id: "live".into(),
3431 lease_expires: Some(u64::MAX),
3432 ..base.clone()
3433 };
3434 deploy
3435 .put_invocation(ProjectRef::DEFAULT, &live)
3436 .await
3437 .unwrap();
3438
3439 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3440 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3441 let inner = rt.inner.as_ref().unwrap();
3442
3443 let now = CronNow {
3444 minute: 0,
3445 hour: 0,
3446 dom: 1,
3447 month: 1,
3448 dow: 0,
3449 minute_stamp: 0,
3450 };
3451 let mut wasm_cache = std::collections::HashMap::new();
3452 let mut cron_state = std::collections::HashMap::new();
3453 let mut sweep = std::collections::HashMap::new();
3454 run_scheduler_tick(
3455 inner,
3456 &deploy,
3457 &mut wasm_cache,
3458 &mut cron_state,
3459 &mut sweep,
3460 now,
3461 )
3462 .await
3463 .unwrap();
3464
3465 let settled =
3467 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3468 assert_eq!(settled.status, InvocationStatus::Succeeded);
3469 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3470 assert_eq!(
3471 settled.lease_expires, None,
3472 "a settled invocation drops its lease"
3473 );
3474 let live_after = deploy
3476 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3477 .await
3478 .unwrap()
3479 .unwrap();
3480 assert_eq!(live_after.status, InvocationStatus::Running);
3481 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3482 assert_eq!(live_after.lease_expires, Some(u64::MAX));
3483 }
3484
3485 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3500 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3501 use boatramp_core::deploy::DeployStore;
3502 use boatramp_core::function::{
3503 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3504 };
3505 use boatramp_handlers::{HandlerEngine, Limits};
3506 use futures::StreamExt;
3507
3508 let storage = Arc::new(MemStorage::default());
3509 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3510 let deploy = DeployStore::new(storage.clone(), kv.clone());
3511
3512 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3515 let stream: ByteStream =
3516 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3517 deploy.put_blob(&hash, stream).await.unwrap();
3518
3519 let store = Function {
3524 name: "store".into(),
3525 owner: Owner::Project("default".into()),
3526 versions: vec![FunctionVersion {
3527 id: "v1".into(),
3528 component: hash.clone(),
3529 created: 0,
3530 lifecycle: Lifecycle::Independent,
3531 }],
3532 active: "v1".into(),
3533 aliases: Default::default(),
3534 config: FunctionConfig {
3535 imports: vec!["wasi:keyvalue".into()],
3536 ..Default::default()
3537 },
3538 };
3539 let acme = ProjectRef::new("acme");
3540 let globex = ProjectRef::new("globex");
3541
3542 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3543 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3544 let inner = rt.inner.as_ref().unwrap();
3545
3546 let request = || {
3547 axum::http::Request::builder()
3548 .method("GET")
3549 .uri("/")
3550 .body(axum::body::Body::empty())
3551 .unwrap()
3552 };
3553
3554 let component = store.resolve(&store.active).unwrap().to_owned();
3557 for project in [acme, globex, ProjectRef::DEFAULT] {
3558 let (response, _) = execute_function(
3559 inner,
3560 &deploy,
3561 project,
3562 &store,
3563 &component,
3564 request(),
3565 0,
3566 boatramp_handlers::Lane::Sync,
3567 )
3568 .await;
3569 assert!(response.status().is_success(), "invocation should succeed");
3570 }
3571
3572 assert_eq!(
3575 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3576 Some(b"1".to_vec()),
3577 "acme's write must be tenant-qualified"
3578 );
3579 assert_eq!(
3580 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3581 Some(b"1".to_vec()),
3582 "globex's write must be tenant-qualified"
3583 );
3584 assert_eq!(
3585 kv.get("hkv/fn/store/hits").await.unwrap(),
3586 Some(b"1".to_vec()),
3587 "the default project must keep the byte-identical pre-project key"
3588 );
3589 }
3592
3593 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3597 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3598 use boatramp_core::config::{
3599 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3600 };
3601 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3602 use boatramp_handlers::{HandlerEngine, Limits};
3603 use futures::StreamExt;
3604 use std::sync::atomic::Ordering;
3605
3606 let storage = Arc::new(MemStorage::default());
3607 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3608 let deploy = DeployStore::new(storage.clone(), kv.clone());
3609
3610 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3611 let stream: ByteStream =
3612 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3613 deploy.put_blob(&hash, stream).await.unwrap();
3614 let mut files = std::collections::BTreeMap::new();
3615 files.insert(
3616 "counter.wasm".to_string(),
3617 FileEntry {
3618 hash: hash.clone(),
3619 size: KV_COUNTER.len() as u64,
3620 content_type: None,
3621 variants: std::collections::BTreeMap::new(),
3622 },
3623 );
3624 let manifest = Manifest {
3625 files,
3626 config: DeployConfig {
3627 handlers: vec![HandlerConfig {
3628 route: "/".into(),
3629 methods: Vec::new(),
3630 component: "counter.wasm".into(),
3631 imports: vec!["wasi:keyvalue".into()],
3632 streaming: false,
3633 limits: None,
3634 env: std::collections::BTreeMap::new(),
3635 invoke_targets: Vec::new(),
3636 }],
3637 crons: vec![CronConfig {
3638 schedule: "* * * * *".into(),
3639 route: "/".into(),
3640 overlap: Overlap::Skip,
3641 }],
3642 ..Default::default()
3643 },
3644 ..Default::default()
3645 };
3646 let id = deploy.put_manifest(&manifest).await.unwrap();
3647 deploy
3648 .activate(ProjectRef::DEFAULT, "blog", &id)
3649 .await
3650 .unwrap();
3651 deploy
3652 .set_site_config(
3653 ProjectRef::DEFAULT,
3654 "blog",
3655 &SiteConfig {
3656 handlers: Some(HandlersSiteConfig {
3657 enabled: true,
3658 allow_imports: vec!["wasi:keyvalue".into()],
3659 ..Default::default()
3660 }),
3661 ..Default::default()
3662 },
3663 )
3664 .await
3665 .unwrap();
3666
3667 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3668 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3669 let inner = rt.inner.clone().unwrap();
3670 let mut wasm = std::collections::HashMap::new();
3671 let mut crons = std::collections::HashMap::new();
3672 let mut sweep = std::collections::HashMap::new();
3673 let at = |stamp| CronNow {
3674 minute: 0,
3675 hour: 0,
3676 dom: 1,
3677 month: 1,
3678 dow: 0,
3679 minute_stamp: stamp,
3680 };
3681
3682 let (_, handles) =
3684 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3685 .await
3686 .unwrap();
3687 for h in handles {
3688 h.await.unwrap();
3689 }
3690 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3691
3692 let (_, handles) =
3694 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3695 .await
3696 .unwrap();
3697 assert!(handles.is_empty());
3698 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3699
3700 let (_, handles) =
3702 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3703 .await
3704 .unwrap();
3705 for h in handles {
3706 h.await.unwrap();
3707 }
3708 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3709
3710 crons
3714 .get("default|blog|cron|0")
3715 .unwrap()
3716 .running
3717 .store(true, Ordering::Release);
3718 let (_, handles) =
3719 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3720 .await
3721 .unwrap();
3722 assert!(handles.is_empty());
3723 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3724 }
3725
3726 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3730 async fn cron_leader_gate_suppresses_crons_off_leader() {
3731 use boatramp_core::config::{
3732 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3733 };
3734 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3735 use boatramp_handlers::{HandlerEngine, Limits};
3736 use futures::StreamExt;
3737
3738 let storage = Arc::new(MemStorage::default());
3739 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3740 let deploy = DeployStore::new(storage.clone(), kv.clone());
3741
3742 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3743 let stream: ByteStream =
3744 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3745 deploy.put_blob(&hash, stream).await.unwrap();
3746 let mut files = std::collections::BTreeMap::new();
3747 files.insert(
3748 "counter.wasm".to_string(),
3749 FileEntry {
3750 hash: hash.clone(),
3751 size: KV_COUNTER.len() as u64,
3752 content_type: None,
3753 variants: std::collections::BTreeMap::new(),
3754 },
3755 );
3756 let manifest = Manifest {
3757 files,
3758 config: DeployConfig {
3759 handlers: vec![HandlerConfig {
3760 route: "/".into(),
3761 methods: Vec::new(),
3762 component: "counter.wasm".into(),
3763 imports: vec!["wasi:keyvalue".into()],
3764 streaming: false,
3765 limits: None,
3766 env: std::collections::BTreeMap::new(),
3767 invoke_targets: Vec::new(),
3768 }],
3769 crons: vec![CronConfig {
3770 schedule: "* * * * *".into(),
3771 route: "/".into(),
3772 overlap: Overlap::Skip,
3773 }],
3774 ..Default::default()
3775 },
3776 ..Default::default()
3777 };
3778 let id = deploy.put_manifest(&manifest).await.unwrap();
3779 deploy
3780 .activate(ProjectRef::DEFAULT, "blog", &id)
3781 .await
3782 .unwrap();
3783 deploy
3784 .set_site_config(
3785 ProjectRef::DEFAULT,
3786 "blog",
3787 &SiteConfig {
3788 handlers: Some(HandlersSiteConfig {
3789 enabled: true,
3790 allow_imports: vec!["wasi:keyvalue".into()],
3791 ..Default::default()
3792 }),
3793 ..Default::default()
3794 },
3795 )
3796 .await
3797 .unwrap();
3798
3799 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3800 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3801 rt.set_cron_leader_gate(Arc::new(|| false));
3803 let inner = rt.inner.clone().unwrap();
3804 let mut wasm = std::collections::HashMap::new();
3805 let mut crons = std::collections::HashMap::new();
3806 let mut sweep = std::collections::HashMap::new();
3807 let now = CronNow {
3808 minute: 0,
3809 hour: 0,
3810 dom: 1,
3811 month: 1,
3812 dow: 0,
3813 minute_stamp: 100,
3814 };
3815
3816 let (_, handles) =
3817 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3818 .await
3819 .unwrap();
3820 assert!(handles.is_empty(), "a non-leader must not fire crons");
3822 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3823 }
3824
3825 #[tokio::test]
3832 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3833 use boatramp_core::config::HandlersSiteConfig;
3834 use boatramp_core::project::ProjectRef;
3835 use boatramp_handlers::{HandlerEngine, Limits};
3836
3837 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3838 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3839 let sql_dir =
3841 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3842 let _ = std::fs::remove_dir_all(&sql_dir);
3843 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3844 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3845
3846 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3847 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3848 let inner = rt.inner.as_ref().unwrap();
3849
3850 let site = HandlersSiteConfig {
3852 enabled: true,
3853 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3854 ..Default::default()
3855 };
3856 let env = std::collections::BTreeMap::new();
3857 let build = |imports: &[&str]| {
3858 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3859 let site = &site;
3860 let env = &env;
3861 async move {
3862 crate::handler_dispatch::build_bindings(
3863 inner,
3864 ProjectRef::new("default"),
3865 "shop",
3866 "shop",
3867 None,
3868 &imports,
3869 site,
3870 env,
3871 &[],
3872 0,
3873 None,
3874 )
3875 .await
3876 .expect("no secrets → resolves")
3877 .sql_database_names()
3878 }
3879 };
3880
3881 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3884 assert_eq!(
3886 build(&["sql", "sql:*"]).await,
3887 vec!["", "privileged", "product"]
3888 );
3889 assert!(build(&["sql:secret"]).await.is_empty());
3891 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3893
3894 let _ = std::fs::remove_dir_all(&sql_dir);
3895 }
3896}