use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::config::{ConsumerConfig, DeployConfig, HandlerConfig, HandlerLimits, Overlap};
use crate::file::FileEntry;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Owner {
Site(String),
Project(String),
}
impl std::fmt::Display for Owner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Site(s) => write!(f, "site:{s}"),
Self::Project(p) => write!(f, "project:{p}"),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Lifecycle {
#[default]
DeployPinned,
Independent,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Runtime {
#[default]
Wasm,
Microvm,
Container,
}
impl Runtime {
pub fn as_str(self) -> &'static str {
match self {
Self::Wasm => "wasm",
Self::Microvm => "microvm",
Self::Container => "container",
}
}
}
impl std::fmt::Display for Runtime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FunctionConfig {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limits: Option<HandlerLimits>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
pub runtime: Runtime,
#[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
pub quota: FunctionQuota,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhook: Option<WebhookConfig>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub invoke_targets: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookAlgorithm {
#[default]
HmacSha256,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebhookConfig {
pub secret_env: String,
#[serde(default)]
pub algorithm: WebhookAlgorithm,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature_header: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_body_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish: Option<String>,
}
impl WebhookConfig {
pub fn header(&self) -> &str {
self.signature_header
.as_deref()
.unwrap_or("x-boatramp-signature")
}
pub fn body_cap(&self) -> u64 {
self.max_body_bytes.unwrap_or(1024 * 1024)
}
}
impl FunctionConfig {
fn from_handler(h: &HandlerConfig) -> Self {
Self {
imports: h.imports.clone(),
limits: h.limits.clone(),
env: h.env.clone(),
runtime: Runtime::default(),
quota: FunctionQuota::default(),
webhook: None,
invoke_targets: Vec::new(),
}
}
fn from_consumer(c: &ConsumerConfig) -> Self {
Self {
imports: c.imports.clone(),
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FunctionRef {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trigger {
pub kind: TriggerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<FunctionRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerKind {
Route {
#[serde(default, skip_serializing_if = "Option::is_none")]
host: Option<String>,
path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
methods: Vec<String>,
},
Invoke { name: String },
Queue {
topic: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
group: String,
#[serde(default, skip_serializing_if = "crate::config::is_default_start")]
start: crate::config::StartPosition,
},
Cron {
schedule: String,
#[serde(default)]
overlap: Overlap,
},
Blob { prefix: String },
Webhook { path: String, secret_env: String },
Stream {
topics: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
websocket: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
publish_topic: Option<String>,
},
}
impl std::fmt::Display for Trigger {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.kind {
TriggerKind::Route { path, methods, .. } => {
let m = if methods.is_empty() {
"*".to_string()
} else {
methods.join(",")
};
write!(f, "route {m} {path}")
}
TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FunctionTrigger {
pub id: String,
pub kind: TriggerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fired_minute: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Function {
pub name: String,
pub owner: Owner,
pub versions: Vec<FunctionVersion>,
pub active: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub aliases: BTreeMap<String, String>,
pub config: FunctionConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("no version {id:?} in function {function:?}")]
pub struct UnknownVersion {
pub function: String,
pub id: String,
}
impl Function {
pub fn new(
name: impl Into<String>,
owner: Owner,
component_hash: impl Into<String>,
config: FunctionConfig,
lifecycle: Lifecycle,
created: u64,
) -> Self {
let hash = component_hash.into();
Self {
name: name.into(),
owner,
versions: vec![FunctionVersion {
id: hash.clone(),
component: hash.clone(),
created,
lifecycle,
}],
active: hash,
aliases: BTreeMap::new(),
config,
}
}
pub fn upsert_version(
&mut self,
component_hash: impl Into<String>,
lifecycle: Lifecycle,
created: u64,
) -> String {
let hash = component_hash.into();
if !self.versions.iter().any(|v| v.id == hash) {
self.versions.push(FunctionVersion {
id: hash.clone(),
component: hash.clone(),
created,
lifecycle,
});
}
self.active = hash.clone();
hash
}
pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
if self.versions.iter().any(|v| v.id == to) {
self.active = to.to_string();
Ok(())
} else {
Err(UnknownVersion {
function: self.name.clone(),
id: to.to_string(),
})
}
}
pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
if self.versions.iter().any(|v| v.id == version) {
self.aliases.insert(label.to_string(), version.to_string());
Ok(())
} else {
Err(UnknownVersion {
function: self.name.clone(),
id: version.to_string(),
})
}
}
pub fn resolve(&self, reference: &str) -> Option<&str> {
let id = self
.aliases
.get(reference)
.map(String::as_str)
.unwrap_or(reference);
self.versions
.iter()
.find(|v| v.id == id)
.map(|v| v.component.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FunctionVersion {
pub id: String,
pub component: String,
pub created: u64,
#[serde(default)]
pub lifecycle: Lifecycle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FunctionSpec {
pub name: String,
pub component: String,
pub config: FunctionConfig,
pub lifecycle: Lifecycle,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InvokeMode {
#[default]
Sync,
Async,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InvocationStatus {
#[default]
Queued,
Running,
Succeeded,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InvocationResult {
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
pub body_b64: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Invocation {
pub id: String,
pub function: String,
pub version: String,
pub mode: InvokeMode,
pub status: InvocationStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(default)]
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lease_expires: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_b64: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_content_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<InvocationResult>,
pub created: u64,
pub updated: u64,
}
impl Invocation {
pub fn is_terminal(&self) -> bool {
matches!(
self.status,
InvocationStatus::Succeeded | InvocationStatus::Failed
)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FunctionQuota {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_invocations: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub window_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent: Option<u32>,
}
impl FunctionQuota {
pub fn is_unset(&self) -> bool {
self.max_invocations.is_none() && self.max_concurrent.is_none()
}
pub fn window(&self) -> u64 {
self.window_secs.unwrap_or(60)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MeteringSample {
pub success: bool,
pub duration_ms: u64,
pub bytes_in: u64,
pub bytes_out: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Metering {
pub function: String,
pub invocations: u64,
pub successes: u64,
pub failures: u64,
pub duration_ms_total: u64,
pub bytes_in_total: u64,
pub bytes_out_total: u64,
pub window_start: u64,
pub window_count: u64,
pub updated: u64,
}
impl Metering {
pub fn new(function: impl Into<String>) -> Self {
Self {
function: function.into(),
..Default::default()
}
}
pub fn record(&mut self, sample: &MeteringSample, now: u64) {
self.invocations += 1;
if sample.success {
self.successes += 1;
} else {
self.failures += 1;
}
self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
self.updated = now;
}
pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
let Some(max) = quota.max_invocations else {
return true;
};
let window = quota.window();
if now.saturating_sub(self.window_start) >= window {
self.window_start = now;
self.window_count = 0;
}
if self.window_count >= max {
return false;
}
self.window_count += 1;
self.updated = now;
true
}
}
pub mod keys {
pub fn meta(project: &str, name: &str) -> String {
format!("project/{project}/functions/{name}")
}
pub fn version(project: &str, name: &str, id: &str) -> String {
format!("project/{project}/functions/{name}/versions/{id}")
}
pub fn alias(project: &str, name: &str, label: &str) -> String {
format!("project/{project}/functions/{name}/alias/{label}")
}
pub fn trigger(project: &str, name: &str, id: &str) -> String {
format!("project/{project}/functions/{name}/triggers/{id}")
}
pub fn invocation(project: &str, name: &str, id: &str) -> String {
format!("project/{project}/functions/{name}/invocations/{id}")
}
pub fn invocations_prefix(project: &str, name: &str) -> String {
format!("project/{project}/functions/{name}/invocations/")
}
pub fn idempotency(project: &str, name: &str, key: &str) -> String {
format!("project/{project}/functions/{name}/idem/{key}")
}
pub fn metering(project: &str, name: &str) -> String {
format!("project/{project}/metering/{name}")
}
pub fn functions_prefix(project: &str) -> String {
format!("project/{project}/functions/")
}
pub fn triggers_prefix(project: &str, name: &str) -> String {
format!("project/{project}/functions/{name}/triggers/")
}
pub fn metering_prefix(project: &str) -> String {
format!("project/{project}/metering/")
}
}
pub fn handler_name(route: &str) -> String {
let s = slug(route);
if s.is_empty() {
"root".to_string()
} else {
s
}
}
pub fn consumer_name(topic: &str) -> String {
format!("consumer-{}", slug(topic))
}
fn slug(s: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
dash = false;
} else if !out.is_empty() && !dash {
out.push('-');
dash = true;
}
}
out.trim_matches('-').to_string()
}
pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
let mut functions = Vec::new();
let mut triggers = Vec::new();
for h in &cfg.handlers {
let name = handler_name(&h.route);
functions.push(FunctionSpec {
name: name.clone(),
component: h.component.clone(),
config: FunctionConfig::from_handler(h),
lifecycle: Lifecycle::DeployPinned,
});
triggers.push(Trigger {
kind: TriggerKind::Route {
host: None,
path: h.route.clone(),
methods: h.methods.clone(),
},
target: Some(FunctionRef {
name,
version: None,
}),
});
}
for c in &cfg.consumers {
let name = consumer_name(&c.topic);
functions.push(FunctionSpec {
name: name.clone(),
component: c.component.clone(),
config: FunctionConfig::from_consumer(c),
lifecycle: Lifecycle::DeployPinned,
});
triggers.push(Trigger {
kind: TriggerKind::Queue {
topic: c.topic.clone(),
group: c.group.clone(),
start: c.start,
},
target: Some(FunctionRef {
name,
version: None,
}),
});
}
for cr in &cfg.crons {
let target = cfg
.handlers
.iter()
.find(|h| h.route == cr.route)
.map(|h| FunctionRef {
name: handler_name(&h.route),
version: None,
});
triggers.push(Trigger {
kind: TriggerKind::Cron {
schedule: cr.schedule.clone(),
overlap: cr.overlap,
},
target,
});
}
for s in &cfg.streams {
triggers.push(Trigger {
kind: TriggerKind::Stream {
topics: s.topics.clone(),
websocket: s.websocket,
publish_topic: s.publish_topic.clone(),
},
target: None,
});
}
(functions, triggers)
}
pub fn materialize(
specs: &[FunctionSpec],
site: &str,
files: &BTreeMap<String, FileEntry>,
created: u64,
) -> Vec<Function> {
specs
.iter()
.filter_map(|s| {
let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
Some(Function {
name: s.name.clone(),
owner: Owner::Site(site.to_string()),
versions: vec![FunctionVersion {
id: hash.clone(),
component: hash.clone(),
created,
lifecycle: s.lifecycle,
}],
active: hash,
aliases: BTreeMap::new(),
config: s.config.clone(),
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FunctionSummary {
pub name: String,
pub owner: String,
pub runtime: String,
pub version: String,
pub triggers: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
HandlerConfig {
route: route.into(),
methods: methods
.iter()
.map(std::string::ToString::to_string)
.collect(),
component: component.into(),
imports: imports
.iter()
.map(std::string::ToString::to_string)
.collect(),
limits: None,
env: BTreeMap::new(),
invoke_targets: Vec::new(),
}
}
#[test]
fn slugs_and_names() {
assert_eq!(handler_name("/api/hello"), "api-hello");
assert_eq!(handler_name("/"), "root");
assert_eq!(handler_name("/a/b/*"), "a-b");
assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
}
#[test]
fn desugar_preserves_all_compute_config() {
let cfg = DeployConfig {
handlers: vec![
handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
handler("/api/report", "report.wasm", &[], &[]),
],
consumers: vec![ConsumerConfig {
topic: "orders".into(),
component: "orders.wasm".into(),
imports: vec!["sql".into()],
group: String::new(),
start: Default::default(),
}],
crons: vec![CronConfig {
schedule: "0 * * * *".into(),
route: "/api/report".into(),
overlap: Overlap::Skip,
}],
streams: vec![StreamConfig {
route: "/live".into(),
topics: vec!["ticks".into()],
websocket: false,
publish_topic: None,
}],
..Default::default()
};
let (functions, triggers) = desugar(&cfg);
assert_eq!(functions.len(), 3);
let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
assert_eq!(hello.component, "hello.wasm");
assert_eq!(hello.config.imports, vec!["kv".to_string()]);
assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
assert_eq!(hello.config.runtime, Runtime::Wasm);
let consumer = functions
.iter()
.find(|f| f.name == "consumer-orders")
.unwrap();
assert_eq!(consumer.component, "orders.wasm");
assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
assert_eq!(triggers.len(), 5);
let route = triggers
.iter()
.find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
.unwrap();
match &route.kind {
TriggerKind::Route { methods, host, .. } => {
assert_eq!(methods, &["GET".to_string()]);
assert!(host.is_none());
}
_ => unreachable!(),
}
assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
let queue = triggers
.iter()
.find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
.unwrap();
assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
let cron = triggers
.iter()
.find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
.unwrap();
assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
let stream = triggers
.iter()
.find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
.unwrap();
assert!(stream.target.is_none());
match &stream.kind {
TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
_ => unreachable!(),
}
}
#[test]
fn materialize_resolves_paths_to_blob_hashes() {
let cfg = DeployConfig {
handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
..Default::default()
};
let (specs, _) = desugar(&cfg);
let files = BTreeMap::from([(
"hello.wasm".to_string(),
FileEntry {
hash: "sha256:abc".into(),
size: 10,
content_type: None,
variants: BTreeMap::new(),
},
)]);
let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
assert_eq!(funcs.len(), 1);
assert_eq!(funcs[0].name, "api-hello");
assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
assert_eq!(funcs[0].active, "sha256:abc");
assert_eq!(funcs[0].versions[0].component, "sha256:abc");
assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
}
#[test]
fn empty_config_desugars_to_nothing() {
let (functions, triggers) = desugar(&DeployConfig::default());
assert!(functions.is_empty() && triggers.is_empty());
}
#[test]
fn model_serde_round_trips() {
let f = Function {
name: "resize".into(),
owner: Owner::Project("acme".into()),
versions: vec![FunctionVersion {
id: "v1abc".into(),
component: "blob:deadbeef".into(),
created: 1_800_000_000,
lifecycle: Lifecycle::Independent,
}],
active: "v1abc".into(),
aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
config: FunctionConfig {
imports: vec!["blobstore".into()],
runtime: Runtime::Microvm,
..Default::default()
},
};
let json = serde_json::to_string(&f).unwrap();
assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
for t in [
Trigger {
kind: TriggerKind::Route {
host: Some("example.com".into()),
path: "/x".into(),
methods: vec!["POST".into()],
},
target: Some(FunctionRef {
name: "resize".into(),
version: None,
}),
},
Trigger {
kind: TriggerKind::Stream {
topics: vec!["t".into()],
websocket: true,
publish_topic: Some("up".into()),
},
target: None,
},
] {
let j = serde_json::to_string(&t).unwrap();
assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
}
}
#[test]
fn versioning_alias_and_rollback() {
let mut f = Function::new(
"resize",
Owner::Project("acme".into()),
"hashA",
FunctionConfig::default(),
Lifecycle::Independent,
1,
);
assert_eq!(f.active, "hashA");
assert_eq!(f.versions.len(), 1);
f.upsert_version("hashB", Lifecycle::Independent, 2);
assert_eq!(f.active, "hashB");
assert_eq!(f.versions.len(), 2);
f.upsert_version("hashA", Lifecycle::Independent, 3);
assert_eq!(f.active, "hashA");
assert_eq!(f.versions.len(), 2);
f.set_alias("prod", "hashB").unwrap();
assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
assert!(f.set_alias("prod", "ghost").is_err());
f.rollback("hashB").unwrap();
assert_eq!(f.active, "hashB");
assert!(f.rollback("ghost").is_err());
assert_eq!(f.resolve("hashA"), Some("hashA"));
assert_eq!(f.resolve("prod"), Some("hashB")); assert_eq!(f.resolve("ghost"), None);
}
#[test]
fn invocation_model_round_trips_and_reports_terminal() {
let inv = Invocation {
id: "inv-1".into(),
function: "greeter".into(),
version: "hashA".into(),
mode: InvokeMode::Async,
status: InvocationStatus::Queued,
idempotency_key: Some("k".into()),
attempts: 0,
lease_expires: None,
request_b64: Some("aGk=".into()),
request_content_type: Some("text/plain".into()),
result: None,
created: 1,
updated: 1,
};
assert!(!inv.is_terminal());
let json = serde_json::to_string(&inv).unwrap();
let back: Invocation = serde_json::from_str(&json).unwrap();
assert_eq!(back, inv);
assert!(json.contains("\"mode\":\"async\""));
assert!(json.contains("\"status\":\"queued\""));
let done = Invocation {
status: InvocationStatus::Succeeded,
result: Some(InvocationResult {
status: 200,
content_type: None,
body_b64: "b2s=".into(),
}),
..inv
};
assert!(done.is_terminal());
}
#[test]
fn metering_records_and_rate_limits() {
let mut m = Metering::new("greeter");
m.record(
&MeteringSample {
success: true,
duration_ms: 5,
bytes_in: 3,
bytes_out: 7,
},
100,
);
m.record(
&MeteringSample {
success: false,
duration_ms: 2,
bytes_in: 0,
bytes_out: 0,
},
101,
);
assert_eq!(m.invocations, 2);
assert_eq!(m.successes, 1);
assert_eq!(m.failures, 1);
assert_eq!(m.duration_ms_total, 7);
assert_eq!(m.bytes_out_total, 7);
assert_eq!(m.updated, 101);
let quota = FunctionQuota {
max_invocations: Some(2),
window_secs: Some(10),
max_concurrent: None,
};
let mut r = Metering::new("greeter");
assert!(r.admit("a, 1000)); assert!(r.admit("a, 1001)); assert!(!r.admit("a, 1002)); assert_eq!(r.window_count, 2);
assert!(r.admit("a, 1011));
assert_eq!(r.window_count, 1);
let unset = FunctionQuota::default();
let mut u = Metering::new("greeter");
assert!(u.admit(&unset, 1));
assert_eq!(u.window_count, 0);
assert!(unset.is_unset());
}
#[test]
fn webhook_config_defaults_and_round_trips() {
let w = WebhookConfig {
secret_env: "HOOK_SECRET".into(),
algorithm: WebhookAlgorithm::HmacSha256,
signature_header: None,
max_body_bytes: None,
publish: None,
};
assert_eq!(w.header(), "x-boatramp-signature");
assert_eq!(w.body_cap(), 1024 * 1024);
let cfg = FunctionConfig {
webhook: Some(w),
..Default::default()
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
assert!(json.contains("\"hmac_sha256\""));
let back: FunctionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back, cfg);
let custom = WebhookConfig {
secret_env: "S".into(),
algorithm: WebhookAlgorithm::HmacSha256,
signature_header: Some("x-hub-signature-256".into()),
max_body_bytes: Some(4096),
publish: None,
};
assert_eq!(custom.header(), "x-hub-signature-256");
assert_eq!(custom.body_cap(), 4096);
}
#[test]
fn keyspace_is_stable() {
assert_eq!(
keys::meta("default", "resize"),
"project/default/functions/resize"
);
assert_eq!(
keys::version("default", "resize", "v1"),
"project/default/functions/resize/versions/v1"
);
assert_eq!(
keys::alias("default", "resize", "prod"),
"project/default/functions/resize/alias/prod"
);
assert_eq!(
keys::trigger("default", "resize", "t1"),
"project/default/functions/resize/triggers/t1"
);
assert_eq!(
keys::invocation("default", "resize", "inv-1"),
"project/default/functions/resize/invocations/inv-1"
);
assert_eq!(
keys::invocations_prefix("default", "resize"),
"project/default/functions/resize/invocations/"
);
assert_eq!(
keys::idempotency("default", "resize", "k-1"),
"project/default/functions/resize/idem/k-1"
);
assert_eq!(
keys::metering("default", "resize"),
"project/default/metering/resize"
);
assert_eq!(
keys::meta("acme", "resize"),
"project/acme/functions/resize"
);
}
}