1use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::{ConsumerConfig, DeployConfig, HandlerConfig, HandlerLimits, Overlap};
21use crate::file::FileEntry;
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Owner {
29 Site(String),
31 Project(String),
33}
34
35impl std::fmt::Display for Owner {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::Site(s) => write!(f, "site:{s}"),
39 Self::Project(p) => write!(f, "project:{p}"),
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Lifecycle {
48 #[default]
50 DeployPinned,
51 Independent,
53}
54
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum Runtime {
61 #[default]
62 Wasm,
63 Microvm,
64 Container,
65}
66
67impl Runtime {
68 pub fn as_str(self) -> &'static str {
70 match self {
71 Self::Wasm => "wasm",
72 Self::Microvm => "microvm",
73 Self::Container => "container",
74 }
75 }
76}
77
78impl std::fmt::Display for Runtime {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.write_str(self.as_str())
81 }
82}
83
84#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct FunctionConfig {
91 #[serde(skip_serializing_if = "Vec::is_empty")]
93 pub imports: Vec<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub limits: Option<HandlerLimits>,
97 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
99 pub env: BTreeMap<String, String>,
100 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
105 pub secrets: BTreeMap<String, String>,
106 pub runtime: Runtime,
108 #[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
110 pub quota: FunctionQuota,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub webhook: Option<WebhookConfig>,
114 #[serde(skip_serializing_if = "Vec::is_empty")]
120 pub invoke_targets: Vec<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub tenancy: Option<crate::tenancy::Tenancy>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub token_claims: Option<crate::config::HandlerGraphqlTokenClaims>,
132}
133
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum WebhookAlgorithm {
138 #[default]
140 HmacSha256,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct WebhookConfig {
150 pub secret_env: String,
152 #[serde(default)]
154 pub algorithm: WebhookAlgorithm,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub signature_header: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub max_body_bytes: Option<u64>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub publish: Option<String>,
169}
170
171impl WebhookConfig {
172 pub fn header(&self) -> &str {
174 self.signature_header
175 .as_deref()
176 .unwrap_or("x-boatramp-signature")
177 }
178 pub fn body_cap(&self) -> u64 {
180 self.max_body_bytes.unwrap_or(1024 * 1024)
181 }
182}
183
184impl FunctionConfig {
185 fn from_handler(h: &HandlerConfig) -> Self {
186 Self {
187 imports: h.imports.clone(),
188 limits: h.limits.clone(),
189 env: h.env.clone(),
190 secrets: BTreeMap::new(),
194 runtime: Runtime::default(),
195 quota: FunctionQuota::default(),
196 webhook: None,
197 invoke_targets: Vec::new(),
198 tenancy: None,
200 token_claims: None,
201 }
202 }
203 fn from_consumer(c: &ConsumerConfig) -> Self {
204 Self {
205 imports: c.imports.clone(),
206 ..Default::default()
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct FunctionRef {
215 pub name: String,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub version: Option<String>,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct Trigger {
228 pub kind: TriggerKind,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub target: Option<FunctionRef>,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(tag = "type", rename_all = "snake_case")]
239pub enum TriggerKind {
240 Route {
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 host: Option<String>,
244 path: String,
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
246 methods: Vec<String>,
247 },
248 Invoke { name: String },
250 Queue {
254 topic: String,
255 #[serde(default, skip_serializing_if = "String::is_empty")]
256 group: String,
257 #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
258 start: crate::config::StartPosition,
259 },
260 Cron {
262 schedule: String,
263 #[serde(default)]
264 overlap: Overlap,
265 },
266 Blob { prefix: String },
268 Webhook { path: String, secret_env: String },
271 Stream {
274 topics: Vec<String>,
275 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
276 websocket: bool,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 publish_topic: Option<String>,
279 },
280}
281
282impl std::fmt::Display for Trigger {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 match &self.kind {
286 TriggerKind::Route { path, methods, .. } => {
287 let m = if methods.is_empty() {
288 "*".to_string()
289 } else {
290 methods.join(",")
291 };
292 write!(f, "route {m} {path}")
293 }
294 TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
295 TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
296 TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
297 TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
298 TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
299 TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
300 }
301 }
302}
303
304#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(deny_unknown_fields)]
310pub struct FunctionTrigger {
311 pub id: String,
313 pub kind: TriggerKind,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub last_fired_minute: Option<i64>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
324#[serde(deny_unknown_fields)]
325pub struct Function {
326 pub name: String,
328 pub owner: Owner,
330 pub versions: Vec<FunctionVersion>,
332 pub active: String,
334 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
336 pub aliases: BTreeMap<String, String>,
337 pub config: FunctionConfig,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
345#[error("no version {id:?} in function {function:?}")]
346pub struct UnknownVersion {
347 pub function: String,
349 pub id: String,
351}
352
353impl Function {
354 pub fn new(
357 name: impl Into<String>,
358 owner: Owner,
359 component_hash: impl Into<String>,
360 config: FunctionConfig,
361 lifecycle: Lifecycle,
362 created: u64,
363 ) -> Self {
364 let hash = component_hash.into();
365 Self {
366 name: name.into(),
367 owner,
368 versions: vec![FunctionVersion {
369 id: hash.clone(),
370 component: hash.clone(),
371 created,
372 lifecycle,
373 }],
374 active: hash,
375 aliases: BTreeMap::new(),
376 config,
377 }
378 }
379
380 pub fn upsert_version(
384 &mut self,
385 component_hash: impl Into<String>,
386 lifecycle: Lifecycle,
387 created: u64,
388 ) -> String {
389 let hash = component_hash.into();
390 if !self.versions.iter().any(|v| v.id == hash) {
391 self.versions.push(FunctionVersion {
392 id: hash.clone(),
393 component: hash.clone(),
394 created,
395 lifecycle,
396 });
397 }
398 self.active = hash.clone();
399 hash
400 }
401
402 pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
404 if self.versions.iter().any(|v| v.id == to) {
405 self.active = to.to_string();
406 Ok(())
407 } else {
408 Err(UnknownVersion {
409 function: self.name.clone(),
410 id: to.to_string(),
411 })
412 }
413 }
414
415 pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
417 if self.versions.iter().any(|v| v.id == version) {
418 self.aliases.insert(label.to_string(), version.to_string());
419 Ok(())
420 } else {
421 Err(UnknownVersion {
422 function: self.name.clone(),
423 id: version.to_string(),
424 })
425 }
426 }
427
428 pub fn resolve(&self, reference: &str) -> Option<&str> {
432 let id = self
433 .aliases
434 .get(reference)
435 .map(String::as_str)
436 .unwrap_or(reference);
437 self.versions
438 .iter()
439 .find(|v| v.id == id)
440 .map(|v| v.component.as_str())
441 }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446#[serde(deny_unknown_fields)]
447pub struct FunctionVersion {
448 pub id: String,
450 pub component: String,
452 pub created: u64,
454 #[serde(default)]
456 pub lifecycle: Lifecycle,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct FunctionSpec {
465 pub name: String,
467 pub component: String,
469 pub config: FunctionConfig,
471 pub lifecycle: Lifecycle,
473}
474
475#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(rename_all = "snake_case")]
480pub enum InvokeMode {
481 #[default]
483 Sync,
484 Async,
486}
487
488#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(rename_all = "snake_case")]
491pub enum InvocationStatus {
492 #[default]
494 Queued,
495 Running,
497 Succeeded,
499 Failed,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(deny_unknown_fields)]
507pub struct InvocationResult {
508 pub status: u16,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub content_type: Option<String>,
513 pub body_b64: String,
515}
516
517#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(deny_unknown_fields)]
521pub struct Invocation {
522 pub id: String,
524 pub function: String,
526 pub version: String,
529 pub mode: InvokeMode,
531 pub status: InvocationStatus,
533 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub idempotency_key: Option<String>,
536 #[serde(default)]
538 pub attempts: u32,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
545 pub lease_expires: Option<u64>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
548 pub request_b64: Option<String>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub request_content_type: Option<String>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub result: Option<InvocationResult>,
555 pub created: u64,
557 pub updated: u64,
559}
560
561impl Invocation {
562 pub fn is_terminal(&self) -> bool {
564 matches!(
565 self.status,
566 InvocationStatus::Succeeded | InvocationStatus::Failed
567 )
568 }
569}
570
571#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
576#[serde(default, deny_unknown_fields)]
577pub struct FunctionQuota {
578 #[serde(skip_serializing_if = "Option::is_none")]
581 pub max_invocations: Option<u64>,
582 #[serde(skip_serializing_if = "Option::is_none")]
585 pub window_secs: Option<u64>,
586 #[serde(skip_serializing_if = "Option::is_none")]
588 pub max_concurrent: Option<u32>,
589}
590
591impl FunctionQuota {
592 pub fn is_unset(&self) -> bool {
595 self.max_invocations.is_none() && self.max_concurrent.is_none()
596 }
597 pub fn window(&self) -> u64 {
599 self.window_secs.unwrap_or(60)
600 }
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605pub struct MeteringSample {
606 pub success: bool,
608 pub duration_ms: u64,
611 pub bytes_in: u64,
613 pub bytes_out: u64,
615}
616
617#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(default, deny_unknown_fields)]
622pub struct Metering {
623 pub function: String,
625 pub invocations: u64,
627 pub successes: u64,
629 pub failures: u64,
631 pub duration_ms_total: u64,
633 pub bytes_in_total: u64,
635 pub bytes_out_total: u64,
637 pub window_start: u64,
639 pub window_count: u64,
641 pub updated: u64,
643}
644
645impl Metering {
646 pub fn new(function: impl Into<String>) -> Self {
648 Self {
649 function: function.into(),
650 ..Default::default()
651 }
652 }
653
654 pub fn record(&mut self, sample: &MeteringSample, now: u64) {
656 self.invocations += 1;
657 if sample.success {
658 self.successes += 1;
659 } else {
660 self.failures += 1;
661 }
662 self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
663 self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
664 self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
665 self.updated = now;
666 }
667
668 pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
673 let Some(max) = quota.max_invocations else {
674 return true;
675 };
676 let window = quota.window();
677 if now.saturating_sub(self.window_start) >= window {
678 self.window_start = now;
679 self.window_count = 0;
680 }
681 if self.window_count >= max {
682 return false;
683 }
684 self.window_count += 1;
685 self.updated = now;
686 true
687 }
688}
689
690pub mod keys {
691 pub fn meta(project: &str, name: &str) -> String {
698 format!("project/{project}/functions/{name}")
699 }
700 pub fn version(project: &str, name: &str, id: &str) -> String {
702 format!("project/{project}/functions/{name}/versions/{id}")
703 }
704 pub fn alias(project: &str, name: &str, label: &str) -> String {
706 format!("project/{project}/functions/{name}/alias/{label}")
707 }
708 pub fn trigger(project: &str, name: &str, id: &str) -> String {
710 format!("project/{project}/functions/{name}/triggers/{id}")
711 }
712 pub fn invocation(project: &str, name: &str, id: &str) -> String {
714 format!("project/{project}/functions/{name}/invocations/{id}")
715 }
716 pub fn invocations_prefix(project: &str, name: &str) -> String {
718 format!("project/{project}/functions/{name}/invocations/")
719 }
720 pub fn idempotency(project: &str, name: &str, key: &str) -> String {
722 format!("project/{project}/functions/{name}/idem/{key}")
723 }
724 pub fn metering(project: &str, name: &str) -> String {
726 format!("project/{project}/metering/{name}")
727 }
728
729 pub fn functions_prefix(project: &str) -> String {
733 format!("project/{project}/functions/")
734 }
735 pub fn triggers_prefix(project: &str, name: &str) -> String {
737 format!("project/{project}/functions/{name}/triggers/")
738 }
739 pub fn metering_prefix(project: &str) -> String {
741 format!("project/{project}/metering/")
742 }
743}
744
745pub fn handler_name(route: &str) -> String {
748 let s = slug(route);
749 if s.is_empty() {
750 "root".to_string()
751 } else {
752 s
753 }
754}
755
756pub fn consumer_name(topic: &str) -> String {
758 format!("consumer-{}", slug(topic))
759}
760
761fn slug(s: &str) -> String {
763 let mut out = String::new();
764 let mut dash = false;
765 for c in s.chars() {
766 if c.is_ascii_alphanumeric() {
767 out.push(c.to_ascii_lowercase());
768 dash = false;
769 } else if !out.is_empty() && !dash {
770 out.push('-');
771 dash = true;
772 }
773 }
774 out.trim_matches('-').to_string()
775}
776
777pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
789 let mut functions = Vec::new();
790 let mut triggers = Vec::new();
791
792 for h in &cfg.handlers {
793 let name = handler_name(&h.route);
794 functions.push(FunctionSpec {
795 name: name.clone(),
796 component: h.component.clone(),
797 config: FunctionConfig::from_handler(h),
798 lifecycle: Lifecycle::DeployPinned,
799 });
800 triggers.push(Trigger {
801 kind: TriggerKind::Route {
802 host: None,
803 path: h.route.clone(),
804 methods: h.methods.clone(),
805 },
806 target: Some(FunctionRef {
807 name,
808 version: None,
809 }),
810 });
811 }
812
813 for c in &cfg.consumers {
814 let name = consumer_name(&c.topic);
815 functions.push(FunctionSpec {
816 name: name.clone(),
817 component: c.component.clone(),
818 config: FunctionConfig::from_consumer(c),
819 lifecycle: Lifecycle::DeployPinned,
820 });
821 triggers.push(Trigger {
822 kind: TriggerKind::Queue {
823 topic: c.topic.clone(),
824 group: c.group.clone(),
825 start: c.start,
826 },
827 target: Some(FunctionRef {
828 name,
829 version: None,
830 }),
831 });
832 }
833
834 for cr in &cfg.crons {
835 let target = cfg
837 .handlers
838 .iter()
839 .find(|h| h.route == cr.route)
840 .map(|h| FunctionRef {
841 name: handler_name(&h.route),
842 version: None,
843 });
844 triggers.push(Trigger {
845 kind: TriggerKind::Cron {
846 schedule: cr.schedule.clone(),
847 overlap: cr.overlap,
848 },
849 target,
850 });
851 }
852
853 for s in &cfg.streams {
854 triggers.push(Trigger {
855 kind: TriggerKind::Stream {
856 topics: s.topics.clone(),
857 websocket: s.websocket,
858 publish_topic: s.publish_topic.clone(),
859 },
860 target: None,
861 });
862 }
863
864 (functions, triggers)
865}
866
867pub fn materialize(
874 specs: &[FunctionSpec],
875 site: &str,
876 files: &BTreeMap<String, FileEntry>,
877 created: u64,
878) -> Vec<Function> {
879 specs
880 .iter()
881 .filter_map(|s| {
882 let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
883 Some(Function {
884 name: s.name.clone(),
885 owner: Owner::Site(site.to_string()),
886 versions: vec![FunctionVersion {
887 id: hash.clone(),
888 component: hash.clone(),
889 created,
890 lifecycle: s.lifecycle,
891 }],
892 active: hash,
893 aliases: BTreeMap::new(),
894 config: s.config.clone(),
895 })
896 })
897 .collect()
898}
899
900#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
904pub struct FunctionSummary {
905 pub name: String,
907 pub owner: String,
909 pub runtime: String,
911 pub version: String,
913 pub triggers: Vec<String>,
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920 use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
921
922 fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
923 HandlerConfig {
924 route: route.into(),
925 methods: methods
926 .iter()
927 .map(std::string::ToString::to_string)
928 .collect(),
929 component: component.into(),
930 imports: imports
931 .iter()
932 .map(std::string::ToString::to_string)
933 .collect(),
934 streaming: false,
935 limits: None,
936 env: BTreeMap::new(),
937 invoke_targets: Vec::new(),
938 }
939 }
940
941 #[test]
942 fn slugs_and_names() {
943 assert_eq!(handler_name("/api/hello"), "api-hello");
944 assert_eq!(handler_name("/"), "root");
945 assert_eq!(handler_name("/a/b/*"), "a-b");
946 assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
947 }
948
949 #[test]
953 fn desugar_preserves_all_compute_config() {
954 let cfg = DeployConfig {
955 handlers: vec![
956 handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
957 handler("/api/report", "report.wasm", &[], &[]),
958 ],
959 consumers: vec![ConsumerConfig {
960 topic: "orders".into(),
961 component: "orders.wasm".into(),
962 imports: vec!["sql".into()],
963 group: String::new(),
964 start: Default::default(),
965 }],
966 crons: vec![CronConfig {
967 schedule: "0 * * * *".into(),
968 route: "/api/report".into(),
969 overlap: Overlap::Skip,
970 }],
971 streams: vec![StreamConfig {
972 route: "/live".into(),
973 topics: vec!["ticks".into()],
974 websocket: false,
975 publish_topic: None,
976 }],
977 ..Default::default()
978 };
979
980 let (functions, triggers) = desugar(&cfg);
981
982 assert_eq!(functions.len(), 3);
984 let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
985 assert_eq!(hello.component, "hello.wasm");
986 assert_eq!(hello.config.imports, vec!["kv".to_string()]);
987 assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
988 assert_eq!(hello.config.runtime, Runtime::Wasm);
989 let consumer = functions
990 .iter()
991 .find(|f| f.name == "consumer-orders")
992 .unwrap();
993 assert_eq!(consumer.component, "orders.wasm");
994 assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
995
996 assert_eq!(triggers.len(), 5);
998
999 let route = triggers
1001 .iter()
1002 .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
1003 .unwrap();
1004 match &route.kind {
1005 TriggerKind::Route { methods, host, .. } => {
1006 assert_eq!(methods, &["GET".to_string()]);
1007 assert!(host.is_none());
1008 }
1009 _ => unreachable!(),
1010 }
1011 assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
1012
1013 let queue = triggers
1015 .iter()
1016 .find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
1017 .unwrap();
1018 assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
1019
1020 let cron = triggers
1022 .iter()
1023 .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
1024 .unwrap();
1025 assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
1026
1027 let stream = triggers
1029 .iter()
1030 .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
1031 .unwrap();
1032 assert!(stream.target.is_none());
1033 match &stream.kind {
1034 TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
1035 _ => unreachable!(),
1036 }
1037 }
1038
1039 #[test]
1040 fn materialize_resolves_paths_to_blob_hashes() {
1041 let cfg = DeployConfig {
1042 handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
1043 ..Default::default()
1044 };
1045 let (specs, _) = desugar(&cfg);
1046 let files = BTreeMap::from([(
1047 "hello.wasm".to_string(),
1048 FileEntry {
1049 hash: "sha256:abc".into(),
1050 size: 10,
1051 content_type: None,
1052 variants: BTreeMap::new(),
1053 },
1054 )]);
1055 let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1056 assert_eq!(funcs.len(), 1);
1057 assert_eq!(funcs[0].name, "api-hello");
1058 assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1059 assert_eq!(funcs[0].active, "sha256:abc");
1060 assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1061 assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1062 assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1064 }
1065
1066 #[test]
1067 fn empty_config_desugars_to_nothing() {
1068 let (functions, triggers) = desugar(&DeployConfig::default());
1069 assert!(functions.is_empty() && triggers.is_empty());
1070 }
1071
1072 #[test]
1073 fn model_serde_round_trips() {
1074 let f = Function {
1075 name: "resize".into(),
1076 owner: Owner::Project("acme".into()),
1077 versions: vec![FunctionVersion {
1078 id: "v1abc".into(),
1079 component: "blob:deadbeef".into(),
1080 created: 1_800_000_000,
1081 lifecycle: Lifecycle::Independent,
1082 }],
1083 active: "v1abc".into(),
1084 aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1085 config: FunctionConfig {
1086 imports: vec!["blobstore".into()],
1087 runtime: Runtime::Microvm,
1088 ..Default::default()
1089 },
1090 };
1091 let json = serde_json::to_string(&f).unwrap();
1092 assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1093
1094 for t in [
1096 Trigger {
1097 kind: TriggerKind::Route {
1098 host: Some("example.com".into()),
1099 path: "/x".into(),
1100 methods: vec!["POST".into()],
1101 },
1102 target: Some(FunctionRef {
1103 name: "resize".into(),
1104 version: None,
1105 }),
1106 },
1107 Trigger {
1108 kind: TriggerKind::Stream {
1109 topics: vec!["t".into()],
1110 websocket: true,
1111 publish_topic: Some("up".into()),
1112 },
1113 target: None,
1114 },
1115 ] {
1116 let j = serde_json::to_string(&t).unwrap();
1117 assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1118 }
1119 }
1120
1121 #[test]
1122 fn versioning_alias_and_rollback() {
1123 let mut f = Function::new(
1124 "resize",
1125 Owner::Project("acme".into()),
1126 "hashA",
1127 FunctionConfig::default(),
1128 Lifecycle::Independent,
1129 1,
1130 );
1131 assert_eq!(f.active, "hashA");
1132 assert_eq!(f.versions.len(), 1);
1133
1134 f.upsert_version("hashB", Lifecycle::Independent, 2);
1136 assert_eq!(f.active, "hashB");
1137 assert_eq!(f.versions.len(), 2);
1138 f.upsert_version("hashA", Lifecycle::Independent, 3);
1140 assert_eq!(f.active, "hashA");
1141 assert_eq!(f.versions.len(), 2);
1142
1143 f.set_alias("prod", "hashB").unwrap();
1145 assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1146 assert!(f.set_alias("prod", "ghost").is_err());
1147
1148 f.rollback("hashB").unwrap();
1150 assert_eq!(f.active, "hashB");
1151 assert!(f.rollback("ghost").is_err());
1152
1153 assert_eq!(f.resolve("hashA"), Some("hashA"));
1156 assert_eq!(f.resolve("prod"), Some("hashB")); assert_eq!(f.resolve("ghost"), None);
1158 }
1159
1160 #[test]
1161 fn invocation_model_round_trips_and_reports_terminal() {
1162 let inv = Invocation {
1163 id: "inv-1".into(),
1164 function: "greeter".into(),
1165 version: "hashA".into(),
1166 mode: InvokeMode::Async,
1167 status: InvocationStatus::Queued,
1168 idempotency_key: Some("k".into()),
1169 attempts: 0,
1170 lease_expires: None,
1171 request_b64: Some("aGk=".into()),
1172 request_content_type: Some("text/plain".into()),
1173 result: None,
1174 created: 1,
1175 updated: 1,
1176 };
1177 assert!(!inv.is_terminal());
1178 let json = serde_json::to_string(&inv).unwrap();
1179 let back: Invocation = serde_json::from_str(&json).unwrap();
1180 assert_eq!(back, inv);
1181 assert!(json.contains("\"mode\":\"async\""));
1183 assert!(json.contains("\"status\":\"queued\""));
1184
1185 let done = Invocation {
1186 status: InvocationStatus::Succeeded,
1187 result: Some(InvocationResult {
1188 status: 200,
1189 content_type: None,
1190 body_b64: "b2s=".into(),
1191 }),
1192 ..inv
1193 };
1194 assert!(done.is_terminal());
1195 }
1196
1197 #[test]
1198 fn metering_records_and_rate_limits() {
1199 let mut m = Metering::new("greeter");
1200 m.record(
1201 &MeteringSample {
1202 success: true,
1203 duration_ms: 5,
1204 bytes_in: 3,
1205 bytes_out: 7,
1206 },
1207 100,
1208 );
1209 m.record(
1210 &MeteringSample {
1211 success: false,
1212 duration_ms: 2,
1213 bytes_in: 0,
1214 bytes_out: 0,
1215 },
1216 101,
1217 );
1218 assert_eq!(m.invocations, 2);
1219 assert_eq!(m.successes, 1);
1220 assert_eq!(m.failures, 1);
1221 assert_eq!(m.duration_ms_total, 7);
1222 assert_eq!(m.bytes_out_total, 7);
1223 assert_eq!(m.updated, 101);
1224
1225 let quota = FunctionQuota {
1227 max_invocations: Some(2),
1228 window_secs: Some(10),
1229 max_concurrent: None,
1230 };
1231 let mut r = Metering::new("greeter");
1232 assert!(r.admit("a, 1000)); assert!(r.admit("a, 1001)); assert!(!r.admit("a, 1002)); assert_eq!(r.window_count, 2);
1236 assert!(r.admit("a, 1011));
1238 assert_eq!(r.window_count, 1);
1239
1240 let unset = FunctionQuota::default();
1242 let mut u = Metering::new("greeter");
1243 assert!(u.admit(&unset, 1));
1244 assert_eq!(u.window_count, 0);
1245 assert!(unset.is_unset());
1246 }
1247
1248 #[test]
1249 fn webhook_config_defaults_and_round_trips() {
1250 let w = WebhookConfig {
1252 secret_env: "HOOK_SECRET".into(),
1253 algorithm: WebhookAlgorithm::HmacSha256,
1254 signature_header: None,
1255 max_body_bytes: None,
1256 publish: None,
1257 };
1258 assert_eq!(w.header(), "x-boatramp-signature");
1259 assert_eq!(w.body_cap(), 1024 * 1024);
1260
1261 let cfg = FunctionConfig {
1263 webhook: Some(w),
1264 ..Default::default()
1265 };
1266 let json = serde_json::to_string(&cfg).unwrap();
1267 assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1268 assert!(json.contains("\"hmac_sha256\""));
1269 let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1270 assert_eq!(back, cfg);
1271
1272 let custom = WebhookConfig {
1274 secret_env: "S".into(),
1275 algorithm: WebhookAlgorithm::HmacSha256,
1276 signature_header: Some("x-hub-signature-256".into()),
1277 max_body_bytes: Some(4096),
1278 publish: None,
1279 };
1280 assert_eq!(custom.header(), "x-hub-signature-256");
1281 assert_eq!(custom.body_cap(), 4096);
1282 }
1283
1284 #[test]
1285 fn secret_refs_round_trip_as_references() {
1286 let cfg = FunctionConfig {
1290 env: BTreeMap::from([("STAGE".into(), "prod".into())]),
1291 secrets: BTreeMap::from([("DB_URL".into(), "PROD_DB_URL".into())]),
1292 ..Default::default()
1293 };
1294 let json = serde_json::to_string(&cfg).unwrap();
1295 assert!(json.contains("\"secrets\":{\"DB_URL\":\"PROD_DB_URL\"}"));
1297 let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1298 assert_eq!(back, cfg);
1299 assert_eq!(
1300 back.secrets.get("DB_URL").map(String::as_str),
1301 Some("PROD_DB_URL")
1302 );
1303
1304 let bare = FunctionConfig::default();
1306 assert!(!serde_json::to_string(&bare).unwrap().contains("secrets"));
1307 }
1308
1309 #[test]
1310 fn keyspace_is_stable() {
1311 assert_eq!(
1312 keys::meta("default", "resize"),
1313 "project/default/functions/resize"
1314 );
1315 assert_eq!(
1316 keys::version("default", "resize", "v1"),
1317 "project/default/functions/resize/versions/v1"
1318 );
1319 assert_eq!(
1320 keys::alias("default", "resize", "prod"),
1321 "project/default/functions/resize/alias/prod"
1322 );
1323 assert_eq!(
1324 keys::trigger("default", "resize", "t1"),
1325 "project/default/functions/resize/triggers/t1"
1326 );
1327 assert_eq!(
1328 keys::invocation("default", "resize", "inv-1"),
1329 "project/default/functions/resize/invocations/inv-1"
1330 );
1331 assert_eq!(
1332 keys::invocations_prefix("default", "resize"),
1333 "project/default/functions/resize/invocations/"
1334 );
1335 assert_eq!(
1336 keys::idempotency("default", "resize", "k-1"),
1337 "project/default/functions/resize/idem/k-1"
1338 );
1339 assert_eq!(
1340 keys::metering("default", "resize"),
1341 "project/default/metering/resize"
1342 );
1343 assert_eq!(
1345 keys::meta("acme", "resize"),
1346 "project/acme/functions/resize"
1347 );
1348 }
1349}