use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::extract::{ConnectInfo, Path, Query, Request, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{any, get, post, put};
use axum::{Extension, Json, Router};
use boatramp_core::access::{AccessConfig, BasicAuth};
use boatramp_core::authz::{GrantedRole, TokenMeta};
use boatramp_core::config::{DeployConfig, SiteConfig};
use boatramp_core::cose::{self, Claims, Signer};
use boatramp_core::deploy::{
DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
};
use boatramp_core::matcher::Pattern;
use boatramp_core::route::{self, Outcome};
use boatramp_core::{DeployError, StorageError};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
mod admin_api;
pub mod sql_shim;
#[cfg(feature = "oidc")]
pub(crate) use admin_api::auth_exchange;
pub(crate) use admin_api::{
activate_deployment, cert_status, create_deployment, current_deployment, delete_compute,
delete_site, get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
list_aliases, list_compute, list_deployments, list_sites, prune_delete, prune_report, put_blob,
put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
scrub_blobs, set_alias,
};
#[cfg(feature = "handlers")]
pub(crate) use admin_api::{
delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
put_graphql_subgraph, register_graphql_safelist,
};
mod auth;
#[cfg(feature = "console")]
pub mod console;
mod content;
mod control_api;
#[cfg(feature = "compression")]
pub(crate) use content::maybe_compress;
pub(crate) use content::multipart_byteranges;
pub(crate) use content::{
negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
};
pub(crate) use control_api::{
add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
};
#[cfg(all(test, feature = "handlers"))]
use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
mod domain_verify;
pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
pub mod envelope;
#[cfg(feature = "handlers")]
mod graphql_apq;
#[cfg(feature = "handlers")]
mod graphql_data;
#[cfg(feature = "handlers")]
mod graphql_federation;
#[cfg(feature = "handlers")]
mod graphql_gateway;
#[cfg(feature = "handlers")]
mod graphql_graphiql;
#[cfg(feature = "handlers")]
mod graphql_guard;
#[cfg(feature = "handlers")]
mod graphql_plan;
#[cfg(feature = "handlers")]
mod graphql_registry;
#[cfg(feature = "handlers")]
mod graphql_subscription;
#[cfg(feature = "handlers")]
mod handler_cache;
#[cfg(feature = "handlers")]
mod handler_dispatch;
#[cfg(feature = "handlers")]
pub(crate) use handler_dispatch::{
build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
read_blob_fully,
};
#[cfg(all(feature = "handlers", test))]
use handler_dispatch::{resolve_env, set_forwarded_headers};
mod function_api;
pub(crate) use function_api::{
alias_function, deploy_function, list_functions, remove_function, rollback_function,
};
#[cfg(all(test, feature = "handlers"))]
use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
mod gateway;
mod host;
pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
#[cfg(feature = "http3")]
mod http3;
mod limits;
#[cfg(feature = "handlers")]
mod logs;
#[cfg(feature = "handlers")]
mod metrics;
#[cfg(feature = "oidc")]
mod oidc;
mod operator;
pub(crate) use operator::prometheus_metrics;
#[cfg(feature = "handlers")]
pub(crate) use operator::{
operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
};
mod proxy;
pub use proxy::spawn_compute_reconcile;
pub(crate) use proxy::{
await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
proxy, COMPUTE_WAKE_TIMEOUT,
};
#[cfg(feature = "handlers")]
pub(crate) use proxy::is_upgrade_request;
#[cfg(all(test, feature = "handlers"))]
use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
mod project_api;
pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
mod project_scope;
pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
mod ratelimit;
mod routes;
pub use routes::{router, router_with};
#[cfg(feature = "mcp")]
mod mcp_http;
#[cfg(feature = "handlers")]
mod scheduler;
mod serve_pipeline;
pub use serve_pipeline::http_redirect_router;
#[cfg(all(test, feature = "handlers"))]
use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
pub(crate) use serve_pipeline::{
serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
BootstrapAttestation,
};
pub mod signer;
mod srvmetrics;
#[cfg(all(feature = "handlers", test))]
use scheduler::run_scheduler_tick;
#[cfg(feature = "handlers")]
pub(crate) use scheduler::{
acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
};
#[cfg(feature = "handlers")]
use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
#[cfg(feature = "handlers")]
mod function_runtime;
#[cfg(feature = "handlers")]
pub(crate) use function_runtime::{
b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
put_trigger_handler, webhook_ingress,
};
#[cfg(feature = "handlers")]
mod stream;
#[cfg(feature = "handlers")]
mod workflow;
pub use auth::{require_auth, Auth};
#[cfg(feature = "http3")]
pub use http3::{
advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
Http3Error,
};
pub use limits::{ServerLimits, UploadGuard};
#[cfg(feature = "oidc")]
pub use oidc::{OidcConfig, OidcError, OidcVerifier};
use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
#[cfg(feature = "handlers")]
pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
#[cfg(feature = "handlers")]
pub(crate) use workflow::{
define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
get_workflow_run_handler, list_workflows_handler, start_workflow_run,
};
pub use srvmetrics::{server_metrics, ServerMetrics};
#[derive(Clone, Default)]
pub struct HandlerRuntime {
#[cfg(feature = "handlers")]
inner: Option<Arc<HandlerRuntimeInner>>,
}
#[cfg(feature = "handlers")]
struct HandlerRuntimeInner {
engine: boatramp_handlers::HandlerEngine,
kv: Arc<dyn boatramp_core::kv::KvStore>,
storage: Arc<dyn boatramp_core::Storage>,
sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
site_semaphores:
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
stream_semaphores:
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
metrics: metrics::Metrics,
logs: Arc<logs::LogStore>,
cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
max_blob_bytes: std::sync::OnceLock<u64>,
max_component_bytes: std::sync::OnceLock<u64>,
function_meter_locks:
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
function_semaphores:
std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
}
pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
impl HandlerRuntime {
pub fn disabled() -> Self {
Self::default()
}
#[cfg(feature = "handlers")]
pub fn new(
engine: boatramp_handlers::HandlerEngine,
kv: Arc<dyn boatramp_core::kv::KvStore>,
storage: Arc<dyn boatramp_core::Storage>,
sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
) -> Self {
Self {
inner: Some(Arc::new(HandlerRuntimeInner {
engine,
kv,
storage,
sql,
messaging,
site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
metrics: metrics::Metrics::default(),
logs: Arc::new(logs::LogStore::default()),
cron_leader_gate: std::sync::OnceLock::new(),
max_blob_bytes: std::sync::OnceLock::new(),
max_component_bytes: std::sync::OnceLock::new(),
function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
watch_provider: std::sync::OnceLock::new(),
provision_tier: std::sync::OnceLock::new(),
invoker: std::sync::OnceLock::new(),
federation_runner: std::sync::OnceLock::new(),
})),
}
}
#[cfg(feature = "handlers")]
pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
self.inner.as_ref().and_then(|inner| inner.sql.clone())
}
#[cfg(feature = "handlers")]
pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
self.inner
.as_ref()
.and_then(|inner| inner.invoker.get().cloned())
}
#[cfg(feature = "handlers")]
pub(crate) async fn introspect_subgraph_sdl(
&self,
deploy: &DeployStore,
project: boatramp_core::project::ProjectRef<'_>,
function: &boatramp_core::function::Function,
component: &str,
) -> Result<String, function_runtime::SubgraphSdlError> {
match self.inner.as_ref() {
Some(inner) => {
function_runtime::introspect_service_sdl(
inner, deploy, project, function, component,
)
.await
}
None => Err(function_runtime::SubgraphSdlError::Unavailable),
}
}
#[cfg(feature = "handlers")]
pub fn set_invoker(&self, deploy: DeployStore) {
if let Some(inner) = self.inner.as_ref() {
let invoker = Arc::new(function_runtime::FunctionInvoker::new(
deploy,
Arc::downgrade(inner),
));
let _ = inner.invoker.set(invoker);
let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
inner,
)));
let _ = inner.federation_runner.set(runner);
}
}
#[cfg(feature = "handlers")]
pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
self.inner.as_ref().and_then(|inner| inner.sql.clone())
}
#[cfg(feature = "handlers")]
pub fn set_watch_provider(
&self,
provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
) {
if let Some(inner) = self.inner.as_ref() {
let _ = inner.watch_provider.set(provider);
}
}
#[cfg(feature = "handlers")]
pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
if let Some(inner) = self.inner.as_ref() {
let _ = inner.provision_tier.set(tier);
}
}
#[cfg(feature = "handlers")]
pub fn set_max_blob_bytes(&self, max_bytes: u64) {
if let Some(inner) = self.inner.as_ref() {
let _ = inner.max_blob_bytes.set(max_bytes);
}
}
#[cfg(feature = "handlers")]
pub fn set_max_component_bytes(&self, max_bytes: u64) {
if let Some(inner) = self.inner.as_ref() {
let _ = inner.max_component_bytes.set(max_bytes);
}
}
#[cfg(feature = "handlers")]
pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
if let Some(inner) = self.inner.as_ref() {
let _ = inner.cron_leader_gate.set(gate);
}
}
#[cfg(feature = "handlers")]
async fn precheck_activation(
&self,
deploy: &DeployStore,
manifest: &Manifest,
site_config: Option<&SiteConfig>,
) -> Result<(), String> {
let Some(inner) = self.inner.as_ref() else {
return Ok(());
};
if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
return Ok(());
}
let site_handlers = site_config
.and_then(|c| c.handlers.as_ref())
.filter(|h| h.enabled)
.ok_or_else(|| {
"deployment ships handlers/consumers but the site has them disabled".to_string()
})?;
let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
for handler in &manifest.config.handlers {
precheck_component(
deploy,
manifest,
site_handlers,
inner,
max_component,
&handler.imports,
&handler.component,
&format!("handler {:?}", handler.route),
)
.await?;
}
for consumer in &manifest.config.consumers {
precheck_component(
deploy,
manifest,
site_handlers,
inner,
max_component,
&consumer.imports,
&consumer.component,
&format!("consumer {:?}", consumer.topic),
)
.await?;
}
Ok(())
}
#[cfg(not(feature = "handlers"))]
async fn precheck_activation(
&self,
_deploy: &DeployStore,
_manifest: &Manifest,
_site_config: Option<&SiteConfig>,
) -> Result<(), String> {
Ok(())
}
}
#[derive(Default, Clone)]
pub struct ServerOptions {
pub limits: ServerLimits,
pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
pub default_site: Option<String>,
pub implicit_routing: bool,
pub protect_previews: bool,
pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
pub issuer: Option<Arc<dyn Signer>>,
pub bootstrap_secret: Option<String>,
pub bootstrap_attestation: Option<String>,
pub mesh_control: Option<Arc<dyn MeshControl>>,
pub cors_allowed_origins: Vec<String>,
#[cfg(feature = "oidc")]
pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
pub posture: boatramp_core::security::SecurityPosture,
pub served_over_tls: bool,
pub pop_origin: Option<String>,
pub daemon_runtime: Option<Arc<DaemonRuntime>>,
#[cfg(feature = "console")]
pub console: Option<console::ConsoleMount>,
}
#[derive(Clone, Copy)]
struct ServedOverTls(bool);
#[derive(Clone, Copy, Default)]
struct ImplicitRouting(bool);
const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
pub struct DaemonRuntime {
baseline: boatramp_core::daemon_config::ConfigBaseline,
state: std::sync::RwLock<DaemonState>,
reload: tokio::sync::Notify,
}
struct DaemonState {
effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
generation: Option<String>,
}
pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
#[cfg(feature = "console")]
let (console_enabled, console_host, console_path) = match options.console.as_ref() {
Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
None => (false, None, None),
};
#[cfg(not(feature = "console"))]
let (console_enabled, console_host, console_path) = (false, None, None);
boatramp_core::daemon_config::ConfigBaseline {
default_site: options.default_site.clone(),
protect_previews: options.protect_previews,
max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
compute_vcpus: 0,
compute_mem_mib: 0,
console_enabled,
console_host,
console_path,
max_upload_ceiling: options.posture.max_upload_bytes,
max_concurrent_uploads_ceiling: None,
posture: options.posture,
}
}
impl DaemonRuntime {
pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
let effective =
Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
Self {
baseline,
state: std::sync::RwLock::new(DaemonState {
effective,
generation: None,
}),
reload: tokio::sync::Notify::new(),
}
}
pub fn notify_reload(&self) {
self.reload.notify_one();
}
pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
self.state
.read()
.expect("daemon config lock")
.effective
.clone()
}
pub fn generation(&self) -> Option<String> {
self.state
.read()
.expect("daemon config lock")
.generation
.clone()
}
pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
&self.baseline
}
pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
let generation = deploy.daemon_config_generation().await?;
let effective = Arc::new(cfg.resolve(&self.baseline));
*self.state.write().expect("daemon config lock") = DaemonState {
effective,
generation,
};
Ok(())
}
}
#[derive(Clone, Copy, Default)]
struct PreviewPolicy {
protect: bool,
}
#[derive(Clone, Default)]
struct Issuer(Option<Arc<dyn Signer>>);
#[derive(Clone, Default)]
struct BootstrapGate(Option<Arc<BootstrapInner>>);
struct BootstrapInner {
secret_hash: String,
lock: tokio::sync::Mutex<()>,
}
impl BootstrapGate {
fn new(secret: Option<&str>) -> Self {
Self(secret.filter(|s| !s.is_empty()).map(|s| {
Arc::new(BootstrapInner {
secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
lock: tokio::sync::Mutex::new(()),
})
}))
}
}
#[async_trait::async_trait]
pub trait MeshControl: Send + Sync {
async fn admit(
&self,
mesh_pubkey_hex: &str,
jti: &str,
possession_proof: &[u8],
proof_iat: u64,
now: u64,
advertise_addr: Option<&str>,
) -> Result<JoinOutcome, String>;
async fn rotate_key(&self) -> Result<String, String>;
async fn revoke(&self, node: u64) -> Result<(), String>;
async fn members(&self) -> Result<Vec<MeshMember>, String>;
async fn promote(&self, node: u64) -> Result<(), String>;
}
pub enum JoinOutcome {
Admitted {
members: Vec<String>,
addrs: std::collections::BTreeMap<u64, String>,
},
TokenSpent,
ProofInvalid,
Revoked,
}
#[derive(Debug, Clone, Serialize)]
pub struct MeshMember {
pub node: u64,
pub voter: bool,
pub caught_up: bool,
pub leader: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub addr: Option<String>,
}
#[derive(Clone, Default)]
struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
#[cfg(feature = "oidc")]
#[derive(Clone, Default)]
struct OidcState(Option<Arc<oidc::OidcVerifier>>);
#[cfg(feature = "oidc")]
const EXCHANGE_TTL_SECS: u64 = 3600;
use boatramp_core::time::now_unix;
#[derive(Clone)]
struct CorsState(Arc<Vec<String>>);
const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
const CORS_MAX_AGE: &str = "600";
fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
allowed.iter().any(|a| a == "*" || a == origin)
}
async fn cors(
State(allowed): State<CorsState>,
request: Request,
next: axum::middleware::Next,
) -> Response {
let origin = request
.headers()
.get(header::ORIGIN)
.and_then(|v| v.to_str().ok())
.filter(|o| cors_origin_allowed(&allowed.0, o))
.map(str::to_string);
let is_preflight = request.method() == Method::OPTIONS
&& request
.headers()
.contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
if is_preflight {
let allow_headers = request
.headers()
.get(header::ACCESS_CONTROL_REQUEST_HEADERS)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
.unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
let mut response = Response::new(Body::empty());
*response.status_mut() = StatusCode::NO_CONTENT;
if let Some(origin) = origin {
let headers = response.headers_mut();
set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
set_header(headers, header::VARY, "Origin");
set_header(
headers,
header::ACCESS_CONTROL_ALLOW_METHODS,
CORS_ALLOW_METHODS,
);
set_header(
headers,
header::ACCESS_CONTROL_ALLOW_HEADERS,
&allow_headers,
);
set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
}
return response;
}
let mut response = next.run(request).await;
if let Some(origin) = origin {
let headers = response.headers_mut();
set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
if let Ok(value) = HeaderValue::from_str("Origin") {
headers.append(header::VARY, value);
}
}
response
}
const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
#[derive(Debug, thiserror::Error)]
pub enum ServeError {
#[error("server I/O: {0}")]
Io(#[from] std::io::Error),
}
pub async fn serve(
addr: SocketAddr,
deploy: DeployStore,
auth: Auth,
handlers: HandlerRuntime,
) -> Result<(), ServeError> {
serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
}
pub async fn serve_with(
addr: SocketAddr,
deploy: DeployStore,
auth: Auth,
handlers: HandlerRuntime,
options: ServerOptions,
) -> Result<(), ServeError> {
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
#[cfg(feature = "handlers")]
let scheduler = handlers.spawn_scheduler(deploy.clone());
let gateway_prober = gateway::spawn_active_health_prober();
let app = router_with(deploy, auth, handlers, options)
.into_make_service_with_connect_info::<SocketAddr>();
let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
shutdown_signal().await;
let _ = signalled_tx.send(true);
});
let signalled = {
let mut rx = signalled_rx;
async move {
let _ = rx.wait_for(|fired| *fired).await;
}
};
let result = serve_with_drain_deadline(
async move { server.await.map_err(ServeError::from) },
signalled,
DRAIN_DEADLINE,
)
.await;
#[cfg(feature = "handlers")]
if let Some(handle) = scheduler {
handle.abort();
}
gateway_prober.abort();
result
}
async fn serve_with_drain_deadline<Srv, Sig>(
server: Srv,
signalled: Sig,
deadline: Duration,
) -> Result<(), ServeError>
where
Srv: Future<Output = Result<(), ServeError>>,
Sig: Future<Output = ()>,
{
tokio::pin!(server);
let drain_cap = async move {
signalled.await;
tokio::time::sleep(deadline).await;
};
tokio::select! {
result = &mut server => result,
_ = drain_cap => {
tracing::warn!(
deadline_s = deadline.as_secs(),
"drain deadline exceeded; forcing shutdown with requests still in flight"
);
Ok(())
}
}
}
pub async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut sig) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
sig.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
tracing::info!("shutdown signal received; draining");
}
async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
match daemon.generation() {
Some(gen) => format!("ok gen={gen}"),
None => "ok".to_string(),
}
}
async fn readyz(State(deploy): State<DeployStore>) -> Response {
match deploy.ready().await {
Ok(()) => (StatusCode::OK, "ready\n").into_response(),
Err(err) => {
tracing::warn!(error = %err, "readiness probe failed");
(StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
}
}
}
struct AccessLog {
method: Method,
path: String,
host: String,
client: String,
status: u16,
encoding: String,
start: std::time::Instant,
bytes: std::sync::atomic::AtomicU64,
}
impl Drop for AccessLog {
fn drop(&mut self) {
let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
srvmetrics::server_metrics().record_request(self.status, bytes);
tracing::info!(
target: "boatramp::access",
method = %self.method,
path = %self.path,
host = %self.host,
client = %self.client,
status = self.status,
bytes = bytes,
encoding = %self.encoding,
cache_result = srvmetrics::cache_result(self.status),
elapsed_ms = self.start.elapsed().as_millis() as u64,
"request"
);
}
}
async fn access_log(request: axum::extract::Request, next: axum::middleware::Next) -> Response {
let method = request.method().clone();
let path = request.uri().path().to_string();
let host = request
.headers()
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.unwrap_or("-")
.to_string();
let client = request
.extensions()
.get::<axum::extract::ConnectInfo<SocketAddr>>()
.map(|info| info.0.ip().to_string())
.unwrap_or_else(|| "-".to_string());
let start = std::time::Instant::now();
let response = next.run(request).await;
let encoding = response
.headers()
.get(header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or("identity")
.to_string();
let log = AccessLog {
method,
path,
host,
client,
status: response.status().as_u16(),
encoding,
start,
bytes: std::sync::atomic::AtomicU64::new(0),
};
let (parts, body) = response.into_parts();
let counted = body.into_data_stream().map(move |chunk| {
if let Ok(bytes) = &chunk {
log.bytes
.fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
}
chunk
});
Response::from_parts(parts, Body::from_stream(counted))
}
fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
req_headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.map(str::trim)
.any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
})
}
fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
if let Ok(value) = HeaderValue::from_str(value) {
headers.insert(name, value);
}
}
fn not_found() -> Response {
(StatusCode::NOT_FOUND, "not found\n").into_response()
}
fn redirect(status: u16, location: &str) -> Response {
let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
match HeaderValue::from_str(location) {
Ok(location) => {
let mut headers = HeaderMap::new();
headers.insert(header::LOCATION, location);
(status, headers).into_response()
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
}
}
fn deploy_error_response(err: DeployError) -> Response {
let status = match &err {
DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
StatusCode::NOT_FOUND
}
DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
DeployError::Incomplete(_) => StatusCode::CONFLICT,
DeployError::Conflict(_) => StatusCode::CONFLICT,
DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
tracing::warn!(error = %err, "request failed");
(status, format!("{err}\n")).into_response()
}
fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
boatramp_core::project::validate_resource_name(kind, value)
.err()
.map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
}
#[cfg(test)]
mod drain_tests {
use super::*;
#[tokio::test]
async fn deadline_forces_shutdown_after_signal() {
let server = std::future::pending::<Result<(), ServeError>>();
let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn server_finishing_first_wins() {
let server = async { Ok(()) };
let signalled = std::future::pending::<()>();
let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn deadline_does_not_trip_before_signal() {
let server = async {
tokio::time::sleep(Duration::from_millis(40)).await;
Err(ServeError::Io(std::io::Error::other("server error")))
};
let signalled = std::future::pending::<()>();
let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
assert!(result.is_err());
}
}
#[cfg(all(test, feature = "handlers"))]
mod tests {
use super::*;
use boatramp_core::cose::{LocalSigner, TokenAlg};
use boatramp_core::project::ProjectRef;
#[test]
fn query_string_parses_and_url_decodes() {
let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
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")); }
#[test]
fn cookie_header_parses_pairs() {
let c = parse_cookie_header("beta=1; sid = abc ; empty=");
assert_eq!(c.get("beta").map(String::as_str), Some("1"));
assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
assert_eq!(c.get("empty").map(String::as_str), Some(""));
}
#[test]
fn apply_vary_merges_without_duplicates() {
let base = (StatusCode::OK, "x").into_response();
let r = apply_vary(base, &["accept-language".into()]);
assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
assert!(v.contains("accept-language") && v.contains("cookie"));
assert_eq!(v.matches("accept-language").count(), 1);
let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
assert!(plain.headers().get(header::VARY).is_none());
}
#[tokio::test]
async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
let public = keys.public_key();
let resp = create_join_token(
Extension(Issuer(Some(keys.clone()))),
Json(CreateJoinTokenRequest {
ttl_secs: Some(600),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = parsed["token"].as_str().unwrap();
let jti = cose::verify_join(token, &public, now_unix()).unwrap();
assert!(!jti.is_empty());
let no_issuer = create_join_token(
Extension(Issuer(None)),
Json(CreateJoinTokenRequest { ttl_secs: None }),
)
.await;
assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn function_write_path_deploy_rollback_alias_remove() {
use boatramp_core::function::Lifecycle;
use boatramp_core::kv::MemoryKv;
use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
struct FakeStorage {
present: bool,
}
#[async_trait::async_trait]
impl Storage for FakeStorage {
async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
Err(StorageError::NotFound(String::new()))
}
async fn get_range(
&self,
_: &str,
_: u64,
_: Option<u64>,
) -> Result<GetObject, StorageError> {
Err(StorageError::NotFound(String::new()))
}
async fn put(
&self,
_: &str,
_: ByteStream,
_: PutMeta,
) -> Result<ObjectMeta, StorageError> {
Err(StorageError::unsupported("fake"))
}
async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
if self.present {
Ok(ObjectMeta {
key: key.to_string(),
..Default::default()
})
} else {
Err(StorageError::NotFound(key.to_string()))
}
}
async fn delete(&self, _: &str) -> Result<(), StorageError> {
Ok(())
}
async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
Ok(Vec::new())
}
}
async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let value = if bytes.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
};
(status, value)
}
let deploy = DeployStore::new(
Arc::new(FakeStorage { present: true }),
Arc::new(MemoryKv::new()),
);
let v1 = "a".repeat(64);
let v2 = "b".repeat(64);
let (st, body) = body_json(
deploy_function(
State(deploy.clone()),
axum::extract::Extension(crate::ProjectContext::default()),
axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
axum::extract::Query(DeployFunctionQuery::default()),
Path("greeter".to_string()),
Json(FunctionUpsert {
component: v1.clone(),
config: Default::default(),
lifecycle: Lifecycle::Independent,
}),
)
.await,
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(body["active"], v1);
let (_, body) = body_json(
deploy_function(
State(deploy.clone()),
axum::extract::Extension(crate::ProjectContext::default()),
axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
axum::extract::Query(DeployFunctionQuery::default()),
Path("greeter".to_string()),
Json(FunctionUpsert {
component: v2.clone(),
config: Default::default(),
lifecycle: Lifecycle::Independent,
}),
)
.await,
)
.await;
assert_eq!(body["active"], v2);
assert_eq!(body["versions"].as_array().unwrap().len(), 2);
let (st, body) = body_json(
rollback_function(
State(deploy.clone()),
axum::extract::Extension(crate::ProjectContext::default()),
Path("greeter".to_string()),
Json(RollbackBody { to: v1.clone() }),
)
.await,
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(body["active"], v1);
let resp = rollback_function(
State(deploy.clone()),
axum::extract::Extension(crate::ProjectContext::default()),
Path("greeter".to_string()),
Json(RollbackBody { to: "c".repeat(64) }),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let (st, body) = body_json(
alias_function(
State(deploy.clone()),
axum::extract::Extension(crate::ProjectContext::default()),
Path(("greeter".to_string(), "prod".to_string())),
Json(AliasBody {
version: v2.clone(),
}),
)
.await,
)
.await;
assert_eq!(st, StatusCode::OK);
assert_eq!(body["aliases"]["prod"], v2);
let (st, _) = body_json(
remove_function(
State(deploy.clone()),
axum::extract::Extension(crate::ProjectContext::default()),
Path("greeter".to_string()),
)
.await,
)
.await;
assert_eq!(st, StatusCode::NO_CONTENT);
assert!(deploy
.get_function(ProjectRef::DEFAULT, "greeter")
.await
.unwrap()
.is_none());
let empty = DeployStore::new(
Arc::new(FakeStorage { present: false }),
Arc::new(MemoryKv::new()),
);
let resp = deploy_function(
State(empty),
axum::extract::Extension(crate::ProjectContext::default()),
axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
axum::extract::Query(DeployFunctionQuery::default()),
Path("orphan".to_string()),
Json(FunctionUpsert {
component: v1.clone(),
config: Default::default(),
lifecycle: Lifecycle::default(),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
struct StubControl {
admits: std::sync::Mutex<Vec<(String, String)>>,
respond: StubJoin,
}
#[derive(Clone, Copy)]
enum StubJoin {
Admit,
Spent,
Invalid,
Revoked,
}
#[async_trait::async_trait]
impl MeshControl for StubControl {
async fn admit(
&self,
mesh_pubkey_hex: &str,
jti: &str,
_proof: &[u8],
_proof_iat: u64,
_now: u64,
_advertise_addr: Option<&str>,
) -> Result<JoinOutcome, String> {
self.admits
.lock()
.unwrap()
.push((mesh_pubkey_hex.to_string(), jti.to_string()));
Ok(match self.respond {
StubJoin::Admit => JoinOutcome::Admitted {
members: vec!["signed-member".to_string()],
addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
},
StubJoin::Spent => JoinOutcome::TokenSpent,
StubJoin::Invalid => JoinOutcome::ProofInvalid,
StubJoin::Revoked => JoinOutcome::Revoked,
})
}
async fn rotate_key(&self) -> Result<String, String> {
Ok("cafe".to_string())
}
async fn revoke(&self, _node: u64) -> Result<(), String> {
Ok(())
}
async fn members(&self) -> Result<Vec<MeshMember>, String> {
Ok(Vec::new())
}
async fn promote(&self, _node: u64) -> Result<(), String> {
Ok(())
}
}
#[tokio::test]
async fn cluster_join_dispatches_and_maps_outcomes() {
let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let auth = Auth::with_key(keys.public_key(), kv);
let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
let req = |proof: &str| JoinRequest {
token: token.clone(),
mesh_pubkey: "302a300506032b6570032100feed".into(),
possession_proof: proof.to_string(),
proof_iat: now_unix(),
advertise_addr: Some("https://joiner:7000".into()),
};
let admitter = Arc::new(StubControl {
admits: std::sync::Mutex::new(Vec::new()),
respond: StubJoin::Admit,
});
let resp = cluster_join(
Extension(auth.clone()),
Extension(MeshControlHandle(Some(admitter.clone()))),
Json(req("aa01")),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(admitter.admits.lock().unwrap().len(), 1);
let spent = Arc::new(StubControl {
admits: std::sync::Mutex::new(Vec::new()),
respond: StubJoin::Spent,
});
assert_eq!(
cluster_join(
Extension(auth.clone()),
Extension(MeshControlHandle(Some(spent))),
Json(req("aa01")),
)
.await
.status(),
StatusCode::CONFLICT
);
let invalid = Arc::new(StubControl {
admits: std::sync::Mutex::new(Vec::new()),
respond: StubJoin::Invalid,
});
assert_eq!(
cluster_join(
Extension(auth.clone()),
Extension(MeshControlHandle(Some(invalid))),
Json(req("aa01")),
)
.await
.status(),
StatusCode::FORBIDDEN
);
let revoked = Arc::new(StubControl {
admits: std::sync::Mutex::new(Vec::new()),
respond: StubJoin::Revoked,
});
assert_eq!(
cluster_join(
Extension(auth.clone()),
Extension(MeshControlHandle(Some(revoked))),
Json(req("aa01")),
)
.await
.status(),
StatusCode::FORBIDDEN
);
let ok = Arc::new(StubControl {
admits: std::sync::Mutex::new(Vec::new()),
respond: StubJoin::Admit,
});
assert_eq!(
cluster_join(
Extension(auth.clone()),
Extension(MeshControlHandle(Some(ok))),
Json(req("not-hex")),
)
.await
.status(),
StatusCode::BAD_REQUEST
);
let none = cluster_join(
Extension(auth),
Extension(MeshControlHandle(None)),
Json(req("aa01")),
)
.await;
assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn bootstrap_mints_the_first_token_once() {
use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
let public = keys.public_key();
let deploy = DeployStore::new(
Arc::new(MemStorage::default()),
Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
);
let secret = "s3cr3t-bootstrap-value";
let gate = BootstrapGate::new(Some(secret));
let issuer = Issuer(Some(keys.clone()));
let bearer = |s: &str| {
let mut h = HeaderMap::new();
h.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
);
h
};
let req = || BootstrapRequest {
roles: vec!["admin".to_string()],
ttl_secs: None,
};
let bad = bootstrap_token(
State(deploy.clone()),
Extension(issuer.clone()),
Extension(gate.clone()),
bearer("wrong"),
Json(req()),
)
.await;
assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
let ok = bootstrap_token(
State(deploy.clone()),
Extension(issuer.clone()),
Extension(gate.clone()),
bearer(secret),
Json(req()),
)
.await;
assert_eq!(ok.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
let token = json["token"].as_str().unwrap();
let id = json["id"].as_str().unwrap();
let verified = cose::verify(token, &public, now_unix()).unwrap();
assert!(verified.roles.iter().any(|r| r.name == "admin"));
assert!(deploy
.list_token_meta()
.await
.unwrap()
.iter()
.any(|m| m.revocation_id == id));
let reuse = bootstrap_token(
State(deploy.clone()),
Extension(issuer.clone()),
Extension(gate),
bearer(secret),
Json(req()),
)
.await;
assert_eq!(reuse.status(), StatusCode::CONFLICT);
let disabled = bootstrap_token(
State(deploy),
Extension(issuer),
Extension(BootstrapGate(None)),
bearer(secret),
Json(req()),
)
.await;
assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
let control = Arc::new(StubControl {
admits: std::sync::Mutex::new(Vec::new()),
respond: StubJoin::Admit,
});
let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
}
#[test]
fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
use boatramp_core::security::SecurityProfile;
let strict = SecurityProfile::MultiTenant.preset();
let loose = SecurityProfile::SingleTenant.preset();
let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
let loopback: IpAddr = "127.0.0.1".parse().unwrap();
let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
assert!(gateway_addr_allowed(public, &strict));
assert!(!gateway_addr_allowed(private, &strict));
assert!(!gateway_addr_allowed(loopback, &strict));
assert!(!gateway_addr_allowed(metadata, &strict));
assert!(gateway_addr_allowed(public, &loose));
assert!(gateway_addr_allowed(private, &loose));
assert!(gateway_addr_allowed(loopback, &loose));
assert!(!gateway_addr_allowed(metadata, &loose));
}
#[test]
fn resolve_env_merges_static_and_host_secrets() {
use boatramp_core::config::HandlersSiteConfig;
std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
let deploy_env = std::collections::BTreeMap::from([
("GREETING".to_string(), "hi".to_string()),
("OVERRIDE_ME".to_string(), "static".to_string()),
]);
let site_handlers = HandlersSiteConfig {
enabled: true,
secrets: std::collections::BTreeMap::from([
(
"SECRET_TOKEN".to_string(),
"BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
),
(
"OVERRIDE_ME".to_string(),
"BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
),
(
"MISSING".to_string(),
"BOATRAMP_TEST_NOT_SET_VAR".to_string(),
),
]),
..Default::default()
};
let env = resolve_env("blog", &deploy_env, &site_handlers);
assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
assert!(!env.iter().any(|(k, _)| k == "MISSING"));
std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
}
fn req() -> Request {
Request::builder()
.uri("/")
.header(header::HOST, "example.com")
.body(Body::empty())
.unwrap()
}
#[test]
fn forwarded_headers_set_standard_triple() {
let mut request = req();
set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
let h = request.headers();
assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
}
#[test]
fn forwarded_for_overwrites_spoofed_value() {
let mut request = Request::builder()
.uri("/")
.header(header::HOST, "example.com")
.header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
.body(Body::empty())
.unwrap();
set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
let values: Vec<_> = request
.headers()
.get_all("x-forwarded-for")
.iter()
.collect();
assert_eq!(values.len(), 1);
assert_eq!(values[0], "203.0.113.7");
}
#[test]
fn forwarded_proto_preserves_upstream_tls() {
let mut request = Request::builder()
.uri("/")
.header(header::HOST, "example.com")
.header("x-forwarded-proto", "https")
.body(Body::empty())
.unwrap();
set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
}
#[test]
fn forwarded_host_absent_when_no_host_header() {
let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
assert!(request.headers().get("x-forwarded-host").is_none());
assert_eq!(
request.headers().get("x-forwarded-for").unwrap(),
"203.0.113.7"
);
}
use boatramp_core::kv::{KvStore, MemoryKv};
use boatramp_core::messaging::{LogMessaging, Messaging};
use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
const EVENT_CONSUMER: &[u8] =
include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
#[derive(Default)]
struct MemStorage {
objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
}
#[async_trait::async_trait]
impl boatramp_core::Storage for MemStorage {
async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
let bytes = self
.objects
.lock()
.unwrap()
.get(key)
.cloned()
.ok_or_else(|| StorageError::NotFound(key.to_string()))?;
let body: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
Ok(GetObject {
meta: ObjectMeta {
key: key.to_string(),
..Default::default()
},
body,
})
}
async fn get_range(
&self,
key: &str,
_: u64,
_: Option<u64>,
) -> Result<GetObject, StorageError> {
self.get(key).await
}
async fn put(
&self,
key: &str,
mut body: ByteStream,
_: PutMeta,
) -> Result<ObjectMeta, StorageError> {
use futures::StreamExt;
let mut buf = Vec::new();
while let Some(chunk) = body.next().await {
buf.extend_from_slice(&chunk?);
}
self.objects.lock().unwrap().insert(key.to_string(), buf);
Ok(ObjectMeta {
key: key.to_string(),
..Default::default()
})
}
async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
self.objects
.lock()
.unwrap()
.get(key)
.map(|_| ObjectMeta {
key: key.to_string(),
..Default::default()
})
.ok_or_else(|| StorageError::NotFound(key.to_string()))
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
self.objects.lock().unwrap().remove(key);
Ok(())
}
async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
Ok(Vec::new())
}
}
fn observed_state(
workload: &str,
healthy: bool,
phase: boatramp_core::compute::ReplicaPhase,
) -> boatramp_core::compute::ObservedInstance {
use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
boatramp_core::compute::ObservedInstance {
handle: InstanceHandle {
workload: workload.into(),
replica: 0,
backend_ref: "ref-0".into(),
},
node: 1,
backend: "vmm".into(),
endpoint: Endpoint {
scheme: Scheme::Http,
host: "10.0.0.2".into(),
port: 80,
},
region: None,
healthy,
phase,
snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
workload: workload.into(),
replica: 0,
data_ref: "snap-0".into(),
}),
}
}
#[tokio::test]
async fn has_parked_replica_detects_a_zeroed_replica() {
use boatramp_core::compute::ReplicaPhase;
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage, kv);
assert!(!has_parked_replica(&deploy, "w").await);
deploy
.set_replica_state(
ProjectRef::DEFAULT,
&observed_state("w", true, ReplicaPhase::Running),
)
.await
.unwrap();
assert!(!has_parked_replica(&deploy, "w").await);
deploy
.set_replica_state(
ProjectRef::DEFAULT,
&observed_state("w", false, ReplicaPhase::Zero),
)
.await
.unwrap();
assert!(has_parked_replica(&deploy, "w").await);
}
#[tokio::test]
async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
use boatramp_core::compute::ReplicaPhase;
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage, kv);
let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
assert!(empty.is_empty());
deploy
.set_replica_state(
ProjectRef::DEFAULT,
&observed_state("w", true, ReplicaPhase::Running),
)
.await
.unwrap();
let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dispatcher_delivers_at_least_once_then_dead_letters() {
use boatramp_handlers::{Bindings, HandlerEngine, Limits};
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let mq = LogMessaging::new(storage, kv.clone());
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
let topic = "blog/orders/created";
for _ in 0..3 {
mq.publish(topic, b"ok").await.unwrap();
}
loop {
let acked = dispatch_consumer_batch(
&engine,
&mq,
&metrics::Metrics::default(),
"blog",
topic,
"blog/",
&hash,
EVENT_CONSUMER,
&bindings,
Limits::default(),
Duration::from_secs(30),
5,
10,
)
.await;
if acked == 0 {
break;
}
}
assert_eq!(
kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
Some(b"3".to_vec())
);
mq.publish(topic, b"fail").await.unwrap();
for _ in 0..5 {
dispatch_consumer_batch(
&engine,
&mq,
&metrics::Metrics::default(),
"blog",
topic,
"blog/",
&hash,
EVENT_CONSUMER,
&bindings,
Limits::default(),
Duration::ZERO,
2,
10,
)
.await;
}
assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
assert_eq!(
kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
Some(b"3".to_vec())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scheduler_runs_current_consumers_not_previews() {
use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
use boatramp_handlers::{HandlerEngine, Limits};
use futures::StreamExt;
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let messaging: Arc<dyn Messaging> =
Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
.boxed();
deploy.put_blob(&hash, stream).await.unwrap();
let mut files = std::collections::BTreeMap::new();
files.insert(
"consumer.wasm".to_string(),
FileEntry {
hash: hash.clone(),
size: EVENT_CONSUMER.len() as u64,
content_type: None,
variants: std::collections::BTreeMap::new(),
},
);
let manifest = Manifest {
files,
config: DeployConfig {
consumers: vec![ConsumerConfig {
topic: "orders/created".into(),
component: "consumer.wasm".into(),
imports: vec!["wasi:keyvalue".into()],
}],
..Default::default()
},
..Default::default()
};
let id = deploy.put_manifest(&manifest).await.unwrap();
deploy
.activate(ProjectRef::DEFAULT, "blog", &id)
.await
.unwrap();
deploy
.set_site_config(
ProjectRef::DEFAULT,
"blog",
&SiteConfig {
handlers: Some(HandlersSiteConfig {
enabled: true,
allow_imports: vec!["wasi:keyvalue".into()],
..Default::default()
}),
..Default::default()
},
)
.await
.unwrap();
messaging
.publish("blog/orders/created", b"live")
.await
.unwrap();
messaging
.publish("blog/_preview/abc/orders/created", b"preview")
.await
.unwrap();
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
let inner = rt.inner.clone().unwrap();
let mut cache = std::collections::HashMap::new();
let mut crons = std::collections::HashMap::new();
let now = CronNow {
minute: 0,
hour: 0,
dom: 1,
month: 1,
dow: 0,
minute_stamp: 0,
};
for _ in 0..3 {
run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, now)
.await
.unwrap();
}
assert_eq!(
kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
Some(b"1".to_vec())
);
assert_eq!(
kv.get("hkv/blog/_preview/abc/delivered/orders/created")
.await
.unwrap(),
None
);
}
const KV_COUNTER: &[u8] =
include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn function_invoker_runs_target_buffers_and_meters() {
use boatramp_core::deploy::DeployStore;
use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
use futures::StreamExt;
const HTTP_200: &[u8] =
include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
deploy.put_blob(&hash, stream).await.unwrap();
let function = Function {
name: "target".into(),
owner: Owner::Project("default".into()),
versions: vec![FunctionVersion {
id: "v1".into(),
component: hash.clone(),
created: 0,
lifecycle: Lifecycle::Independent,
}],
active: "v1".into(),
aliases: Default::default(),
config: Default::default(),
};
deploy
.put_function(ProjectRef::DEFAULT, &function)
.await
.unwrap();
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv, storage, None, None);
rt.set_invoker(deploy.clone());
let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
let request = || InvokeRequest {
method: "GET".into(),
path: "/".into(),
headers: vec![],
body: vec![],
};
let response = invoker.invoke("target", request(), 1).await.unwrap();
assert_eq!(response.status, 200);
let metering = deploy
.get_metering(ProjectRef::DEFAULT, "target")
.await
.unwrap()
.unwrap();
assert_eq!(metering.invocations, 1);
let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
assert!(matches!(err, InvokeError::NotFound));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn federation_runner_enforces_the_safelist_before_planning() {
use boatramp_core::deploy::DeployStore;
use boatramp_core::project::ProjectRef;
use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
rt.set_invoker(deploy.clone());
let runner = rt
.inner
.as_ref()
.unwrap()
.federation_runner
.get()
.unwrap()
.scoped(ProjectRef::new("default"));
let req = |query: &str| GraphqlRequest {
query: Some(query.to_string()),
persisted_hash: None,
variables: "{}".to_string(),
operation_name: None,
authorization: None,
};
assert!(matches!(
runner.run(req("{ me { id } }"), 1).await,
Err(SupergraphRunError::NotSafelisted)
));
let query = "{ me { id } }";
let hash = crate::graphql_apq::sha256_hex(query);
kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
.await
.unwrap();
assert!(matches!(
runner.run(req(query), 1).await,
Err(SupergraphRunError::PlanFailed(_))
));
let persisted = GraphqlRequest {
query: None,
persisted_hash: Some("deadbeef".to_string()),
variables: "{}".to_string(),
operation_name: None,
authorization: None,
};
assert!(matches!(
runner.run(persisted, 1).await,
Err(SupergraphRunError::NotSafelisted)
));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
use crate::scheduler::{run_scheduler_tick, CronNow};
use boatramp_core::deploy::DeployStore;
use boatramp_core::function::{
Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
};
use boatramp_handlers::{HandlerEngine, Limits};
use futures::StreamExt;
const HTTP_200: &[u8] =
include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
deploy.put_blob(&hash, stream).await.unwrap();
let acme = ProjectRef::new("acme");
let function = Function {
name: "worker".into(),
owner: Owner::Project("acme".into()),
versions: vec![FunctionVersion {
id: "v1".into(),
component: hash.clone(),
created: 0,
lifecycle: Lifecycle::Independent,
}],
active: "v1".into(),
aliases: Default::default(),
config: Default::default(),
};
deploy.put_function(acme, &function).await.unwrap();
let inv = Invocation {
id: "inv1".into(),
function: "worker".into(),
version: "v1".into(),
mode: InvokeMode::Async,
status: InvocationStatus::Queued,
idempotency_key: None,
attempts: 0,
request_b64: None,
request_content_type: None,
result: None,
created: 0,
updated: 0,
};
deploy.put_invocation(acme, &inv).await.unwrap();
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
let inner = rt.inner.as_ref().unwrap();
let mut wasm_cache = std::collections::HashMap::new();
let mut cron_state = std::collections::HashMap::new();
let now = CronNow {
minute: 0,
hour: 0,
dom: 1,
month: 1,
dow: 0,
minute_stamp: 0,
};
run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
.await
.unwrap();
let settled = deploy
.get_invocation(acme, "worker", "inv1")
.await
.unwrap()
.expect("invocation still present in acme");
assert_eq!(settled.status, InvocationStatus::Succeeded);
let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
assert_eq!(metering.invocations, 1);
assert!(deploy
.get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
.await
.unwrap()
.is_none());
assert!(deploy
.get_metering(ProjectRef::DEFAULT, "worker")
.await
.unwrap()
.is_none());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
use boatramp_core::deploy::DeployStore;
use boatramp_core::function::{
Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
};
use boatramp_handlers::{HandlerEngine, Limits};
use futures::StreamExt;
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
deploy.put_blob(&hash, stream).await.unwrap();
let store = Function {
name: "store".into(),
owner: Owner::Project("default".into()),
versions: vec![FunctionVersion {
id: "v1".into(),
component: hash.clone(),
created: 0,
lifecycle: Lifecycle::Independent,
}],
active: "v1".into(),
aliases: Default::default(),
config: FunctionConfig {
imports: vec!["wasi:keyvalue".into()],
..Default::default()
},
};
let acme = ProjectRef::new("acme");
let globex = ProjectRef::new("globex");
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
let inner = rt.inner.as_ref().unwrap();
let request = || {
axum::http::Request::builder()
.method("GET")
.uri("/")
.body(axum::body::Body::empty())
.unwrap()
};
let component = store.resolve(&store.active).unwrap().to_owned();
for project in [acme, globex, ProjectRef::DEFAULT] {
let (response, _) =
execute_function(inner, &deploy, project, &store, &component, request(), 0).await;
assert!(response.status().is_success(), "invocation should succeed");
}
assert_eq!(
kv.get("hkv/acme/fn/store/hits").await.unwrap(),
Some(b"1".to_vec()),
"acme's write must be tenant-qualified"
);
assert_eq!(
kv.get("hkv/globex/fn/store/hits").await.unwrap(),
Some(b"1".to_vec()),
"globex's write must be tenant-qualified"
);
assert_eq!(
kv.get("hkv/fn/store/hits").await.unwrap(),
Some(b"1".to_vec()),
"the default project must keep the byte-identical pre-project key"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
use boatramp_core::config::{
CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
};
use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
use boatramp_handlers::{HandlerEngine, Limits};
use futures::StreamExt;
use std::sync::atomic::Ordering;
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
deploy.put_blob(&hash, stream).await.unwrap();
let mut files = std::collections::BTreeMap::new();
files.insert(
"counter.wasm".to_string(),
FileEntry {
hash: hash.clone(),
size: KV_COUNTER.len() as u64,
content_type: None,
variants: std::collections::BTreeMap::new(),
},
);
let manifest = Manifest {
files,
config: DeployConfig {
handlers: vec![HandlerConfig {
route: "/".into(),
methods: Vec::new(),
component: "counter.wasm".into(),
imports: vec!["wasi:keyvalue".into()],
limits: None,
env: std::collections::BTreeMap::new(),
invoke_targets: Vec::new(),
}],
crons: vec![CronConfig {
schedule: "* * * * *".into(),
route: "/".into(),
overlap: Overlap::Skip,
}],
..Default::default()
},
..Default::default()
};
let id = deploy.put_manifest(&manifest).await.unwrap();
deploy
.activate(ProjectRef::DEFAULT, "blog", &id)
.await
.unwrap();
deploy
.set_site_config(
ProjectRef::DEFAULT,
"blog",
&SiteConfig {
handlers: Some(HandlersSiteConfig {
enabled: true,
allow_imports: vec!["wasi:keyvalue".into()],
..Default::default()
}),
..Default::default()
},
)
.await
.unwrap();
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
let inner = rt.inner.clone().unwrap();
let mut wasm = std::collections::HashMap::new();
let mut crons = std::collections::HashMap::new();
let at = |stamp| CronNow {
minute: 0,
hour: 0,
dom: 1,
month: 1,
dow: 0,
minute_stamp: stamp,
};
let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
.await
.unwrap();
for h in handles {
h.await.unwrap();
}
assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
.await
.unwrap();
assert!(handles.is_empty());
assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(101))
.await
.unwrap();
for h in handles {
h.await.unwrap();
}
assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
crons
.get("default|blog|cron|0")
.unwrap()
.running
.store(true, Ordering::Release);
let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(102))
.await
.unwrap();
assert!(handles.is_empty());
assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cron_leader_gate_suppresses_crons_off_leader() {
use boatramp_core::config::{
CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
};
use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
use boatramp_handlers::{HandlerEngine, Limits};
use futures::StreamExt;
let storage = Arc::new(MemStorage::default());
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let deploy = DeployStore::new(storage.clone(), kv.clone());
let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
let stream: ByteStream =
futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
deploy.put_blob(&hash, stream).await.unwrap();
let mut files = std::collections::BTreeMap::new();
files.insert(
"counter.wasm".to_string(),
FileEntry {
hash: hash.clone(),
size: KV_COUNTER.len() as u64,
content_type: None,
variants: std::collections::BTreeMap::new(),
},
);
let manifest = Manifest {
files,
config: DeployConfig {
handlers: vec![HandlerConfig {
route: "/".into(),
methods: Vec::new(),
component: "counter.wasm".into(),
imports: vec!["wasi:keyvalue".into()],
limits: None,
env: std::collections::BTreeMap::new(),
invoke_targets: Vec::new(),
}],
crons: vec![CronConfig {
schedule: "* * * * *".into(),
route: "/".into(),
overlap: Overlap::Skip,
}],
..Default::default()
},
..Default::default()
};
let id = deploy.put_manifest(&manifest).await.unwrap();
deploy
.activate(ProjectRef::DEFAULT, "blog", &id)
.await
.unwrap();
deploy
.set_site_config(
ProjectRef::DEFAULT,
"blog",
&SiteConfig {
handlers: Some(HandlersSiteConfig {
enabled: true,
allow_imports: vec!["wasi:keyvalue".into()],
..Default::default()
}),
..Default::default()
},
)
.await
.unwrap();
let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
rt.set_cron_leader_gate(Arc::new(|| false));
let inner = rt.inner.clone().unwrap();
let mut wasm = std::collections::HashMap::new();
let mut crons = std::collections::HashMap::new();
let now = CronNow {
minute: 0,
hour: 0,
dom: 1,
month: 1,
dow: 0,
minute_stamp: 100,
};
let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, now)
.await
.unwrap();
assert!(handles.is_empty(), "a non-leader must not fire crons");
assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
}
}