Skip to main content

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(&quota, 1000)); // 1st in window
1444        assert!(r.admit(&quota, 1001)); // 2nd
1445        assert!(!r.admit(&quota, 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(&quota, 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}