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}
146
147impl WebhookConfig {
148 pub fn header(&self) -> &str {
150 self.signature_header
151 .as_deref()
152 .unwrap_or("x-boatramp-signature")
153 }
154 pub fn body_cap(&self) -> u64 {
156 self.max_body_bytes.unwrap_or(1024 * 1024)
157 }
158}
159
160impl FunctionConfig {
161 fn from_handler(h: &HandlerConfig) -> Self {
162 Self {
163 imports: h.imports.clone(),
164 limits: h.limits.clone(),
165 env: h.env.clone(),
166 runtime: Runtime::default(),
167 quota: FunctionQuota::default(),
168 webhook: None,
169 invoke_targets: Vec::new(),
170 }
171 }
172 fn from_consumer(c: &ConsumerConfig) -> Self {
173 Self {
174 imports: c.imports.clone(),
175 ..Default::default()
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(deny_unknown_fields)]
183pub struct FunctionRef {
184 pub name: String,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub version: Option<String>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct Trigger {
197 pub kind: TriggerKind,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub target: Option<FunctionRef>,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(tag = "type", rename_all = "snake_case")]
208pub enum TriggerKind {
209 Route {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 host: Option<String>,
213 path: String,
214 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 methods: Vec<String>,
216 },
217 Invoke { name: String },
219 Queue { topic: String },
221 Cron {
223 schedule: String,
224 #[serde(default)]
225 overlap: Overlap,
226 },
227 Blob { prefix: String },
229 Webhook { path: String, secret_env: String },
232 Stream {
235 topics: Vec<String>,
236 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
237 websocket: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 publish_topic: Option<String>,
240 },
241}
242
243impl std::fmt::Display for Trigger {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 match &self.kind {
247 TriggerKind::Route { path, methods, .. } => {
248 let m = if methods.is_empty() {
249 "*".to_string()
250 } else {
251 methods.join(",")
252 };
253 write!(f, "route {m} {path}")
254 }
255 TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
256 TriggerKind::Queue { topic } => write!(f, "queue {topic}"),
257 TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
258 TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
259 TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
260 TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
261 }
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct FunctionTrigger {
272 pub id: String,
274 pub kind: TriggerKind,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub last_fired_minute: Option<i64>,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[serde(deny_unknown_fields)]
286pub struct Function {
287 pub name: String,
289 pub owner: Owner,
291 pub versions: Vec<FunctionVersion>,
293 pub active: String,
295 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
297 pub aliases: BTreeMap<String, String>,
298 pub config: FunctionConfig,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
306#[error("no version {id:?} in function {function:?}")]
307pub struct UnknownVersion {
308 pub function: String,
310 pub id: String,
312}
313
314impl Function {
315 pub fn new(
318 name: impl Into<String>,
319 owner: Owner,
320 component_hash: impl Into<String>,
321 config: FunctionConfig,
322 lifecycle: Lifecycle,
323 created: u64,
324 ) -> Self {
325 let hash = component_hash.into();
326 Self {
327 name: name.into(),
328 owner,
329 versions: vec![FunctionVersion {
330 id: hash.clone(),
331 component: hash.clone(),
332 created,
333 lifecycle,
334 }],
335 active: hash,
336 aliases: BTreeMap::new(),
337 config,
338 }
339 }
340
341 pub fn upsert_version(
345 &mut self,
346 component_hash: impl Into<String>,
347 lifecycle: Lifecycle,
348 created: u64,
349 ) -> String {
350 let hash = component_hash.into();
351 if !self.versions.iter().any(|v| v.id == hash) {
352 self.versions.push(FunctionVersion {
353 id: hash.clone(),
354 component: hash.clone(),
355 created,
356 lifecycle,
357 });
358 }
359 self.active = hash.clone();
360 hash
361 }
362
363 pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
365 if self.versions.iter().any(|v| v.id == to) {
366 self.active = to.to_string();
367 Ok(())
368 } else {
369 Err(UnknownVersion {
370 function: self.name.clone(),
371 id: to.to_string(),
372 })
373 }
374 }
375
376 pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
378 if self.versions.iter().any(|v| v.id == version) {
379 self.aliases.insert(label.to_string(), version.to_string());
380 Ok(())
381 } else {
382 Err(UnknownVersion {
383 function: self.name.clone(),
384 id: version.to_string(),
385 })
386 }
387 }
388
389 pub fn resolve(&self, reference: &str) -> Option<&str> {
393 let id = self
394 .aliases
395 .get(reference)
396 .map(String::as_str)
397 .unwrap_or(reference);
398 self.versions
399 .iter()
400 .find(|v| v.id == id)
401 .map(|v| v.component.as_str())
402 }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(deny_unknown_fields)]
408pub struct FunctionVersion {
409 pub id: String,
411 pub component: String,
413 pub created: u64,
415 #[serde(default)]
417 pub lifecycle: Lifecycle,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct FunctionSpec {
426 pub name: String,
428 pub component: String,
430 pub config: FunctionConfig,
432 pub lifecycle: Lifecycle,
434}
435
436#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
440#[serde(rename_all = "snake_case")]
441pub enum InvokeMode {
442 #[default]
444 Sync,
445 Async,
447}
448
449#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
451#[serde(rename_all = "snake_case")]
452pub enum InvocationStatus {
453 #[default]
455 Queued,
456 Running,
458 Succeeded,
460 Failed,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(deny_unknown_fields)]
468pub struct InvocationResult {
469 pub status: u16,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub content_type: Option<String>,
474 pub body_b64: String,
476}
477
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
481#[serde(deny_unknown_fields)]
482pub struct Invocation {
483 pub id: String,
485 pub function: String,
487 pub version: String,
490 pub mode: InvokeMode,
492 pub status: InvocationStatus,
494 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub idempotency_key: Option<String>,
497 #[serde(default)]
499 pub attempts: u32,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub request_b64: Option<String>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub request_content_type: Option<String>,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub result: Option<InvocationResult>,
509 pub created: u64,
511 pub updated: u64,
513}
514
515impl Invocation {
516 pub fn is_terminal(&self) -> bool {
518 matches!(
519 self.status,
520 InvocationStatus::Succeeded | InvocationStatus::Failed
521 )
522 }
523}
524
525#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(default, deny_unknown_fields)]
531pub struct FunctionQuota {
532 #[serde(skip_serializing_if = "Option::is_none")]
535 pub max_invocations: Option<u64>,
536 #[serde(skip_serializing_if = "Option::is_none")]
539 pub window_secs: Option<u64>,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub max_concurrent: Option<u32>,
543}
544
545impl FunctionQuota {
546 pub fn is_unset(&self) -> bool {
549 self.max_invocations.is_none() && self.max_concurrent.is_none()
550 }
551 pub fn window(&self) -> u64 {
553 self.window_secs.unwrap_or(60)
554 }
555}
556
557#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559pub struct MeteringSample {
560 pub success: bool,
562 pub duration_ms: u64,
565 pub bytes_in: u64,
567 pub bytes_out: u64,
569}
570
571#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
575#[serde(default, deny_unknown_fields)]
576pub struct Metering {
577 pub function: String,
579 pub invocations: u64,
581 pub successes: u64,
583 pub failures: u64,
585 pub duration_ms_total: u64,
587 pub bytes_in_total: u64,
589 pub bytes_out_total: u64,
591 pub window_start: u64,
593 pub window_count: u64,
595 pub updated: u64,
597}
598
599impl Metering {
600 pub fn new(function: impl Into<String>) -> Self {
602 Self {
603 function: function.into(),
604 ..Default::default()
605 }
606 }
607
608 pub fn record(&mut self, sample: &MeteringSample, now: u64) {
610 self.invocations += 1;
611 if sample.success {
612 self.successes += 1;
613 } else {
614 self.failures += 1;
615 }
616 self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
617 self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
618 self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
619 self.updated = now;
620 }
621
622 pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
627 let Some(max) = quota.max_invocations else {
628 return true;
629 };
630 let window = quota.window();
631 if now.saturating_sub(self.window_start) >= window {
632 self.window_start = now;
633 self.window_count = 0;
634 }
635 if self.window_count >= max {
636 return false;
637 }
638 self.window_count += 1;
639 self.updated = now;
640 true
641 }
642}
643
644pub mod keys {
645 pub fn meta(project: &str, name: &str) -> String {
652 format!("project/{project}/functions/{name}")
653 }
654 pub fn version(project: &str, name: &str, id: &str) -> String {
656 format!("project/{project}/functions/{name}/versions/{id}")
657 }
658 pub fn alias(project: &str, name: &str, label: &str) -> String {
660 format!("project/{project}/functions/{name}/alias/{label}")
661 }
662 pub fn trigger(project: &str, name: &str, id: &str) -> String {
664 format!("project/{project}/functions/{name}/triggers/{id}")
665 }
666 pub fn invocation(project: &str, name: &str, id: &str) -> String {
668 format!("project/{project}/functions/{name}/invocations/{id}")
669 }
670 pub fn invocations_prefix(project: &str, name: &str) -> String {
672 format!("project/{project}/functions/{name}/invocations/")
673 }
674 pub fn idempotency(project: &str, name: &str, key: &str) -> String {
676 format!("project/{project}/functions/{name}/idem/{key}")
677 }
678 pub fn metering(project: &str, name: &str) -> String {
680 format!("project/{project}/metering/{name}")
681 }
682
683 pub fn functions_prefix(project: &str) -> String {
687 format!("project/{project}/functions/")
688 }
689 pub fn triggers_prefix(project: &str, name: &str) -> String {
691 format!("project/{project}/functions/{name}/triggers/")
692 }
693 pub fn metering_prefix(project: &str) -> String {
695 format!("project/{project}/metering/")
696 }
697}
698
699pub fn handler_name(route: &str) -> String {
702 let s = slug(route);
703 if s.is_empty() {
704 "root".to_string()
705 } else {
706 s
707 }
708}
709
710pub fn consumer_name(topic: &str) -> String {
712 format!("consumer-{}", slug(topic))
713}
714
715fn slug(s: &str) -> String {
717 let mut out = String::new();
718 let mut dash = false;
719 for c in s.chars() {
720 if c.is_ascii_alphanumeric() {
721 out.push(c.to_ascii_lowercase());
722 dash = false;
723 } else if !out.is_empty() && !dash {
724 out.push('-');
725 dash = true;
726 }
727 }
728 out.trim_matches('-').to_string()
729}
730
731pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
743 let mut functions = Vec::new();
744 let mut triggers = Vec::new();
745
746 for h in &cfg.handlers {
747 let name = handler_name(&h.route);
748 functions.push(FunctionSpec {
749 name: name.clone(),
750 component: h.component.clone(),
751 config: FunctionConfig::from_handler(h),
752 lifecycle: Lifecycle::DeployPinned,
753 });
754 triggers.push(Trigger {
755 kind: TriggerKind::Route {
756 host: None,
757 path: h.route.clone(),
758 methods: h.methods.clone(),
759 },
760 target: Some(FunctionRef {
761 name,
762 version: None,
763 }),
764 });
765 }
766
767 for c in &cfg.consumers {
768 let name = consumer_name(&c.topic);
769 functions.push(FunctionSpec {
770 name: name.clone(),
771 component: c.component.clone(),
772 config: FunctionConfig::from_consumer(c),
773 lifecycle: Lifecycle::DeployPinned,
774 });
775 triggers.push(Trigger {
776 kind: TriggerKind::Queue {
777 topic: c.topic.clone(),
778 },
779 target: Some(FunctionRef {
780 name,
781 version: None,
782 }),
783 });
784 }
785
786 for cr in &cfg.crons {
787 let target = cfg
789 .handlers
790 .iter()
791 .find(|h| h.route == cr.route)
792 .map(|h| FunctionRef {
793 name: handler_name(&h.route),
794 version: None,
795 });
796 triggers.push(Trigger {
797 kind: TriggerKind::Cron {
798 schedule: cr.schedule.clone(),
799 overlap: cr.overlap,
800 },
801 target,
802 });
803 }
804
805 for s in &cfg.streams {
806 triggers.push(Trigger {
807 kind: TriggerKind::Stream {
808 topics: s.topics.clone(),
809 websocket: s.websocket,
810 publish_topic: s.publish_topic.clone(),
811 },
812 target: None,
813 });
814 }
815
816 (functions, triggers)
817}
818
819pub fn materialize(
826 specs: &[FunctionSpec],
827 site: &str,
828 files: &BTreeMap<String, FileEntry>,
829 created: u64,
830) -> Vec<Function> {
831 specs
832 .iter()
833 .filter_map(|s| {
834 let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
835 Some(Function {
836 name: s.name.clone(),
837 owner: Owner::Site(site.to_string()),
838 versions: vec![FunctionVersion {
839 id: hash.clone(),
840 component: hash.clone(),
841 created,
842 lifecycle: s.lifecycle,
843 }],
844 active: hash,
845 aliases: BTreeMap::new(),
846 config: s.config.clone(),
847 })
848 })
849 .collect()
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
856pub struct FunctionSummary {
857 pub name: String,
859 pub owner: String,
861 pub runtime: String,
863 pub version: String,
865 pub triggers: Vec<String>,
867}
868
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
873
874 fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
875 HandlerConfig {
876 route: route.into(),
877 methods: methods
878 .iter()
879 .map(std::string::ToString::to_string)
880 .collect(),
881 component: component.into(),
882 imports: imports
883 .iter()
884 .map(std::string::ToString::to_string)
885 .collect(),
886 limits: None,
887 env: BTreeMap::new(),
888 invoke_targets: Vec::new(),
889 }
890 }
891
892 #[test]
893 fn slugs_and_names() {
894 assert_eq!(handler_name("/api/hello"), "api-hello");
895 assert_eq!(handler_name("/"), "root");
896 assert_eq!(handler_name("/a/b/*"), "a-b");
897 assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
898 }
899
900 #[test]
904 fn desugar_preserves_all_compute_config() {
905 let cfg = DeployConfig {
906 handlers: vec![
907 handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
908 handler("/api/report", "report.wasm", &[], &[]),
909 ],
910 consumers: vec![ConsumerConfig {
911 topic: "orders".into(),
912 component: "orders.wasm".into(),
913 imports: vec!["sql".into()],
914 }],
915 crons: vec![CronConfig {
916 schedule: "0 * * * *".into(),
917 route: "/api/report".into(),
918 overlap: Overlap::Skip,
919 }],
920 streams: vec![StreamConfig {
921 route: "/live".into(),
922 topics: vec!["ticks".into()],
923 websocket: false,
924 publish_topic: None,
925 }],
926 ..Default::default()
927 };
928
929 let (functions, triggers) = desugar(&cfg);
930
931 assert_eq!(functions.len(), 3);
933 let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
934 assert_eq!(hello.component, "hello.wasm");
935 assert_eq!(hello.config.imports, vec!["kv".to_string()]);
936 assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
937 assert_eq!(hello.config.runtime, Runtime::Wasm);
938 let consumer = functions
939 .iter()
940 .find(|f| f.name == "consumer-orders")
941 .unwrap();
942 assert_eq!(consumer.component, "orders.wasm");
943 assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
944
945 assert_eq!(triggers.len(), 5);
947
948 let route = triggers
950 .iter()
951 .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
952 .unwrap();
953 match &route.kind {
954 TriggerKind::Route { methods, host, .. } => {
955 assert_eq!(methods, &["GET".to_string()]);
956 assert!(host.is_none());
957 }
958 _ => unreachable!(),
959 }
960 assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
961
962 let queue = triggers
964 .iter()
965 .find(|t| matches!(&t.kind, TriggerKind::Queue { topic } if topic == "orders"))
966 .unwrap();
967 assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
968
969 let cron = triggers
971 .iter()
972 .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
973 .unwrap();
974 assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
975
976 let stream = triggers
978 .iter()
979 .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
980 .unwrap();
981 assert!(stream.target.is_none());
982 match &stream.kind {
983 TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
984 _ => unreachable!(),
985 }
986 }
987
988 #[test]
989 fn materialize_resolves_paths_to_blob_hashes() {
990 let cfg = DeployConfig {
991 handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
992 ..Default::default()
993 };
994 let (specs, _) = desugar(&cfg);
995 let files = BTreeMap::from([(
996 "hello.wasm".to_string(),
997 FileEntry {
998 hash: "sha256:abc".into(),
999 size: 10,
1000 content_type: None,
1001 variants: BTreeMap::new(),
1002 },
1003 )]);
1004 let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1005 assert_eq!(funcs.len(), 1);
1006 assert_eq!(funcs[0].name, "api-hello");
1007 assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1008 assert_eq!(funcs[0].active, "sha256:abc");
1009 assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1010 assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1011 assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1013 }
1014
1015 #[test]
1016 fn empty_config_desugars_to_nothing() {
1017 let (functions, triggers) = desugar(&DeployConfig::default());
1018 assert!(functions.is_empty() && triggers.is_empty());
1019 }
1020
1021 #[test]
1022 fn model_serde_round_trips() {
1023 let f = Function {
1024 name: "resize".into(),
1025 owner: Owner::Project("acme".into()),
1026 versions: vec![FunctionVersion {
1027 id: "v1abc".into(),
1028 component: "blob:deadbeef".into(),
1029 created: 1_800_000_000,
1030 lifecycle: Lifecycle::Independent,
1031 }],
1032 active: "v1abc".into(),
1033 aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1034 config: FunctionConfig {
1035 imports: vec!["blobstore".into()],
1036 runtime: Runtime::Microvm,
1037 ..Default::default()
1038 },
1039 };
1040 let json = serde_json::to_string(&f).unwrap();
1041 assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1042
1043 for t in [
1045 Trigger {
1046 kind: TriggerKind::Route {
1047 host: Some("example.com".into()),
1048 path: "/x".into(),
1049 methods: vec!["POST".into()],
1050 },
1051 target: Some(FunctionRef {
1052 name: "resize".into(),
1053 version: None,
1054 }),
1055 },
1056 Trigger {
1057 kind: TriggerKind::Stream {
1058 topics: vec!["t".into()],
1059 websocket: true,
1060 publish_topic: Some("up".into()),
1061 },
1062 target: None,
1063 },
1064 ] {
1065 let j = serde_json::to_string(&t).unwrap();
1066 assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1067 }
1068 }
1069
1070 #[test]
1071 fn versioning_alias_and_rollback() {
1072 let mut f = Function::new(
1073 "resize",
1074 Owner::Project("acme".into()),
1075 "hashA",
1076 FunctionConfig::default(),
1077 Lifecycle::Independent,
1078 1,
1079 );
1080 assert_eq!(f.active, "hashA");
1081 assert_eq!(f.versions.len(), 1);
1082
1083 f.upsert_version("hashB", Lifecycle::Independent, 2);
1085 assert_eq!(f.active, "hashB");
1086 assert_eq!(f.versions.len(), 2);
1087 f.upsert_version("hashA", Lifecycle::Independent, 3);
1089 assert_eq!(f.active, "hashA");
1090 assert_eq!(f.versions.len(), 2);
1091
1092 f.set_alias("prod", "hashB").unwrap();
1094 assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1095 assert!(f.set_alias("prod", "ghost").is_err());
1096
1097 f.rollback("hashB").unwrap();
1099 assert_eq!(f.active, "hashB");
1100 assert!(f.rollback("ghost").is_err());
1101
1102 assert_eq!(f.resolve("hashA"), Some("hashA"));
1105 assert_eq!(f.resolve("prod"), Some("hashB")); assert_eq!(f.resolve("ghost"), None);
1107 }
1108
1109 #[test]
1110 fn invocation_model_round_trips_and_reports_terminal() {
1111 let inv = Invocation {
1112 id: "inv-1".into(),
1113 function: "greeter".into(),
1114 version: "hashA".into(),
1115 mode: InvokeMode::Async,
1116 status: InvocationStatus::Queued,
1117 idempotency_key: Some("k".into()),
1118 attempts: 0,
1119 request_b64: Some("aGk=".into()),
1120 request_content_type: Some("text/plain".into()),
1121 result: None,
1122 created: 1,
1123 updated: 1,
1124 };
1125 assert!(!inv.is_terminal());
1126 let json = serde_json::to_string(&inv).unwrap();
1127 let back: Invocation = serde_json::from_str(&json).unwrap();
1128 assert_eq!(back, inv);
1129 assert!(json.contains("\"mode\":\"async\""));
1131 assert!(json.contains("\"status\":\"queued\""));
1132
1133 let done = Invocation {
1134 status: InvocationStatus::Succeeded,
1135 result: Some(InvocationResult {
1136 status: 200,
1137 content_type: None,
1138 body_b64: "b2s=".into(),
1139 }),
1140 ..inv
1141 };
1142 assert!(done.is_terminal());
1143 }
1144
1145 #[test]
1146 fn metering_records_and_rate_limits() {
1147 let mut m = Metering::new("greeter");
1148 m.record(
1149 &MeteringSample {
1150 success: true,
1151 duration_ms: 5,
1152 bytes_in: 3,
1153 bytes_out: 7,
1154 },
1155 100,
1156 );
1157 m.record(
1158 &MeteringSample {
1159 success: false,
1160 duration_ms: 2,
1161 bytes_in: 0,
1162 bytes_out: 0,
1163 },
1164 101,
1165 );
1166 assert_eq!(m.invocations, 2);
1167 assert_eq!(m.successes, 1);
1168 assert_eq!(m.failures, 1);
1169 assert_eq!(m.duration_ms_total, 7);
1170 assert_eq!(m.bytes_out_total, 7);
1171 assert_eq!(m.updated, 101);
1172
1173 let quota = FunctionQuota {
1175 max_invocations: Some(2),
1176 window_secs: Some(10),
1177 max_concurrent: None,
1178 };
1179 let mut r = Metering::new("greeter");
1180 assert!(r.admit("a, 1000)); assert!(r.admit("a, 1001)); assert!(!r.admit("a, 1002)); assert_eq!(r.window_count, 2);
1184 assert!(r.admit("a, 1011));
1186 assert_eq!(r.window_count, 1);
1187
1188 let unset = FunctionQuota::default();
1190 let mut u = Metering::new("greeter");
1191 assert!(u.admit(&unset, 1));
1192 assert_eq!(u.window_count, 0);
1193 assert!(unset.is_unset());
1194 }
1195
1196 #[test]
1197 fn webhook_config_defaults_and_round_trips() {
1198 let w = WebhookConfig {
1200 secret_env: "HOOK_SECRET".into(),
1201 algorithm: WebhookAlgorithm::HmacSha256,
1202 signature_header: None,
1203 max_body_bytes: None,
1204 };
1205 assert_eq!(w.header(), "x-boatramp-signature");
1206 assert_eq!(w.body_cap(), 1024 * 1024);
1207
1208 let cfg = FunctionConfig {
1210 webhook: Some(w),
1211 ..Default::default()
1212 };
1213 let json = serde_json::to_string(&cfg).unwrap();
1214 assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1215 assert!(json.contains("\"hmac_sha256\""));
1216 let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1217 assert_eq!(back, cfg);
1218
1219 let custom = WebhookConfig {
1221 secret_env: "S".into(),
1222 algorithm: WebhookAlgorithm::HmacSha256,
1223 signature_header: Some("x-hub-signature-256".into()),
1224 max_body_bytes: Some(4096),
1225 };
1226 assert_eq!(custom.header(), "x-hub-signature-256");
1227 assert_eq!(custom.body_cap(), 4096);
1228 }
1229
1230 #[test]
1231 fn keyspace_is_stable() {
1232 assert_eq!(
1233 keys::meta("default", "resize"),
1234 "project/default/functions/resize"
1235 );
1236 assert_eq!(
1237 keys::version("default", "resize", "v1"),
1238 "project/default/functions/resize/versions/v1"
1239 );
1240 assert_eq!(
1241 keys::alias("default", "resize", "prod"),
1242 "project/default/functions/resize/alias/prod"
1243 );
1244 assert_eq!(
1245 keys::trigger("default", "resize", "t1"),
1246 "project/default/functions/resize/triggers/t1"
1247 );
1248 assert_eq!(
1249 keys::invocation("default", "resize", "inv-1"),
1250 "project/default/functions/resize/invocations/inv-1"
1251 );
1252 assert_eq!(
1253 keys::invocations_prefix("default", "resize"),
1254 "project/default/functions/resize/invocations/"
1255 );
1256 assert_eq!(
1257 keys::idempotency("default", "resize", "k-1"),
1258 "project/default/functions/resize/idem/k-1"
1259 );
1260 assert_eq!(
1261 keys::metering("default", "resize"),
1262 "project/default/metering/resize"
1263 );
1264 assert_eq!(
1266 keys::meta("acme", "resize"),
1267 "project/acme/functions/resize"
1268 );
1269 }
1270}