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 pub runtime: Runtime,
102 #[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
104 pub quota: FunctionQuota,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub webhook: Option<WebhookConfig>,
108 #[serde(skip_serializing_if = "Vec::is_empty")]
114 pub invoke_targets: Vec<String>,
115}
116
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum WebhookAlgorithm {
121 #[default]
123 HmacSha256,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct WebhookConfig {
133 pub secret_env: String,
135 #[serde(default)]
137 pub algorithm: WebhookAlgorithm,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub signature_header: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub max_body_bytes: Option<u64>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub publish: Option<String>,
152}
153
154impl WebhookConfig {
155 pub fn header(&self) -> &str {
157 self.signature_header
158 .as_deref()
159 .unwrap_or("x-boatramp-signature")
160 }
161 pub fn body_cap(&self) -> u64 {
163 self.max_body_bytes.unwrap_or(1024 * 1024)
164 }
165}
166
167impl FunctionConfig {
168 fn from_handler(h: &HandlerConfig) -> Self {
169 Self {
170 imports: h.imports.clone(),
171 limits: h.limits.clone(),
172 env: h.env.clone(),
173 runtime: Runtime::default(),
174 quota: FunctionQuota::default(),
175 webhook: None,
176 invoke_targets: Vec::new(),
177 }
178 }
179 fn from_consumer(c: &ConsumerConfig) -> Self {
180 Self {
181 imports: c.imports.clone(),
182 ..Default::default()
183 }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(deny_unknown_fields)]
190pub struct FunctionRef {
191 pub name: String,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub version: Option<String>,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(deny_unknown_fields)]
203pub struct Trigger {
204 pub kind: TriggerKind,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub target: Option<FunctionRef>,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(tag = "type", rename_all = "snake_case")]
215pub enum TriggerKind {
216 Route {
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 host: Option<String>,
220 path: String,
221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 methods: Vec<String>,
223 },
224 Invoke { name: String },
226 Queue {
230 topic: String,
231 #[serde(default, skip_serializing_if = "String::is_empty")]
232 group: String,
233 #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
234 start: crate::config::StartPosition,
235 },
236 Cron {
238 schedule: String,
239 #[serde(default)]
240 overlap: Overlap,
241 },
242 Blob { prefix: String },
244 Webhook { path: String, secret_env: String },
247 Stream {
250 topics: Vec<String>,
251 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
252 websocket: bool,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 publish_topic: Option<String>,
255 },
256}
257
258impl std::fmt::Display for Trigger {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 match &self.kind {
262 TriggerKind::Route { path, methods, .. } => {
263 let m = if methods.is_empty() {
264 "*".to_string()
265 } else {
266 methods.join(",")
267 };
268 write!(f, "route {m} {path}")
269 }
270 TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
271 TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
272 TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
273 TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
274 TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
275 TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
276 }
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct FunctionTrigger {
287 pub id: String,
289 pub kind: TriggerKind,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub last_fired_minute: Option<i64>,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
300#[serde(deny_unknown_fields)]
301pub struct Function {
302 pub name: String,
304 pub owner: Owner,
306 pub versions: Vec<FunctionVersion>,
308 pub active: String,
310 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
312 pub aliases: BTreeMap<String, String>,
313 pub config: FunctionConfig,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
321#[error("no version {id:?} in function {function:?}")]
322pub struct UnknownVersion {
323 pub function: String,
325 pub id: String,
327}
328
329impl Function {
330 pub fn new(
333 name: impl Into<String>,
334 owner: Owner,
335 component_hash: impl Into<String>,
336 config: FunctionConfig,
337 lifecycle: Lifecycle,
338 created: u64,
339 ) -> Self {
340 let hash = component_hash.into();
341 Self {
342 name: name.into(),
343 owner,
344 versions: vec![FunctionVersion {
345 id: hash.clone(),
346 component: hash.clone(),
347 created,
348 lifecycle,
349 }],
350 active: hash,
351 aliases: BTreeMap::new(),
352 config,
353 }
354 }
355
356 pub fn upsert_version(
360 &mut self,
361 component_hash: impl Into<String>,
362 lifecycle: Lifecycle,
363 created: u64,
364 ) -> String {
365 let hash = component_hash.into();
366 if !self.versions.iter().any(|v| v.id == hash) {
367 self.versions.push(FunctionVersion {
368 id: hash.clone(),
369 component: hash.clone(),
370 created,
371 lifecycle,
372 });
373 }
374 self.active = hash.clone();
375 hash
376 }
377
378 pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
380 if self.versions.iter().any(|v| v.id == to) {
381 self.active = to.to_string();
382 Ok(())
383 } else {
384 Err(UnknownVersion {
385 function: self.name.clone(),
386 id: to.to_string(),
387 })
388 }
389 }
390
391 pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
393 if self.versions.iter().any(|v| v.id == version) {
394 self.aliases.insert(label.to_string(), version.to_string());
395 Ok(())
396 } else {
397 Err(UnknownVersion {
398 function: self.name.clone(),
399 id: version.to_string(),
400 })
401 }
402 }
403
404 pub fn resolve(&self, reference: &str) -> Option<&str> {
408 let id = self
409 .aliases
410 .get(reference)
411 .map(String::as_str)
412 .unwrap_or(reference);
413 self.versions
414 .iter()
415 .find(|v| v.id == id)
416 .map(|v| v.component.as_str())
417 }
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(deny_unknown_fields)]
423pub struct FunctionVersion {
424 pub id: String,
426 pub component: String,
428 pub created: u64,
430 #[serde(default)]
432 pub lifecycle: Lifecycle,
433}
434
435#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct FunctionSpec {
441 pub name: String,
443 pub component: String,
445 pub config: FunctionConfig,
447 pub lifecycle: Lifecycle,
449}
450
451#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(rename_all = "snake_case")]
456pub enum InvokeMode {
457 #[default]
459 Sync,
460 Async,
462}
463
464#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(rename_all = "snake_case")]
467pub enum InvocationStatus {
468 #[default]
470 Queued,
471 Running,
473 Succeeded,
475 Failed,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(deny_unknown_fields)]
483pub struct InvocationResult {
484 pub status: u16,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub content_type: Option<String>,
489 pub body_b64: String,
491}
492
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
496#[serde(deny_unknown_fields)]
497pub struct Invocation {
498 pub id: String,
500 pub function: String,
502 pub version: String,
505 pub mode: InvokeMode,
507 pub status: InvocationStatus,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub idempotency_key: Option<String>,
512 #[serde(default)]
514 pub attempts: u32,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub lease_expires: Option<u64>,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub request_b64: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub request_content_type: Option<String>,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub result: Option<InvocationResult>,
531 pub created: u64,
533 pub updated: u64,
535}
536
537impl Invocation {
538 pub fn is_terminal(&self) -> bool {
540 matches!(
541 self.status,
542 InvocationStatus::Succeeded | InvocationStatus::Failed
543 )
544 }
545}
546
547#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(default, deny_unknown_fields)]
553pub struct FunctionQuota {
554 #[serde(skip_serializing_if = "Option::is_none")]
557 pub max_invocations: Option<u64>,
558 #[serde(skip_serializing_if = "Option::is_none")]
561 pub window_secs: Option<u64>,
562 #[serde(skip_serializing_if = "Option::is_none")]
564 pub max_concurrent: Option<u32>,
565}
566
567impl FunctionQuota {
568 pub fn is_unset(&self) -> bool {
571 self.max_invocations.is_none() && self.max_concurrent.is_none()
572 }
573 pub fn window(&self) -> u64 {
575 self.window_secs.unwrap_or(60)
576 }
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
581pub struct MeteringSample {
582 pub success: bool,
584 pub duration_ms: u64,
587 pub bytes_in: u64,
589 pub bytes_out: u64,
591}
592
593#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
597#[serde(default, deny_unknown_fields)]
598pub struct Metering {
599 pub function: String,
601 pub invocations: u64,
603 pub successes: u64,
605 pub failures: u64,
607 pub duration_ms_total: u64,
609 pub bytes_in_total: u64,
611 pub bytes_out_total: u64,
613 pub window_start: u64,
615 pub window_count: u64,
617 pub updated: u64,
619}
620
621impl Metering {
622 pub fn new(function: impl Into<String>) -> Self {
624 Self {
625 function: function.into(),
626 ..Default::default()
627 }
628 }
629
630 pub fn record(&mut self, sample: &MeteringSample, now: u64) {
632 self.invocations += 1;
633 if sample.success {
634 self.successes += 1;
635 } else {
636 self.failures += 1;
637 }
638 self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
639 self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
640 self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
641 self.updated = now;
642 }
643
644 pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
649 let Some(max) = quota.max_invocations else {
650 return true;
651 };
652 let window = quota.window();
653 if now.saturating_sub(self.window_start) >= window {
654 self.window_start = now;
655 self.window_count = 0;
656 }
657 if self.window_count >= max {
658 return false;
659 }
660 self.window_count += 1;
661 self.updated = now;
662 true
663 }
664}
665
666pub mod keys {
667 pub fn meta(project: &str, name: &str) -> String {
674 format!("project/{project}/functions/{name}")
675 }
676 pub fn version(project: &str, name: &str, id: &str) -> String {
678 format!("project/{project}/functions/{name}/versions/{id}")
679 }
680 pub fn alias(project: &str, name: &str, label: &str) -> String {
682 format!("project/{project}/functions/{name}/alias/{label}")
683 }
684 pub fn trigger(project: &str, name: &str, id: &str) -> String {
686 format!("project/{project}/functions/{name}/triggers/{id}")
687 }
688 pub fn invocation(project: &str, name: &str, id: &str) -> String {
690 format!("project/{project}/functions/{name}/invocations/{id}")
691 }
692 pub fn invocations_prefix(project: &str, name: &str) -> String {
694 format!("project/{project}/functions/{name}/invocations/")
695 }
696 pub fn idempotency(project: &str, name: &str, key: &str) -> String {
698 format!("project/{project}/functions/{name}/idem/{key}")
699 }
700 pub fn metering(project: &str, name: &str) -> String {
702 format!("project/{project}/metering/{name}")
703 }
704
705 pub fn functions_prefix(project: &str) -> String {
709 format!("project/{project}/functions/")
710 }
711 pub fn triggers_prefix(project: &str, name: &str) -> String {
713 format!("project/{project}/functions/{name}/triggers/")
714 }
715 pub fn metering_prefix(project: &str) -> String {
717 format!("project/{project}/metering/")
718 }
719}
720
721pub fn handler_name(route: &str) -> String {
724 let s = slug(route);
725 if s.is_empty() {
726 "root".to_string()
727 } else {
728 s
729 }
730}
731
732pub fn consumer_name(topic: &str) -> String {
734 format!("consumer-{}", slug(topic))
735}
736
737fn slug(s: &str) -> String {
739 let mut out = String::new();
740 let mut dash = false;
741 for c in s.chars() {
742 if c.is_ascii_alphanumeric() {
743 out.push(c.to_ascii_lowercase());
744 dash = false;
745 } else if !out.is_empty() && !dash {
746 out.push('-');
747 dash = true;
748 }
749 }
750 out.trim_matches('-').to_string()
751}
752
753pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
765 let mut functions = Vec::new();
766 let mut triggers = Vec::new();
767
768 for h in &cfg.handlers {
769 let name = handler_name(&h.route);
770 functions.push(FunctionSpec {
771 name: name.clone(),
772 component: h.component.clone(),
773 config: FunctionConfig::from_handler(h),
774 lifecycle: Lifecycle::DeployPinned,
775 });
776 triggers.push(Trigger {
777 kind: TriggerKind::Route {
778 host: None,
779 path: h.route.clone(),
780 methods: h.methods.clone(),
781 },
782 target: Some(FunctionRef {
783 name,
784 version: None,
785 }),
786 });
787 }
788
789 for c in &cfg.consumers {
790 let name = consumer_name(&c.topic);
791 functions.push(FunctionSpec {
792 name: name.clone(),
793 component: c.component.clone(),
794 config: FunctionConfig::from_consumer(c),
795 lifecycle: Lifecycle::DeployPinned,
796 });
797 triggers.push(Trigger {
798 kind: TriggerKind::Queue {
799 topic: c.topic.clone(),
800 group: c.group.clone(),
801 start: c.start,
802 },
803 target: Some(FunctionRef {
804 name,
805 version: None,
806 }),
807 });
808 }
809
810 for cr in &cfg.crons {
811 let target = cfg
813 .handlers
814 .iter()
815 .find(|h| h.route == cr.route)
816 .map(|h| FunctionRef {
817 name: handler_name(&h.route),
818 version: None,
819 });
820 triggers.push(Trigger {
821 kind: TriggerKind::Cron {
822 schedule: cr.schedule.clone(),
823 overlap: cr.overlap,
824 },
825 target,
826 });
827 }
828
829 for s in &cfg.streams {
830 triggers.push(Trigger {
831 kind: TriggerKind::Stream {
832 topics: s.topics.clone(),
833 websocket: s.websocket,
834 publish_topic: s.publish_topic.clone(),
835 },
836 target: None,
837 });
838 }
839
840 (functions, triggers)
841}
842
843pub fn materialize(
850 specs: &[FunctionSpec],
851 site: &str,
852 files: &BTreeMap<String, FileEntry>,
853 created: u64,
854) -> Vec<Function> {
855 specs
856 .iter()
857 .filter_map(|s| {
858 let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
859 Some(Function {
860 name: s.name.clone(),
861 owner: Owner::Site(site.to_string()),
862 versions: vec![FunctionVersion {
863 id: hash.clone(),
864 component: hash.clone(),
865 created,
866 lifecycle: s.lifecycle,
867 }],
868 active: hash,
869 aliases: BTreeMap::new(),
870 config: s.config.clone(),
871 })
872 })
873 .collect()
874}
875
876#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880pub struct FunctionSummary {
881 pub name: String,
883 pub owner: String,
885 pub runtime: String,
887 pub version: String,
889 pub triggers: Vec<String>,
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
897
898 fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
899 HandlerConfig {
900 route: route.into(),
901 methods: methods
902 .iter()
903 .map(std::string::ToString::to_string)
904 .collect(),
905 component: component.into(),
906 imports: imports
907 .iter()
908 .map(std::string::ToString::to_string)
909 .collect(),
910 limits: None,
911 env: BTreeMap::new(),
912 invoke_targets: Vec::new(),
913 }
914 }
915
916 #[test]
917 fn slugs_and_names() {
918 assert_eq!(handler_name("/api/hello"), "api-hello");
919 assert_eq!(handler_name("/"), "root");
920 assert_eq!(handler_name("/a/b/*"), "a-b");
921 assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
922 }
923
924 #[test]
928 fn desugar_preserves_all_compute_config() {
929 let cfg = DeployConfig {
930 handlers: vec![
931 handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
932 handler("/api/report", "report.wasm", &[], &[]),
933 ],
934 consumers: vec![ConsumerConfig {
935 topic: "orders".into(),
936 component: "orders.wasm".into(),
937 imports: vec!["sql".into()],
938 group: String::new(),
939 start: Default::default(),
940 }],
941 crons: vec![CronConfig {
942 schedule: "0 * * * *".into(),
943 route: "/api/report".into(),
944 overlap: Overlap::Skip,
945 }],
946 streams: vec![StreamConfig {
947 route: "/live".into(),
948 topics: vec!["ticks".into()],
949 websocket: false,
950 publish_topic: None,
951 }],
952 ..Default::default()
953 };
954
955 let (functions, triggers) = desugar(&cfg);
956
957 assert_eq!(functions.len(), 3);
959 let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
960 assert_eq!(hello.component, "hello.wasm");
961 assert_eq!(hello.config.imports, vec!["kv".to_string()]);
962 assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
963 assert_eq!(hello.config.runtime, Runtime::Wasm);
964 let consumer = functions
965 .iter()
966 .find(|f| f.name == "consumer-orders")
967 .unwrap();
968 assert_eq!(consumer.component, "orders.wasm");
969 assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
970
971 assert_eq!(triggers.len(), 5);
973
974 let route = triggers
976 .iter()
977 .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
978 .unwrap();
979 match &route.kind {
980 TriggerKind::Route { methods, host, .. } => {
981 assert_eq!(methods, &["GET".to_string()]);
982 assert!(host.is_none());
983 }
984 _ => unreachable!(),
985 }
986 assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
987
988 let queue = triggers
990 .iter()
991 .find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
992 .unwrap();
993 assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
994
995 let cron = triggers
997 .iter()
998 .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
999 .unwrap();
1000 assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
1001
1002 let stream = triggers
1004 .iter()
1005 .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
1006 .unwrap();
1007 assert!(stream.target.is_none());
1008 match &stream.kind {
1009 TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
1010 _ => unreachable!(),
1011 }
1012 }
1013
1014 #[test]
1015 fn materialize_resolves_paths_to_blob_hashes() {
1016 let cfg = DeployConfig {
1017 handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
1018 ..Default::default()
1019 };
1020 let (specs, _) = desugar(&cfg);
1021 let files = BTreeMap::from([(
1022 "hello.wasm".to_string(),
1023 FileEntry {
1024 hash: "sha256:abc".into(),
1025 size: 10,
1026 content_type: None,
1027 variants: BTreeMap::new(),
1028 },
1029 )]);
1030 let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1031 assert_eq!(funcs.len(), 1);
1032 assert_eq!(funcs[0].name, "api-hello");
1033 assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1034 assert_eq!(funcs[0].active, "sha256:abc");
1035 assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1036 assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1037 assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1039 }
1040
1041 #[test]
1042 fn empty_config_desugars_to_nothing() {
1043 let (functions, triggers) = desugar(&DeployConfig::default());
1044 assert!(functions.is_empty() && triggers.is_empty());
1045 }
1046
1047 #[test]
1048 fn model_serde_round_trips() {
1049 let f = Function {
1050 name: "resize".into(),
1051 owner: Owner::Project("acme".into()),
1052 versions: vec![FunctionVersion {
1053 id: "v1abc".into(),
1054 component: "blob:deadbeef".into(),
1055 created: 1_800_000_000,
1056 lifecycle: Lifecycle::Independent,
1057 }],
1058 active: "v1abc".into(),
1059 aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1060 config: FunctionConfig {
1061 imports: vec!["blobstore".into()],
1062 runtime: Runtime::Microvm,
1063 ..Default::default()
1064 },
1065 };
1066 let json = serde_json::to_string(&f).unwrap();
1067 assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1068
1069 for t in [
1071 Trigger {
1072 kind: TriggerKind::Route {
1073 host: Some("example.com".into()),
1074 path: "/x".into(),
1075 methods: vec!["POST".into()],
1076 },
1077 target: Some(FunctionRef {
1078 name: "resize".into(),
1079 version: None,
1080 }),
1081 },
1082 Trigger {
1083 kind: TriggerKind::Stream {
1084 topics: vec!["t".into()],
1085 websocket: true,
1086 publish_topic: Some("up".into()),
1087 },
1088 target: None,
1089 },
1090 ] {
1091 let j = serde_json::to_string(&t).unwrap();
1092 assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1093 }
1094 }
1095
1096 #[test]
1097 fn versioning_alias_and_rollback() {
1098 let mut f = Function::new(
1099 "resize",
1100 Owner::Project("acme".into()),
1101 "hashA",
1102 FunctionConfig::default(),
1103 Lifecycle::Independent,
1104 1,
1105 );
1106 assert_eq!(f.active, "hashA");
1107 assert_eq!(f.versions.len(), 1);
1108
1109 f.upsert_version("hashB", Lifecycle::Independent, 2);
1111 assert_eq!(f.active, "hashB");
1112 assert_eq!(f.versions.len(), 2);
1113 f.upsert_version("hashA", Lifecycle::Independent, 3);
1115 assert_eq!(f.active, "hashA");
1116 assert_eq!(f.versions.len(), 2);
1117
1118 f.set_alias("prod", "hashB").unwrap();
1120 assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1121 assert!(f.set_alias("prod", "ghost").is_err());
1122
1123 f.rollback("hashB").unwrap();
1125 assert_eq!(f.active, "hashB");
1126 assert!(f.rollback("ghost").is_err());
1127
1128 assert_eq!(f.resolve("hashA"), Some("hashA"));
1131 assert_eq!(f.resolve("prod"), Some("hashB")); assert_eq!(f.resolve("ghost"), None);
1133 }
1134
1135 #[test]
1136 fn invocation_model_round_trips_and_reports_terminal() {
1137 let inv = Invocation {
1138 id: "inv-1".into(),
1139 function: "greeter".into(),
1140 version: "hashA".into(),
1141 mode: InvokeMode::Async,
1142 status: InvocationStatus::Queued,
1143 idempotency_key: Some("k".into()),
1144 attempts: 0,
1145 lease_expires: None,
1146 request_b64: Some("aGk=".into()),
1147 request_content_type: Some("text/plain".into()),
1148 result: None,
1149 created: 1,
1150 updated: 1,
1151 };
1152 assert!(!inv.is_terminal());
1153 let json = serde_json::to_string(&inv).unwrap();
1154 let back: Invocation = serde_json::from_str(&json).unwrap();
1155 assert_eq!(back, inv);
1156 assert!(json.contains("\"mode\":\"async\""));
1158 assert!(json.contains("\"status\":\"queued\""));
1159
1160 let done = Invocation {
1161 status: InvocationStatus::Succeeded,
1162 result: Some(InvocationResult {
1163 status: 200,
1164 content_type: None,
1165 body_b64: "b2s=".into(),
1166 }),
1167 ..inv
1168 };
1169 assert!(done.is_terminal());
1170 }
1171
1172 #[test]
1173 fn metering_records_and_rate_limits() {
1174 let mut m = Metering::new("greeter");
1175 m.record(
1176 &MeteringSample {
1177 success: true,
1178 duration_ms: 5,
1179 bytes_in: 3,
1180 bytes_out: 7,
1181 },
1182 100,
1183 );
1184 m.record(
1185 &MeteringSample {
1186 success: false,
1187 duration_ms: 2,
1188 bytes_in: 0,
1189 bytes_out: 0,
1190 },
1191 101,
1192 );
1193 assert_eq!(m.invocations, 2);
1194 assert_eq!(m.successes, 1);
1195 assert_eq!(m.failures, 1);
1196 assert_eq!(m.duration_ms_total, 7);
1197 assert_eq!(m.bytes_out_total, 7);
1198 assert_eq!(m.updated, 101);
1199
1200 let quota = FunctionQuota {
1202 max_invocations: Some(2),
1203 window_secs: Some(10),
1204 max_concurrent: None,
1205 };
1206 let mut r = Metering::new("greeter");
1207 assert!(r.admit("a, 1000)); assert!(r.admit("a, 1001)); assert!(!r.admit("a, 1002)); assert_eq!(r.window_count, 2);
1211 assert!(r.admit("a, 1011));
1213 assert_eq!(r.window_count, 1);
1214
1215 let unset = FunctionQuota::default();
1217 let mut u = Metering::new("greeter");
1218 assert!(u.admit(&unset, 1));
1219 assert_eq!(u.window_count, 0);
1220 assert!(unset.is_unset());
1221 }
1222
1223 #[test]
1224 fn webhook_config_defaults_and_round_trips() {
1225 let w = WebhookConfig {
1227 secret_env: "HOOK_SECRET".into(),
1228 algorithm: WebhookAlgorithm::HmacSha256,
1229 signature_header: None,
1230 max_body_bytes: None,
1231 publish: None,
1232 };
1233 assert_eq!(w.header(), "x-boatramp-signature");
1234 assert_eq!(w.body_cap(), 1024 * 1024);
1235
1236 let cfg = FunctionConfig {
1238 webhook: Some(w),
1239 ..Default::default()
1240 };
1241 let json = serde_json::to_string(&cfg).unwrap();
1242 assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1243 assert!(json.contains("\"hmac_sha256\""));
1244 let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1245 assert_eq!(back, cfg);
1246
1247 let custom = WebhookConfig {
1249 secret_env: "S".into(),
1250 algorithm: WebhookAlgorithm::HmacSha256,
1251 signature_header: Some("x-hub-signature-256".into()),
1252 max_body_bytes: Some(4096),
1253 publish: None,
1254 };
1255 assert_eq!(custom.header(), "x-hub-signature-256");
1256 assert_eq!(custom.body_cap(), 4096);
1257 }
1258
1259 #[test]
1260 fn keyspace_is_stable() {
1261 assert_eq!(
1262 keys::meta("default", "resize"),
1263 "project/default/functions/resize"
1264 );
1265 assert_eq!(
1266 keys::version("default", "resize", "v1"),
1267 "project/default/functions/resize/versions/v1"
1268 );
1269 assert_eq!(
1270 keys::alias("default", "resize", "prod"),
1271 "project/default/functions/resize/alias/prod"
1272 );
1273 assert_eq!(
1274 keys::trigger("default", "resize", "t1"),
1275 "project/default/functions/resize/triggers/t1"
1276 );
1277 assert_eq!(
1278 keys::invocation("default", "resize", "inv-1"),
1279 "project/default/functions/resize/invocations/inv-1"
1280 );
1281 assert_eq!(
1282 keys::invocations_prefix("default", "resize"),
1283 "project/default/functions/resize/invocations/"
1284 );
1285 assert_eq!(
1286 keys::idempotency("default", "resize", "k-1"),
1287 "project/default/functions/resize/idem/k-1"
1288 );
1289 assert_eq!(
1290 keys::metering("default", "resize"),
1291 "project/default/metering/resize"
1292 );
1293 assert_eq!(
1295 keys::meta("acme", "resize"),
1296 "project/acme/functions/resize"
1297 );
1298 }
1299}