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