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