boatramp_types/function.rs
1//! The FaaS **function** model — PLAN-faas FA-1.
2//!
3//! A **function** is the compute *artifact*: a versioned WASI component + its
4//! binding/capability config. It is the one primitive the engine runs (decision 1
5//! — "one primitive, two views"). A **handler** is *not* a resource — it is a
6//! function reached by an HTTP **route** trigger; likewise a *consumer* / *cron* is
7//! a function reached by a queue / timer trigger (decision 5). Triggers are their
8//! own objects, and many can point at one function version (decision 2).
9//!
10//! [`desugar`] lowers a site's deploy-scoped `handlers/consumers/crons/streams`
11//! into functions + triggers with **no behavioural change** — the mandatory
12//! non-breaking gate: a site's handlers must run identically before and after.
13//! It is a pure config→shape transform; the content hash / version id of each
14//! function is assigned later, at `sync`, when its component blob is uploaded.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::{ConsumerConfig, DeployConfig, HandlerConfig, HandlerLimits, Overlap};
21use crate::file::FileEntry;
22
23/// A function's owner — a **site** or a **project/tenant**. Drives the KV/blob/sql
24/// binding prefix and the inherited RBAC (a site-scoped function gains no privilege
25/// over its site).
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Owner {
29 /// Owned by a site: `fn/<site>/<name>`, binding prefix + RBAC of the site.
30 Site(String),
31 /// A top-level function owned by a project/tenant: `fn/<name>`.
32 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/// A function version's lifecycle (decision 3: `DeployPinned` is the default).
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "kebab-case")]
47pub enum Lifecycle {
48 /// Versions + rolls back atomically with the owning site's deploy.
49 #[default]
50 DeployPinned,
51 /// Its own version / alias / rollback, independent of any deploy.
52 Independent,
53}
54
55/// The execution substrate (decision 1: a per-function knob; `wasm` is the default
56/// and scales to zero by instantiation; the stronger-isolation substrates are the
57/// compute backends — see PLAN-compute-backends).
58#[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 /// The snake_case wire term (matches the serde `rename_all`).
69 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/// A function's binding/capability config — the `HandlerConfig` capability fields
85/// (imports, resource limits, static env) *minus its trigger* (route/methods, which
86/// become a [`Trigger`]), plus the [`Runtime`] knob. A `HandlerConfig` *is* a
87/// function's config, so the engine has one path.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct FunctionConfig {
91 /// Requested host capabilities (interface names).
92 #[serde(skip_serializing_if = "Vec::is_empty")]
93 pub imports: Vec<String>,
94 /// Optional resource limits (mem / timeout / fuel).
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub limits: Option<HandlerLimits>,
97 /// Static, non-secret environment.
98 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
99 pub env: BTreeMap<String, String>,
100 /// Env-var name → secret reference (`ENV_VAR` → `HOST_ENV`), resolved
101 /// server-side from the serve env at instantiation; the value is a
102 /// reference, never stored in the manifest or the control-plane store
103 /// (mirrors a site handler's `[handlers].secrets`).
104 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
105 pub secrets: BTreeMap<String, String>,
106 /// Execution substrate (default `wasm`).
107 pub runtime: Runtime,
108 /// Usage quota (FA-4). Absent / all-`None` ⇒ unlimited.
109 #[serde(default, skip_serializing_if = "FunctionQuota::is_unset")]
110 pub quota: FunctionQuota,
111 /// Signed inbound-webhook ingress (FA-5). Absent ⇒ no webhook endpoint.
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub webhook: Option<WebhookConfig>,
114 /// Function-to-function invoke allowlist (FI): the target names this function
115 /// may call through the `invoke` capability. Each entry may use `*` wildcards
116 /// (`*` = any sibling, `img-*` = a family, `resize` = one literal). Deny by
117 /// default: empty ⇒ the function cannot invoke anything, even if it imports
118 /// `invoke`. Only consulted when `imports` contains `invoke`.
119 #[serde(skip_serializing_if = "Vec::is_empty")]
120 pub invoke_targets: Vec<String>,
121 /// Declared `bus:` stats-topic templates for the read-only `messaging-stats` capability: each a
122 /// `bus:`-relative topic with an optional literal `{tenant}` placeholder the HOST fills with this
123 /// invocation's resolved tenant (e.g. `sync/{tenant}/import`). A guest's `messaging-stats` call for
124 /// a `bus:` topic must name one of these verbatim; the host substitutes the tenant, so the guest
125 /// can never read another tenant's bus stats. Deny by default: empty ⇒ no bus stats readable. Only
126 /// consulted when `imports` contains `messaging-stats`.
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub stats_topics: Vec<String>,
129 /// Per-component name allowlist for the per-tenant sealed-secret capability
130 /// (`boatramp:handlers/tenant-secrets`, task #493): the secret names this function may address
131 /// for its resolved tenant. Least-privilege: **empty ⇒ deny-all**. A name not in the list is
132 /// `access-denied`; the resolved tenant is always host-supplied. Only consulted when `imports`
133 /// contains a `tenant-secrets:*` right.
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub tenant_secret_names: Vec<String>,
136 /// Per-component container allowlist for the guest blob-upload mint capability
137 /// (`boatramp:handlers/blob-upload`, S3 external ingress): the blob containers this function may
138 /// mint an upload credential for. Least-privilege: **empty ⇒ deny-all**. A container not in the
139 /// list is `access-denied`; the project is always host-forced from the resolved invocation scope,
140 /// and the site is host-forced from [`blob_upload_site`](Self::blob_upload_site). Only consulted
141 /// when `imports` contains a `blob-upload:*` right.
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub upload_containers: Vec<String>,
144 /// Per-component container allowlist for the **plain `wasi:blobstore`** capability (host-enforced
145 /// tenant confinement; same contract as [`crate::config::HandlerConfig::blobstore_containers`]):
146 /// the blob containers this function may open, `{tenant}`-templated against its host-resolved OWN
147 /// tenant. SEPARATE from [`upload_containers`](Self::upload_containers) — no inheritance/fallback.
148 /// Empty on a multi-tenant function ⇒ deny-all (fail-closed); empty single-tenant ⇒ permissive.
149 /// Only consulted when `imports` contains `wasi:blobstore`.
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub blobstore_containers: Vec<String>,
152 /// Per-component **outbound-TCP allowlist** for the gated `wasi:sockets` capability (same contract
153 /// as [`crate::config::HandlerConfig::egress_allowlist`]): the `host:port` / `ip:port` /
154 /// `CIDR:port` targets this function may `connect` to. **Empty ⇒ deny-all.** A standalone
155 /// top-level function opts into outbound TCP by naming targets here; the engine's
156 /// `socket_addr_check` then enforces it connect-only + SSRF-floored, and ONLY when the node
157 /// posture `allow_guest_tcp_egress` is on. Only consulted when `imports` contains `wasi:sockets`.
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub egress_allowlist: Vec<String>,
160 /// The site a **standalone top-level function** mints blob-upload credentials for
161 /// (`boatramp:handlers/blob-upload`, S3 external ingress). A site handler mints for its own
162 /// host-routed site, but a standalone function has no single resolved site — so it names one here,
163 /// in its config (**host-forced, never guest-supplied**: the WIT surface still has no project/site
164 /// parameter). At bind time the host validates the named site actually exists in the function's
165 /// (host-forced) project; the credential then lands objects at
166 /// `hblob/{project-qualified-site}/{container}/{key}` — the exact prefix the guest `compat::blob`
167 /// read path serves, so nothing new is invented. Absent, or naming a site that does not exist in
168 /// the project ⇒ **fail-closed** (`no-resolved-site`, the binding is not attached). A guest can
169 /// never override it. Only consulted when `imports` contains a `blob-upload:*` right.
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub blob_upload_site: Option<String>,
172 /// In-site tenancy decision for this function's `sql`/`orm` access (Dimension 0). Absent ⇒
173 /// *undeclared* (refused under the `multi-tenant` posture, treated as `Disabled` — plain
174 /// queries — under single-tenant/dev). `Scoped` opts into host-injected row scoping. Parsed via
175 /// the [`crate::tenancy::de_opt_tenancy`] bridge so it reads from RON (`project.cfg`) as well as
176 /// the stored JSON.
177 #[serde(
178 default,
179 deserialize_with = "crate::tenancy::de_opt_tenancy",
180 skip_serializing_if = "Option::is_none"
181 )]
182 pub tenancy: Option<crate::tenancy::Tenancy>,
183 /// JWKS/issuer config verifying the app bearer for a [`crate::tenancy::TenantSource::Token`]
184 /// tenant source when this function is invoked over HTTP (the function analogue of a site's
185 /// `[handlers.graphql.data].claims_from_token`). Absent ⇒ the token source can't verify, so it
186 /// resolves no value (fail-closed); the domain/none sources don't need it.
187 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub token_claims: Option<crate::config::HandlerGraphqlTokenClaims>,
189}
190
191/// The signature scheme a webhook is verified under (FA-5).
192#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum WebhookAlgorithm {
195 /// `HMAC-SHA256(body, secret)`, hex — the GitHub/Stripe-style scheme.
196 #[default]
197 HmacSha256,
198}
199
200/// Signed inbound-webhook config for a function (FA-5). The verifying secret is a
201/// **reference to a host env var** (never stored plaintext — mirrors site
202/// secrets); the endpoint verifies the request signature over the raw body,
203/// constant-time, **before** the guest runs.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct WebhookConfig {
207 /// The host env var holding the shared secret.
208 pub secret_env: String,
209 /// Signature scheme (default HMAC-SHA256).
210 #[serde(default)]
211 pub algorithm: WebhookAlgorithm,
212 /// The header carrying the hex signature (default `x-boatramp-signature`; a
213 /// leading `sha256=` is accepted and stripped).
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub signature_header: Option<String>,
216 /// Max request body accepted before verifying/dispatching (default 1 MiB).
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub max_body_bytes: Option<u64>,
219 /// If set, a **verified** webhook publishes its body onto the project bus at
220 /// this topic (a consumer subscribes with `bus:<topic>`) and returns `202`,
221 /// **without running any component** — the "external event → the fabric"
222 /// ingress, so a quick webhook needs no dedicated `wasi:http` function. Unset
223 /// ⇒ the verified webhook invokes the function as before.
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub publish: Option<String>,
226}
227
228impl WebhookConfig {
229 /// The signature header name (defaulted).
230 pub fn header(&self) -> &str {
231 self.signature_header
232 .as_deref()
233 .unwrap_or("x-boatramp-signature")
234 }
235 /// The body cap (defaulted to 1 MiB).
236 pub fn body_cap(&self) -> u64 {
237 self.max_body_bytes.unwrap_or(1024 * 1024)
238 }
239}
240
241impl FunctionConfig {
242 fn from_handler(h: &HandlerConfig) -> Self {
243 Self {
244 imports: h.imports.clone(),
245 limits: h.limits.clone(),
246 env: h.env.clone(),
247 // Site handlers carry their secret refs on the site config
248 // (`[handlers].secrets`), resolved by the handler dispatch path — a
249 // desugared handler-function starts with none of its own.
250 secrets: BTreeMap::new(),
251 runtime: Runtime::default(),
252 quota: FunctionQuota::default(),
253 webhook: None,
254 invoke_targets: Vec::new(),
255 // Carry the handler's declared `messaging-stats` bus-topic templates onto the desugared
256 // function so the grant surface is identical whichever path serves it.
257 stats_topics: h.stats_topics.clone(),
258 // Carry the handler's tenant-secret name allowlist likewise (grant surface parity).
259 tenant_secret_names: h.tenant_secret_names.clone(),
260 // Carry the handler's blob-upload container allowlist likewise (grant surface parity).
261 upload_containers: h.upload_containers.clone(),
262 // Carry the handler's plain-`wasi:blobstore` container allowlist likewise (grant parity).
263 blobstore_containers: h.blobstore_containers.clone(),
264 // Carry the handler's outbound-TCP `egress_allowlist` likewise (gated `wasi:sockets` parity).
265 egress_allowlist: h.egress_allowlist.clone(),
266 // A desugared handler-function runs on the site-handler dispatch path, where the site is
267 // host-ROUTED (not config-declared) — so it has no `blob_upload_site` of its own. The
268 // `blob_upload_site` field is exclusively the STANDALONE top-level function's way to name
269 // the site it mints for.
270 blob_upload_site: None,
271 // A desugared handler-function carries its own per-handler tenancy when declared;
272 // absent ⇒ inherit the site config (the dispatch path applies the site decision).
273 tenancy: h.tenancy.clone(),
274 token_claims: h.token_claims.clone(),
275 }
276 }
277 fn from_consumer(c: &ConsumerConfig) -> Self {
278 Self {
279 imports: c.imports.clone(),
280 // A consumer's own tenancy (typically `signed_context` on the async lane) + token
281 // claims are carried onto the desugared function it runs as.
282 tenancy: c.tenancy.clone(),
283 token_claims: c.token_claims.clone(),
284 // Carry the consumer's declared `messaging-stats` bus-topic templates.
285 stats_topics: c.stats_topics.clone(),
286 // Carry the consumer's tenant-secret name allowlist likewise.
287 tenant_secret_names: c.tenant_secret_names.clone(),
288 // Carry the consumer's blob-upload container allowlist likewise.
289 upload_containers: c.upload_containers.clone(),
290 // Carry the consumer's plain-`wasi:blobstore` container allowlist likewise.
291 blobstore_containers: c.blobstore_containers.clone(),
292 // Carry the consumer's outbound-TCP `egress_allowlist` likewise (gated `wasi:sockets`).
293 egress_allowlist: c.egress_allowlist.clone(),
294 ..Default::default()
295 }
296 }
297}
298
299/// A reference to a function (a `None` version = the function's active version).
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(deny_unknown_fields)]
302pub struct FunctionRef {
303 /// The function name (site-scoped names are `<site>/<name>`).
304 pub name: String,
305 /// A specific version id, or `None` for the active version.
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub version: Option<String>,
308}
309
310/// A **trigger** — a separate object (decision 2). Many triggers may point at the
311/// same function version (e.g. a route *and* a cron). A `target` of `None` is a
312/// host-native trigger (a stream fan-out has no component).
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(deny_unknown_fields)]
315pub struct Trigger {
316 /// What fires the trigger.
317 pub kind: TriggerKind,
318 /// The function it invokes, or `None` for a host-native trigger.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub target: Option<FunctionRef>,
321}
322
323/// The event that fires a trigger. The role words (route/queue/cron/stream) are the
324/// familiar site-view names; each is just *a way to reach a function*.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(tag = "type", rename_all = "snake_case")]
327pub enum TriggerKind {
328 /// An HTTP route — the "handler" shape. `host` scopes it to a virtualhost.
329 Route {
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 host: Option<String>,
332 path: String,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 methods: Vec<String>,
335 },
336 /// A stable invoke name — `/api/functions/<name>` (the FaaS verb, FA-3).
337 Invoke { name: String },
338 /// A message topic — the "consumer" shape. An empty `group` is the default
339 /// work-queue; a non-empty `group` is a durable fan-out subscriber (every
340 /// message, its own cursor) starting at `start` on first subscription.
341 Queue {
342 topic: String,
343 #[serde(default, skip_serializing_if = "String::is_empty")]
344 group: String,
345 #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
346 start: crate::config::StartPosition,
347 },
348 /// A cron schedule — the "cron" shape.
349 Cron {
350 schedule: String,
351 #[serde(default)]
352 overlap: Overlap,
353 },
354 /// An object-storage change under a prefix (FA-5).
355 Blob { prefix: String },
356 /// A signed inbound webhook (FA-5). `secret_env` names the host env var
357 /// holding the verifying secret (never the secret itself).
358 Webhook { path: String, secret_env: String },
359 /// Host-native SSE / WebSocket topic fan-out — the "stream" shape (no component,
360 /// so a stream trigger's `target` is `None`).
361 Stream {
362 topics: Vec<String>,
363 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
364 websocket: bool,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 publish_topic: Option<String>,
367 },
368}
369
370impl std::fmt::Display for Trigger {
371 /// A short one-line label for the functions view (`route GET /x`, `queue t`, …).
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 match &self.kind {
374 TriggerKind::Route { path, methods, .. } => {
375 let m = if methods.is_empty() {
376 "*".to_string()
377 } else {
378 methods.join(",")
379 };
380 write!(f, "route {m} {path}")
381 }
382 TriggerKind::Invoke { name } => write!(f, "invoke {name}"),
383 TriggerKind::Queue { topic, .. } => write!(f, "queue {topic}"),
384 TriggerKind::Cron { schedule, .. } => write!(f, "cron {schedule}"),
385 TriggerKind::Blob { prefix } => write!(f, "blob {prefix}"),
386 TriggerKind::Webhook { path, .. } => write!(f, "webhook {path}"),
387 TriggerKind::Stream { topics, .. } => write!(f, "stream {}", topics.join(",")),
388 }
389 }
390}
391
392/// A **stored trigger** bound to a top-level function — the durable form the
393/// server dispatches (FA-3 *scheduled* + FA-5 *event sources*). Its owning
394/// function is the key context, so there is no separate `target`. Keyed under
395/// [`keys::trigger`].
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(deny_unknown_fields)]
398pub struct FunctionTrigger {
399 /// Unique id within the function (the key suffix).
400 pub id: String,
401 /// What fires it.
402 pub kind: TriggerKind,
403 /// Cron dedup: the minute-stamp (`hour*60 + minute` within the day, or a
404 /// monotonic per-fire stamp) this trigger last fired at — durable so a fire
405 /// isn't repeated across a restart or (in a cluster) a leader change.
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub last_fired_minute: Option<i64>,
408}
409
410/// A stored, content-addressed function resource (the FA-1/FA-2 keyspace form).
411#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
412#[serde(deny_unknown_fields)]
413pub struct Function {
414 /// The function name (unique within its owner).
415 pub name: String,
416 /// Who owns it (drives binding prefix + RBAC).
417 pub owner: Owner,
418 /// Immutable versions, newest last.
419 pub versions: Vec<FunctionVersion>,
420 /// The active version's id.
421 pub active: String,
422 /// Named aliases → version id (staging/previews; mirrors deploy aliases).
423 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
424 pub aliases: BTreeMap<String, String>,
425 /// Binding/capability config.
426 pub config: FunctionConfig,
427}
428
429/// A [`Function`] operation named a version id the function has no record of
430/// (`rollback`/`set_alias`). A typed error so a caller can distinguish it (e.g.
431/// map it to a 404) instead of string-matching a message.
432#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
433#[error("no version {id:?} in function {function:?}")]
434pub struct UnknownVersion {
435 /// The function the operation was on.
436 pub function: String,
437 /// The version id that was not found.
438 pub id: String,
439}
440
441impl Function {
442 /// A new top-level function with a single active version (the component blob).
443 /// The version id **is** the component's content hash (content-addressed).
444 pub fn new(
445 name: impl Into<String>,
446 owner: Owner,
447 component_hash: impl Into<String>,
448 config: FunctionConfig,
449 lifecycle: Lifecycle,
450 created: u64,
451 ) -> Self {
452 let hash = component_hash.into();
453 Self {
454 name: name.into(),
455 owner,
456 versions: vec![FunctionVersion {
457 id: hash.clone(),
458 component: hash.clone(),
459 created,
460 lifecycle,
461 }],
462 active: hash,
463 aliases: BTreeMap::new(),
464 config,
465 }
466 }
467
468 /// Add a version for `component_hash` (id = the hash) and make it active. If a
469 /// version with that hash already exists it is just re-activated (idempotent).
470 /// Returns the (active) version id.
471 pub fn upsert_version(
472 &mut self,
473 component_hash: impl Into<String>,
474 lifecycle: Lifecycle,
475 created: u64,
476 ) -> String {
477 let hash = component_hash.into();
478 if !self.versions.iter().any(|v| v.id == hash) {
479 self.versions.push(FunctionVersion {
480 id: hash.clone(),
481 component: hash.clone(),
482 created,
483 lifecycle,
484 });
485 }
486 self.active = hash.clone();
487 hash
488 }
489
490 /// Append a version for `component_hash` WITHOUT activating it (deploy-resilience #1b async
491 /// deploy): the version is stored but `active` (the served pointer) is unchanged, so it cannot
492 /// serve until background validation promotes it with [`rollback`](Self::rollback). Idempotent
493 /// on the version id. Returns the version id.
494 pub fn add_version(
495 &mut self,
496 component_hash: impl Into<String>,
497 lifecycle: Lifecycle,
498 created: u64,
499 ) -> String {
500 let hash = component_hash.into();
501 if !self.versions.iter().any(|v| v.id == hash) {
502 self.versions.push(FunctionVersion {
503 id: hash.clone(),
504 component: hash.clone(),
505 created,
506 lifecycle,
507 });
508 }
509 hash
510 }
511
512 /// Point `active` at an existing version id. `Err` if the version is unknown.
513 pub fn rollback(&mut self, to: &str) -> Result<(), UnknownVersion> {
514 if self.versions.iter().any(|v| v.id == to) {
515 self.active = to.to_string();
516 Ok(())
517 } else {
518 Err(UnknownVersion {
519 function: self.name.clone(),
520 id: to.to_string(),
521 })
522 }
523 }
524
525 /// Set `label` → an existing version id. `Err` if the version is unknown.
526 pub fn set_alias(&mut self, label: &str, version: &str) -> Result<(), UnknownVersion> {
527 if self.versions.iter().any(|v| v.id == version) {
528 self.aliases.insert(label.to_string(), version.to_string());
529 Ok(())
530 } else {
531 Err(UnknownVersion {
532 function: self.name.clone(),
533 id: version.to_string(),
534 })
535 }
536 }
537
538 /// Resolve a **reference** — an alias label or a version id — to the component
539 /// blob hash that backs it, if known. An alias label is resolved first, then a
540 /// version id (so a label named like an id still wins as a label).
541 pub fn resolve(&self, reference: &str) -> Option<&str> {
542 let id = self
543 .aliases
544 .get(reference)
545 .map(String::as_str)
546 .unwrap_or(reference);
547 self.versions
548 .iter()
549 .find(|v| v.id == id)
550 .map(|v| v.component.as_str())
551 }
552}
553
554/// One immutable version of a function.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(deny_unknown_fields)]
557pub struct FunctionVersion {
558 /// Immutable content-hash id.
559 pub id: String,
560 /// The component blob hash.
561 pub component: String,
562 /// Unix creation time.
563 pub created: u64,
564 /// This version's lifecycle.
565 #[serde(default)]
566 pub lifecycle: Lifecycle,
567}
568
569/// The desugared shape of a function derived from a site's `DeployConfig`. The
570/// `component` is still a **path** within the deploy — its content hash / version
571/// id is assigned at `sync`, when the blob is uploaded (so this stays a pure
572/// config→shape transform, testable without a blob store).
573#[derive(Debug, Clone, PartialEq, Eq)]
574pub struct FunctionSpec {
575 /// Site-scoped function name (see [`handler_name`] / [`consumer_name`]).
576 pub name: String,
577 /// The component path within the deploy.
578 pub component: String,
579 /// Binding/capability config.
580 pub config: FunctionConfig,
581 /// Version lifecycle (deploy-scoped functions are `DeployPinned`).
582 pub lifecycle: Lifecycle,
583}
584
585/// How an invocation is delivered (FA-3). `Sync` runs inline and returns the
586/// function's response; `Async` durably enqueues the call, returns `202` with an
587/// id, and a drain worker runs it later (retried, then dead-lettered).
588#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
589#[serde(rename_all = "snake_case")]
590pub enum InvokeMode {
591 /// Run inline; the caller blocks on the function's response.
592 #[default]
593 Sync,
594 /// Durably enqueue; the caller gets an id to poll.
595 Async,
596}
597
598/// The lifecycle of a durable (async) invocation.
599#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "snake_case")]
601pub enum InvocationStatus {
602 /// Enqueued, not yet claimed by a drain worker.
603 #[default]
604 Queued,
605 /// Claimed and executing.
606 Running,
607 /// Completed (the function returned a response — any HTTP status).
608 Succeeded,
609 /// Exhausted its attempts and was dead-lettered.
610 Failed,
611}
612
613/// The captured response of a completed invocation (sync idempotency replay +
614/// async poll both read this). The body is base64 so the record is plain JSON.
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616#[serde(deny_unknown_fields)]
617pub struct InvocationResult {
618 /// The HTTP status the function returned.
619 pub status: u16,
620 /// The response content type, if any.
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub content_type: Option<String>,
623 /// The response body, base64 (standard, no padding stripped).
624 pub body_b64: String,
625}
626
627/// A durable invocation record — the unit of the async queue and the receipt an
628/// idempotency key replays. Keyed under [`keys::invocation`].
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(deny_unknown_fields)]
631pub struct Invocation {
632 /// Opaque invocation id (also the queue key suffix).
633 pub id: String,
634 /// The function invoked.
635 pub function: String,
636 /// The function version that ran (or will run) — pinned at enqueue so a
637 /// later deploy can't silently change an in-flight async call.
638 pub version: String,
639 /// Delivery mode.
640 pub mode: InvokeMode,
641 /// Current status.
642 pub status: InvocationStatus,
643 /// The idempotency key that created it, if any.
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub idempotency_key: Option<String>,
646 /// Delivery attempts so far (async).
647 #[serde(default)]
648 pub attempts: u32,
649 /// Lease expiry (unix seconds) while an async invocation is `Running`: the
650 /// drain that claims it stamps `now + ceiling + margin`, and the drain
651 /// treats a `Running` record whose lease has elapsed as reclaimable — so a
652 /// node that crashes mid-invocation never orphans the job. `None` once the
653 /// invocation settles (or while still `Queued`).
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub lease_expires: Option<u64>,
656 /// The request body the function receives, base64.
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub request_b64: Option<String>,
659 /// The request content type forwarded to the function.
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub request_content_type: Option<String>,
662 /// The captured result once complete.
663 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub result: Option<InvocationResult>,
665 /// Unix create time.
666 pub created: u64,
667 /// Unix last-update time.
668 pub updated: u64,
669}
670
671impl Invocation {
672 /// Whether the invocation reached a terminal state.
673 pub fn is_terminal(&self) -> bool {
674 matches!(
675 self.status,
676 InvocationStatus::Succeeded | InvocationStatus::Failed
677 )
678 }
679}
680
681/// Per-function usage quota (FA-4) — `require`-style knobs enforced host-side,
682/// **fail-closed** (over the limit ⇒ `429`). Accounting, not billing: this bounds
683/// abuse, it does not charge. All limits are per node today (a cluster-wide token
684/// bucket is future work); `None` on a field means that dimension is unlimited.
685#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(default, deny_unknown_fields)]
687pub struct FunctionQuota {
688 /// Max invocations admitted within [`window_secs`](Self::window_secs) (a
689 /// fixed window: the counter resets when the window rolls over).
690 #[serde(skip_serializing_if = "Option::is_none")]
691 pub max_invocations: Option<u64>,
692 /// The rate-limit window length in seconds (defaults to 60 when a
693 /// `max_invocations` cap is set).
694 #[serde(skip_serializing_if = "Option::is_none")]
695 pub window_secs: Option<u64>,
696 /// Max concurrent in-flight invocations for this function (per node).
697 #[serde(skip_serializing_if = "Option::is_none")]
698 pub max_concurrent: Option<u32>,
699}
700
701impl FunctionQuota {
702 /// Whether any dimension is set (an all-`None` quota is a no-op the caller can
703 /// skip entirely).
704 pub fn is_unset(&self) -> bool {
705 self.max_invocations.is_none() && self.max_concurrent.is_none()
706 }
707 /// The effective rate-limit window (defaults to 60s).
708 pub fn window(&self) -> u64 {
709 self.window_secs.unwrap_or(60)
710 }
711}
712
713/// One invocation's measured cost, folded into a [`Metering`] aggregate.
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub struct MeteringSample {
716 /// Whether the invocation succeeded (the guest produced a response).
717 pub success: bool,
718 /// Wall-clock duration in milliseconds (the server-side CPU-time proxy; true
719 /// fuel accounting needs the engine to surface post-completion cost).
720 pub duration_ms: u64,
721 /// Request bytes delivered to the function.
722 pub bytes_in: u64,
723 /// Response bytes the function produced.
724 pub bytes_out: u64,
725}
726
727/// Host-side usage aggregate for one function (FA-4), tenant-isolated under
728/// [`keys::metering`]. It also carries the fixed-window rate-limit counter so
729/// metering + quota share a single read-modify-write.
730#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(default, deny_unknown_fields)]
732pub struct Metering {
733 /// The function these counters belong to.
734 pub function: String,
735 /// Total invocations metered (sync + drained async).
736 pub invocations: u64,
737 /// Invocations whose guest produced a response.
738 pub successes: u64,
739 /// Invocations that failed to deliver (engine error / dead-lettered).
740 pub failures: u64,
741 /// Summed wall-clock duration, milliseconds.
742 pub duration_ms_total: u64,
743 /// Summed request bytes.
744 pub bytes_in_total: u64,
745 /// Summed response bytes.
746 pub bytes_out_total: u64,
747 /// The current rate-limit window's start (unix seconds).
748 pub window_start: u64,
749 /// Invocations admitted in the current window.
750 pub window_count: u64,
751 /// Last update (unix seconds).
752 pub updated: u64,
753}
754
755impl Metering {
756 /// A fresh aggregate for `function`.
757 pub fn new(function: impl Into<String>) -> Self {
758 Self {
759 function: function.into(),
760 ..Default::default()
761 }
762 }
763
764 /// Fold one invocation's cost into the usage counters.
765 pub fn record(&mut self, sample: &MeteringSample, now: u64) {
766 self.invocations += 1;
767 if sample.success {
768 self.successes += 1;
769 } else {
770 self.failures += 1;
771 }
772 self.duration_ms_total = self.duration_ms_total.saturating_add(sample.duration_ms);
773 self.bytes_in_total = self.bytes_in_total.saturating_add(sample.bytes_in);
774 self.bytes_out_total = self.bytes_out_total.saturating_add(sample.bytes_out);
775 self.updated = now;
776 }
777
778 /// Admit one invocation against the fixed-window rate limit, rolling the
779 /// window over when it has elapsed. Returns `true` if admitted (and records
780 /// the admission), `false` if the window is already at `max` (⇒ the caller
781 /// fails closed with `429`). An unset cap always admits.
782 pub fn admit(&mut self, quota: &FunctionQuota, now: u64) -> bool {
783 let Some(max) = quota.max_invocations else {
784 return true;
785 };
786 let window = quota.window();
787 if now.saturating_sub(self.window_start) >= window {
788 self.window_start = now;
789 self.window_count = 0;
790 }
791 if self.window_count >= max {
792 return false;
793 }
794 self.window_count += 1;
795 self.updated = now;
796 true
797 }
798}
799
800pub mod keys {
801 //! KV keyspace for a function (mirrors the deploy/alias immutability model), all
802 //! **project-scoped** under `project/<proj>/…` (0.2.0). `project` is passed as a
803 //! bare `&str` (this crate is wasm-clean and has no `ProjectRef`); callers in
804 //! `boatramp-core` pass `ProjectRef::as_str()`.
805
806 /// Function metadata.
807 pub fn meta(project: &str, name: &str) -> String {
808 format!("project/{project}/functions/{name}")
809 }
810 /// An immutable version.
811 pub fn version(project: &str, name: &str, id: &str) -> String {
812 format!("project/{project}/functions/{name}/versions/{id}")
813 }
814 /// A named alias → version id.
815 pub fn alias(project: &str, name: &str, label: &str) -> String {
816 format!("project/{project}/functions/{name}/alias/{label}")
817 }
818 /// A trigger bound to the function.
819 pub fn trigger(project: &str, name: &str, id: &str) -> String {
820 format!("project/{project}/functions/{name}/triggers/{id}")
821 }
822 /// A durable invocation record.
823 pub fn invocation(project: &str, name: &str, id: &str) -> String {
824 format!("project/{project}/functions/{name}/invocations/{id}")
825 }
826 /// The prefix under which all of a function's invocations live (queue scan).
827 pub fn invocations_prefix(project: &str, name: &str) -> String {
828 format!("project/{project}/functions/{name}/invocations/")
829 }
830 /// An idempotency key → invocation id pointer.
831 pub fn idempotency(project: &str, name: &str, key: &str) -> String {
832 format!("project/{project}/functions/{name}/idem/{key}")
833 }
834 /// The function's usage-metering aggregate (FA-4).
835 pub fn metering(project: &str, name: &str) -> String {
836 format!("project/{project}/metering/{name}")
837 }
838
839 /// The prefix listing every function record in a project (`meta` keys). A
840 /// function record's key has no further `/` after its name, so a lister filters
841 /// on that to skip the per-function sub-records.
842 pub fn functions_prefix(project: &str) -> String {
843 format!("project/{project}/functions/")
844 }
845 /// The prefix listing a function's triggers.
846 pub fn triggers_prefix(project: &str, name: &str) -> String {
847 format!("project/{project}/functions/{name}/triggers/")
848 }
849 /// The prefix listing every metering aggregate in a project.
850 pub fn metering_prefix(project: &str) -> String {
851 format!("project/{project}/metering/")
852 }
853}
854
855/// The site-scoped function name for an HTTP route handler — a slug of the route
856/// (`/api/hello` → `api-hello`, `/` → `root`).
857pub fn handler_name(route: &str) -> String {
858 let s = slug(route);
859 if s.is_empty() { "root".to_string() } else { s }
860}
861
862/// The site-scoped function name for a topic consumer (`orders` → `consumer-orders`).
863pub fn consumer_name(topic: &str) -> String {
864 format!("consumer-{}", slug(topic))
865}
866
867/// Lower-case alphanumeric slug, non-alnum runs collapsed to single `-`, trimmed.
868fn slug(s: &str) -> String {
869 let mut out = String::new();
870 let mut dash = false;
871 for c in s.chars() {
872 if c.is_ascii_alphanumeric() {
873 out.push(c.to_ascii_lowercase());
874 dash = false;
875 } else if !out.is_empty() && !dash {
876 out.push('-');
877 dash = true;
878 }
879 }
880 out.trim_matches('-').to_string()
881}
882
883/// Lower a site's deploy-scoped compute config into **functions + triggers**
884/// (decision 2), preserving behaviour exactly (the non-breaking gate):
885///
886/// - each `handler` → a `DeployPinned` [`FunctionSpec`] + a `Route` [`Trigger`];
887/// - each `consumer` → a [`FunctionSpec`] + a `Queue` trigger;
888/// - each `cron` → a `Cron` trigger onto the **function serving its route** (a
889/// second trigger on one function — N triggers → one function);
890/// - each `stream` → a host-native `Stream` trigger (`target: None`, no component).
891///
892/// Pure: no I/O, no hashing. The site handler surface (`routing.handlers`) is
893/// unchanged — this is the internal lowering that FA-1..FA-3 build on.
894pub fn desugar(cfg: &DeployConfig) -> (Vec<FunctionSpec>, Vec<Trigger>) {
895 let mut functions = Vec::new();
896 let mut triggers = Vec::new();
897
898 for h in &cfg.handlers {
899 let name = handler_name(&h.route);
900 functions.push(FunctionSpec {
901 name: name.clone(),
902 component: h.component.clone(),
903 config: FunctionConfig::from_handler(h),
904 lifecycle: Lifecycle::DeployPinned,
905 });
906 triggers.push(Trigger {
907 kind: TriggerKind::Route {
908 host: None,
909 path: h.route.clone(),
910 methods: h.methods.clone(),
911 },
912 target: Some(FunctionRef {
913 name,
914 version: None,
915 }),
916 });
917 }
918
919 for c in &cfg.consumers {
920 let name = consumer_name(&c.topic);
921 functions.push(FunctionSpec {
922 name: name.clone(),
923 component: c.component.clone(),
924 config: FunctionConfig::from_consumer(c),
925 lifecycle: Lifecycle::DeployPinned,
926 });
927 triggers.push(Trigger {
928 kind: TriggerKind::Queue {
929 topic: c.topic.clone(),
930 group: c.group.clone(),
931 start: c.start,
932 },
933 target: Some(FunctionRef {
934 name,
935 version: None,
936 }),
937 });
938 }
939
940 for cr in &cfg.crons {
941 // A cron fires an existing handler-function, addressed by its route.
942 let target = cfg
943 .handlers
944 .iter()
945 .find(|h| h.route == cr.route)
946 .map(|h| FunctionRef {
947 name: handler_name(&h.route),
948 version: None,
949 });
950 triggers.push(Trigger {
951 kind: TriggerKind::Cron {
952 schedule: cr.schedule.clone(),
953 overlap: cr.overlap,
954 },
955 target,
956 });
957 }
958
959 for s in &cfg.streams {
960 triggers.push(Trigger {
961 kind: TriggerKind::Stream {
962 topics: s.topics.clone(),
963 websocket: s.websocket,
964 publish_topic: s.publish_topic.clone(),
965 },
966 target: None,
967 });
968 }
969
970 (functions, triggers)
971}
972
973/// Materialize desugared specs into stored [`Function`]s for a site, resolving each
974/// component **path** to its blob hash via the deploy's file map (the blob hash is
975/// the content-addressed version id). `created` is the deploy's activation time.
976/// Specs whose component isn't in the file map are dropped (a validated deploy
977/// always has them). This is the derived, read-only view of a site's functions
978/// (FA-1); independently-stored top-level functions come with FA-2.
979pub fn materialize(
980 specs: &[FunctionSpec],
981 site: &str,
982 files: &BTreeMap<String, FileEntry>,
983 created: u64,
984) -> Vec<Function> {
985 specs
986 .iter()
987 .filter_map(|s| {
988 let hash = files.get(s.component.trim_start_matches('/'))?.hash.clone();
989 Some(Function {
990 name: s.name.clone(),
991 owner: Owner::Site(site.to_string()),
992 versions: vec![FunctionVersion {
993 id: hash.clone(),
994 component: hash.clone(),
995 created,
996 lifecycle: s.lifecycle,
997 }],
998 active: hash,
999 aliases: BTreeMap::new(),
1000 config: s.config.clone(),
1001 })
1002 })
1003 .collect()
1004}
1005
1006/// A read-only summary of a route/function's declared in-site tenancy, surfaced on
1007/// [`FunctionSummary`] so an operator can confirm the scope + the `on_unresolved` pass-vs-deny
1008/// decision WITHOUT black-box probing (the gap construens hit diagnosing an applied-but-seemingly-
1009/// ignored `on_unresolved: "pass"`). `None` on [`FunctionSummary::tenancy`] ⇒ the function declares
1010/// no tenancy of its own and inherits the site decision (read the site config to see that one).
1011#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1012pub struct TenancySummary {
1013 /// The tenancy class: `"scoped"` | `"target"` | `"disabled"`.
1014 pub mode: String,
1015 /// The tenant column (`scoped` only).
1016 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub column: Option<String>,
1018 /// Read access mode (`scoped`): `none` | `null` | `own` | `own_or_null` | `all`.
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1020 pub read: Option<String>,
1021 /// Write access mode (`scoped`).
1022 #[serde(default, skip_serializing_if = "Option::is_none")]
1023 pub write: Option<String>,
1024 /// The unresolved-request policy (`scoped`): `"deny"` (default) | `"pass"`.
1025 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub on_unresolved: Option<String>,
1027}
1028
1029impl TenancySummary {
1030 /// Summarize a declared [`Tenancy`](crate::tenancy::Tenancy) for the read-only function view.
1031 pub fn of(t: &crate::tenancy::Tenancy) -> Self {
1032 use crate::tenancy::{AccessMode, OnUnresolved, Tenancy};
1033 let access = |m: &AccessMode| {
1034 match m {
1035 AccessMode::None => "none",
1036 AccessMode::Null => "null",
1037 AccessMode::Own => "own",
1038 AccessMode::OwnOrNull => "own_or_null",
1039 AccessMode::All => "all",
1040 }
1041 .to_string()
1042 };
1043 match t {
1044 Tenancy::Disabled => Self {
1045 mode: "disabled".to_string(),
1046 column: None,
1047 read: None,
1048 write: None,
1049 on_unresolved: None,
1050 },
1051 Tenancy::Scoped {
1052 column,
1053 read,
1054 write,
1055 on_unresolved,
1056 ..
1057 } => Self {
1058 mode: "scoped".to_string(),
1059 column: Some(column.clone()),
1060 read: Some(access(read)),
1061 write: Some(access(write)),
1062 on_unresolved: Some(
1063 match on_unresolved {
1064 OnUnresolved::Pass => "pass",
1065 OnUnresolved::Deny => "deny",
1066 }
1067 .to_string(),
1068 ),
1069 },
1070 Tenancy::Target { .. } => Self {
1071 mode: "target".to_string(),
1072 column: None,
1073 read: Some("target".to_string()),
1074 write: None,
1075 on_unresolved: None,
1076 },
1077 }
1078 }
1079}
1080
1081/// One entry in the `GET /api/functions` view: a function plus its resolved
1082/// active version and the triggers that reach it. The read-only projection the
1083/// server returns and the CLI/console render.
1084#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1085pub struct FunctionSummary {
1086 /// Function name (`<site>/<name>` for site-scoped; bare for top-level).
1087 pub name: String,
1088 /// Owner (`site:<site>` or `project:<project>`).
1089 pub owner: String,
1090 /// Execution substrate.
1091 pub runtime: String,
1092 /// Active version id (the component blob hash).
1093 pub version: String,
1094 /// Rendered triggers that reach this function.
1095 pub triggers: Vec<String>,
1096 /// The function's declared in-site tenancy (scope + `on_unresolved`), so an operator can confirm
1097 /// pass-vs-deny without probing. `None` ⇒ no own tenancy (inherits the site). Elided when absent
1098 /// so a pre-existing consumer (and the console's field-subset mirror) is unaffected.
1099 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub tenancy: Option<TenancySummary>,
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105 use super::*;
1106 use crate::config::{ConsumerConfig, CronConfig, HandlerConfig, StreamConfig};
1107
1108 fn handler(route: &str, component: &str, methods: &[&str], imports: &[&str]) -> HandlerConfig {
1109 HandlerConfig {
1110 tenancy: None,
1111 token_claims: None,
1112 no_secrets: false,
1113 route: route.into(),
1114 methods: methods
1115 .iter()
1116 .map(std::string::ToString::to_string)
1117 .collect(),
1118 component: component.into(),
1119 imports: imports
1120 .iter()
1121 .map(std::string::ToString::to_string)
1122 .collect(),
1123 streaming: false,
1124 limits: None,
1125 env: BTreeMap::new(),
1126 secrets: Vec::new(),
1127 invoke_targets: Vec::new(),
1128 stats_topics: Vec::new(),
1129 tenant_secret_names: Vec::new(),
1130 upload_containers: Vec::new(),
1131 blobstore_containers: Vec::new(),
1132 egress_allowlist: Vec::new(),
1133 }
1134 }
1135
1136 #[test]
1137 fn slugs_and_names() {
1138 assert_eq!(handler_name("/api/hello"), "api-hello");
1139 assert_eq!(handler_name("/"), "root");
1140 assert_eq!(handler_name("/a/b/*"), "a-b");
1141 assert_eq!(consumer_name("orders.new"), "consumer-orders-new");
1142 }
1143
1144 /// The mandatory **non-breaking gate**: desugaring preserves every handler,
1145 /// consumer, cron, and stream's fields exactly — the same component, imports,
1146 /// routes/methods, topics, and the cron→handler binding.
1147 #[test]
1148 fn desugar_preserves_all_compute_config() {
1149 let cfg = DeployConfig {
1150 handlers: vec![
1151 handler("/api/hello", "hello.wasm", &["GET"], &["kv"]),
1152 handler("/api/report", "report.wasm", &[], &[]),
1153 ],
1154 consumers: vec![ConsumerConfig {
1155 max_concurrency: None,
1156 tenancy: None,
1157 token_claims: None,
1158 topic: "orders".into(),
1159 component: "orders.wasm".into(),
1160 imports: vec!["sql".into()],
1161 group: String::new(),
1162 start: Default::default(),
1163 secrets: Vec::new(),
1164 lease_ms: None,
1165 max_attempts: None,
1166 max_batch: None,
1167 max_ack_pending: None,
1168 backoff_ms: None,
1169 retention_ms: None,
1170 stats_topics: Vec::new(),
1171 tenant_secret_names: Vec::new(),
1172 upload_containers: Vec::new(),
1173 blobstore_containers: Vec::new(),
1174 egress_allowlist: Vec::new(),
1175 }],
1176 crons: vec![CronConfig {
1177 schedule: "0 * * * *".into(),
1178 route: "/api/report".into(),
1179 overlap: Overlap::Skip,
1180 run_as: crate::config::CronRunAs::Unauthenticated,
1181 }],
1182 streams: vec![StreamConfig {
1183 route: "/live".into(),
1184 topics: vec!["ticks".into()],
1185 websocket: false,
1186 publish_topic: None,
1187 }],
1188 ..Default::default()
1189 };
1190
1191 let (functions, triggers) = desugar(&cfg);
1192
1193 // Two handlers + one consumer → three functions.
1194 assert_eq!(functions.len(), 3);
1195 let hello = functions.iter().find(|f| f.name == "api-hello").unwrap();
1196 assert_eq!(hello.component, "hello.wasm");
1197 assert_eq!(hello.config.imports, vec!["kv".to_string()]);
1198 assert_eq!(hello.lifecycle, Lifecycle::DeployPinned);
1199 assert_eq!(hello.config.runtime, Runtime::Wasm);
1200 let consumer = functions
1201 .iter()
1202 .find(|f| f.name == "consumer-orders")
1203 .unwrap();
1204 assert_eq!(consumer.component, "orders.wasm");
1205 assert_eq!(consumer.config.imports, vec!["sql".to_string()]);
1206
1207 // Triggers: 2 routes + 1 queue + 1 cron + 1 stream = 5.
1208 assert_eq!(triggers.len(), 5);
1209
1210 // The route trigger carries the exact path + methods and targets its function.
1211 let route = triggers
1212 .iter()
1213 .find(|t| matches!(&t.kind, TriggerKind::Route { path, .. } if path == "/api/hello"))
1214 .unwrap();
1215 match &route.kind {
1216 TriggerKind::Route { methods, host, .. } => {
1217 assert_eq!(methods, &["GET".to_string()]);
1218 assert!(host.is_none());
1219 }
1220 _ => unreachable!(),
1221 }
1222 assert_eq!(route.target.as_ref().unwrap().name, "api-hello");
1223
1224 // The queue trigger.
1225 let queue = triggers
1226 .iter()
1227 .find(|t| matches!(&t.kind, TriggerKind::Queue { topic, .. } if topic == "orders"))
1228 .unwrap();
1229 assert_eq!(queue.target.as_ref().unwrap().name, "consumer-orders");
1230
1231 // The cron is a SECOND trigger on the /api/report function (N triggers → 1 fn).
1232 let cron = triggers
1233 .iter()
1234 .find(|t| matches!(&t.kind, TriggerKind::Cron { .. }))
1235 .unwrap();
1236 assert_eq!(cron.target.as_ref().unwrap().name, "api-report");
1237
1238 // The stream is host-native: a trigger with no function target.
1239 let stream = triggers
1240 .iter()
1241 .find(|t| matches!(&t.kind, TriggerKind::Stream { .. }))
1242 .unwrap();
1243 assert!(stream.target.is_none());
1244 match &stream.kind {
1245 TriggerKind::Stream { topics, .. } => assert_eq!(topics, &["ticks".to_string()]),
1246 _ => unreachable!(),
1247 }
1248 }
1249
1250 #[test]
1251 fn materialize_resolves_paths_to_blob_hashes() {
1252 let cfg = DeployConfig {
1253 handlers: vec![handler("/api/hello", "hello.wasm", &["GET"], &[])],
1254 ..Default::default()
1255 };
1256 let (specs, _) = desugar(&cfg);
1257 let files = BTreeMap::from([(
1258 "hello.wasm".to_string(),
1259 FileEntry {
1260 hash: "sha256:abc".into(),
1261 size: 10,
1262 content_type: None,
1263 variants: BTreeMap::new(),
1264 },
1265 )]);
1266 let funcs = materialize(&specs, "blog", &files, 1_800_000_000);
1267 assert_eq!(funcs.len(), 1);
1268 assert_eq!(funcs[0].name, "api-hello");
1269 assert_eq!(funcs[0].owner, Owner::Site("blog".into()));
1270 assert_eq!(funcs[0].active, "sha256:abc");
1271 assert_eq!(funcs[0].versions[0].component, "sha256:abc");
1272 assert_eq!(funcs[0].versions[0].created, 1_800_000_000);
1273 // A spec whose component blob is absent is dropped (no phantom function).
1274 assert!(materialize(&specs, "blog", &BTreeMap::new(), 0).is_empty());
1275 }
1276
1277 #[test]
1278 fn empty_config_desugars_to_nothing() {
1279 let (functions, triggers) = desugar(&DeployConfig::default());
1280 assert!(functions.is_empty() && triggers.is_empty());
1281 }
1282
1283 #[test]
1284 fn model_serde_round_trips() {
1285 let f = Function {
1286 name: "resize".into(),
1287 owner: Owner::Project("acme".into()),
1288 versions: vec![FunctionVersion {
1289 id: "v1abc".into(),
1290 component: "blob:deadbeef".into(),
1291 created: 1_800_000_000,
1292 lifecycle: Lifecycle::Independent,
1293 }],
1294 active: "v1abc".into(),
1295 aliases: BTreeMap::from([("prod".into(), "v1abc".into())]),
1296 config: FunctionConfig {
1297 imports: vec!["blobstore".into()],
1298 runtime: Runtime::Microvm,
1299 ..Default::default()
1300 },
1301 };
1302 let json = serde_json::to_string(&f).unwrap();
1303 assert_eq!(serde_json::from_str::<Function>(&json).unwrap(), f);
1304
1305 // A trigger with a data-carrying kind + host-native (no target) both round-trip.
1306 for t in [
1307 Trigger {
1308 kind: TriggerKind::Route {
1309 host: Some("example.com".into()),
1310 path: "/x".into(),
1311 methods: vec!["POST".into()],
1312 },
1313 target: Some(FunctionRef {
1314 name: "resize".into(),
1315 version: None,
1316 }),
1317 },
1318 Trigger {
1319 kind: TriggerKind::Stream {
1320 topics: vec!["t".into()],
1321 websocket: true,
1322 publish_topic: Some("up".into()),
1323 },
1324 target: None,
1325 },
1326 ] {
1327 let j = serde_json::to_string(&t).unwrap();
1328 assert_eq!(serde_json::from_str::<Trigger>(&j).unwrap(), t);
1329 }
1330 }
1331
1332 #[test]
1333 fn versioning_alias_and_rollback() {
1334 let mut f = Function::new(
1335 "resize",
1336 Owner::Project("acme".into()),
1337 "hashA",
1338 FunctionConfig::default(),
1339 Lifecycle::Independent,
1340 1,
1341 );
1342 assert_eq!(f.active, "hashA");
1343 assert_eq!(f.versions.len(), 1);
1344
1345 // A new component → a new active version.
1346 f.upsert_version("hashB", Lifecycle::Independent, 2);
1347 assert_eq!(f.active, "hashB");
1348 assert_eq!(f.versions.len(), 2);
1349 // Re-deploying the same hash is idempotent (re-activates, no dup version).
1350 f.upsert_version("hashA", Lifecycle::Independent, 3);
1351 assert_eq!(f.active, "hashA");
1352 assert_eq!(f.versions.len(), 2);
1353
1354 // Alias to a known version; unknown is rejected.
1355 f.set_alias("prod", "hashB").unwrap();
1356 assert_eq!(f.aliases.get("prod").map(String::as_str), Some("hashB"));
1357 assert!(f.set_alias("prod", "ghost").is_err());
1358
1359 // Rollback to a known version; unknown is rejected.
1360 f.rollback("hashB").unwrap();
1361 assert_eq!(f.active, "hashB");
1362 assert!(f.rollback("ghost").is_err());
1363
1364 // `resolve` maps a version id OR an alias label to the component hash;
1365 // an unknown reference resolves to nothing.
1366 assert_eq!(f.resolve("hashA"), Some("hashA"));
1367 assert_eq!(f.resolve("prod"), Some("hashB")); // alias → version → component
1368 assert_eq!(f.resolve("ghost"), None);
1369 }
1370
1371 #[test]
1372 fn invocation_model_round_trips_and_reports_terminal() {
1373 let inv = Invocation {
1374 id: "inv-1".into(),
1375 function: "greeter".into(),
1376 version: "hashA".into(),
1377 mode: InvokeMode::Async,
1378 status: InvocationStatus::Queued,
1379 idempotency_key: Some("k".into()),
1380 attempts: 0,
1381 lease_expires: None,
1382 request_b64: Some("aGk=".into()),
1383 request_content_type: Some("text/plain".into()),
1384 result: None,
1385 created: 1,
1386 updated: 1,
1387 };
1388 assert!(!inv.is_terminal());
1389 let json = serde_json::to_string(&inv).unwrap();
1390 let back: Invocation = serde_json::from_str(&json).unwrap();
1391 assert_eq!(back, inv);
1392 // Enum wire forms are snake_case + stable.
1393 assert!(json.contains("\"mode\":\"async\""));
1394 assert!(json.contains("\"status\":\"queued\""));
1395
1396 let done = Invocation {
1397 status: InvocationStatus::Succeeded,
1398 result: Some(InvocationResult {
1399 status: 200,
1400 content_type: None,
1401 body_b64: "b2s=".into(),
1402 }),
1403 ..inv
1404 };
1405 assert!(done.is_terminal());
1406 }
1407
1408 #[test]
1409 fn metering_records_and_rate_limits() {
1410 let mut m = Metering::new("greeter");
1411 m.record(
1412 &MeteringSample {
1413 success: true,
1414 duration_ms: 5,
1415 bytes_in: 3,
1416 bytes_out: 7,
1417 },
1418 100,
1419 );
1420 m.record(
1421 &MeteringSample {
1422 success: false,
1423 duration_ms: 2,
1424 bytes_in: 0,
1425 bytes_out: 0,
1426 },
1427 101,
1428 );
1429 assert_eq!(m.invocations, 2);
1430 assert_eq!(m.successes, 1);
1431 assert_eq!(m.failures, 1);
1432 assert_eq!(m.duration_ms_total, 7);
1433 assert_eq!(m.bytes_out_total, 7);
1434 assert_eq!(m.updated, 101);
1435
1436 // Fixed-window rate limit: 2 per 10s window.
1437 let quota = FunctionQuota {
1438 max_invocations: Some(2),
1439 window_secs: Some(10),
1440 max_concurrent: None,
1441 };
1442 let mut r = Metering::new("greeter");
1443 assert!(r.admit("a, 1000)); // 1st in window
1444 assert!(r.admit("a, 1001)); // 2nd
1445 assert!(!r.admit("a, 1002)); // 3rd → rejected
1446 assert_eq!(r.window_count, 2);
1447 // The window rolls over after `window_secs`, resetting the counter.
1448 assert!(r.admit("a, 1011));
1449 assert_eq!(r.window_count, 1);
1450
1451 // An unset cap always admits and never touches the counter.
1452 let unset = FunctionQuota::default();
1453 let mut u = Metering::new("greeter");
1454 assert!(u.admit(&unset, 1));
1455 assert_eq!(u.window_count, 0);
1456 assert!(unset.is_unset());
1457 }
1458
1459 #[test]
1460 fn webhook_config_defaults_and_round_trips() {
1461 // Defaults: header + body cap.
1462 let w = WebhookConfig {
1463 secret_env: "HOOK_SECRET".into(),
1464 algorithm: WebhookAlgorithm::HmacSha256,
1465 signature_header: None,
1466 max_body_bytes: None,
1467 publish: None,
1468 };
1469 assert_eq!(w.header(), "x-boatramp-signature");
1470 assert_eq!(w.body_cap(), 1024 * 1024);
1471
1472 // A config carrying a webhook round-trips and the secret is an env *ref*.
1473 let cfg = FunctionConfig {
1474 webhook: Some(w),
1475 ..Default::default()
1476 };
1477 let json = serde_json::to_string(&cfg).unwrap();
1478 assert!(json.contains("\"secret_env\":\"HOOK_SECRET\""));
1479 assert!(json.contains("\"hmac_sha256\""));
1480 let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1481 assert_eq!(back, cfg);
1482
1483 // Custom header + cap are honoured.
1484 let custom = WebhookConfig {
1485 secret_env: "S".into(),
1486 algorithm: WebhookAlgorithm::HmacSha256,
1487 signature_header: Some("x-hub-signature-256".into()),
1488 max_body_bytes: Some(4096),
1489 publish: None,
1490 };
1491 assert_eq!(custom.header(), "x-hub-signature-256");
1492 assert_eq!(custom.body_cap(), 4096);
1493 }
1494
1495 #[test]
1496 fn secret_refs_round_trip_as_references() {
1497 // A `secrets` map is a name→host-env-var *reference*: it round-trips in the
1498 // config and carries no literal secret value (the value is resolved from the
1499 // serve env at instantiation, never baked into the stored config/manifest).
1500 let cfg = FunctionConfig {
1501 env: BTreeMap::from([("STAGE".into(), "prod".into())]),
1502 secrets: BTreeMap::from([("DB_URL".into(), "PROD_DB_URL".into())]),
1503 ..Default::default()
1504 };
1505 let json = serde_json::to_string(&cfg).unwrap();
1506 // The map serializes the *reference* (the host env var name), not a value.
1507 assert!(json.contains("\"secrets\":{\"DB_URL\":\"PROD_DB_URL\"}"));
1508 let back: FunctionConfig = serde_json::from_str(&json).unwrap();
1509 assert_eq!(back, cfg);
1510 assert_eq!(
1511 back.secrets.get("DB_URL").map(String::as_str),
1512 Some("PROD_DB_URL")
1513 );
1514
1515 // An empty `secrets` map is elided (same serde shape as the handler's).
1516 let bare = FunctionConfig::default();
1517 assert!(!serde_json::to_string(&bare).unwrap().contains("secrets"));
1518 }
1519
1520 #[test]
1521 fn keyspace_is_stable() {
1522 assert_eq!(
1523 keys::meta("default", "resize"),
1524 "project/default/functions/resize"
1525 );
1526 assert_eq!(
1527 keys::version("default", "resize", "v1"),
1528 "project/default/functions/resize/versions/v1"
1529 );
1530 assert_eq!(
1531 keys::alias("default", "resize", "prod"),
1532 "project/default/functions/resize/alias/prod"
1533 );
1534 assert_eq!(
1535 keys::trigger("default", "resize", "t1"),
1536 "project/default/functions/resize/triggers/t1"
1537 );
1538 assert_eq!(
1539 keys::invocation("default", "resize", "inv-1"),
1540 "project/default/functions/resize/invocations/inv-1"
1541 );
1542 assert_eq!(
1543 keys::invocations_prefix("default", "resize"),
1544 "project/default/functions/resize/invocations/"
1545 );
1546 assert_eq!(
1547 keys::idempotency("default", "resize", "k-1"),
1548 "project/default/functions/resize/idem/k-1"
1549 );
1550 assert_eq!(
1551 keys::metering("default", "resize"),
1552 "project/default/metering/resize"
1553 );
1554 // A project other than the default keys under its own segment.
1555 assert_eq!(
1556 keys::meta("acme", "resize"),
1557 "project/acme/functions/resize"
1558 );
1559 }
1560}