Skip to main content

boatramp_types/
compute.rs

1//! Compute workloads: legacy apps run as Firecracker microVMs.
2//!
3//! This is the wasm-clean **artifact model** — the content-addressed, immutable
4//! [`ComputeSpec`] (rootfs/kernel/spec, exactly like a site deployment) and the
5//! mutable [`ComputeWorkload`] desired state (active version + replicas +
6//! placement). The executor that actually boots a microVM from a spec
7//! (`boatramp-firecracker`, KVM-only) and the scheduler that places it are
8//! native-only and live elsewhere; this module is just the shared types + their
9//! content-addressing, so the CLI, control plane, and executor agree.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::manifest::sha256_hex;
16
17/// KV key prefix for immutable, content-addressed compute specs. Stays **global**
18/// (a content hash is a self-authenticating capability; specs dedup across projects).
19pub const SPEC_PREFIX: &str = "computever/";
20
21/// The mutable desired-state key for a workload, **project-scoped** (0.2.0):
22/// `project/<proj>/compute/<name>`. `project` is a bare `&str` (this crate is
23/// wasm-clean); `boatramp-core` callers pass `ProjectRef::as_str()`.
24pub fn workload_key(project: &str, name: &str) -> String {
25    format!("project/{project}/compute/{name}")
26}
27
28/// The prefix listing every workload's desired state in a project.
29pub fn workloads_prefix(project: &str) -> String {
30    format!("project/{project}/compute/")
31}
32
33/// The immutable spec key for a content hash (global CAS).
34pub fn spec_key(hash: &str) -> String {
35    format!("{SPEC_PREFIX}{hash}")
36}
37
38/// What to do when a workload's guest process exits.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum RestartPolicy {
42    /// Never restart (run-to-completion / job).
43    Never,
44    /// Restart only on a non-zero exit.
45    OnFailure,
46    /// Always keep it running (the default for a service).
47    #[default]
48    Always,
49}
50
51/// The isolation a workload **requires** — the floor the operator's site policy
52/// and the available backends are matched against.
53/// This is the workload's stated need, distinct from the isolation *class* a
54/// backend provides.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum IsolationRequirement {
58    /// Shared-kernel isolation is acceptable (a namespace/container is fine).
59    /// The default — strong isolation is opt-in.
60    #[default]
61    Trusted,
62    /// Strong isolation is required: only a microVM (KVM) or a managed platform
63    /// may run this workload, never a shared-kernel container.
64    Untrusted,
65}
66
67impl IsolationRequirement {
68    /// Whether this is the default (`Trusted`) — used to omit it from the
69    /// serialized spec so existing specs keep their content hash.
70    pub fn is_trusted(&self) -> bool {
71        matches!(self, Self::Trusted)
72    }
73}
74
75/// The source of a workload's **root filesystem**. Each variant is one concrete
76/// artifact form, matched 1:1 to the backends that accept it — modelled explicitly
77/// rather than overloading one string, because an image reference, a tar archive, and
78/// a rootfs block image are genuinely different things and a mismatch should be a
79/// typed error.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum RootSource {
83    /// An **OCI image reference** (`repo:tag` or a digest) a runtime **pulls** from a
84    /// registry; its unpacked layers become the root filesystem. Backends: `docker`,
85    /// `cloudflare`. [`ComputeSpec::kernel`] does not apply.
86    Image(String),
87    /// A **tar rootfs archive** — a blob hash in the shared store — that the node
88    /// **stages and unpacks** into a directory to run. Backend: the native `container`
89    /// runtime. [`ComputeSpec::kernel`] does not apply.
90    Tar(String),
91    /// A **rootfs filesystem image** — a blob hash in the shared store — that the node
92    /// **stages and attaches** as the guest's root **block device**. The filesystem is
93    /// opaque to boatramp: the guest kernel mounts whatever it finds (`ext4` by
94    /// default, since `compute build` uses `mke2fs`, but any kernel-supported
95    /// filesystem works). Backend: the `firecracker` micro-VM, which pairs it with
96    /// [`ComputeSpec::kernel`].
97    Rootfs(String),
98}
99
100impl RootSource {
101    /// The underlying reference string (an image reference for [`RootSource::Image`],
102    /// a blob hash for [`RootSource::Tar`] / [`RootSource::Rootfs`]).
103    pub fn as_str(&self) -> &str {
104        match self {
105            Self::Image(s) | Self::Tar(s) | Self::Rootfs(s) => s,
106        }
107    }
108}
109
110/// A persistent volume attached to the guest (a host block image, snapshotted
111/// to blob storage for durability). Opt-in; the default rootfs is read-only with
112/// an ephemeral scratch drive.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct VolumeRef {
116    /// In-guest mount point.
117    pub mount: String,
118    /// Volume name (the host tracks its backing image).
119    pub name: String,
120    /// Size in MiB (used when first provisioning).
121    pub size_mib: u32,
122}
123
124/// The generic default startup grace (seconds) for a compute workload — the window a
125/// freshly launched replica has to become healthy before the reconcile loop treats a
126/// still-unhealthy one as a broken launch. Generic compute + micro-VM workloads use
127/// this; the managed-database synthesizer overrides it per engine (slower `initdb`).
128pub fn default_startup_grace_secs() -> u32 {
129    30
130}
131
132/// An immutable, content-addressed compute workload version (the analogue of a
133/// deployment manifest). Stored at `computever/<hash>`; the rootfs + kernel are
134/// blob hashes in the shared blob store (deduped, cached forever).
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct ComputeSpec {
138    /// Pinned schema discriminant (`v1`).
139    #[serde(default = "crate::schema_version")]
140    pub version: u32,
141    /// The source of the workload's root filesystem: an OCI image reference, a tar
142    /// rootfs archive, or a rootfs filesystem image, per the target substrate (see
143    /// [`RootSource`]).
144    pub root: RootSource,
145    /// Blob hash of the `vmlinux` kernel (shared across workloads). Applies only to
146    /// the micro-VM substrate (a [`RootSource::Rootfs`] source); ignored otherwise, so
147    /// an image/tar workload omits it (empty ⇒ absent from the wire + the content hash).
148    #[serde(default, skip_serializing_if = "String::is_empty")]
149    pub kernel: String,
150    /// Kernel boot cmdline override; `None` uses the executor default.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub kernel_cmdline: Option<String>,
153    /// Virtual CPUs.
154    pub vcpus: u32,
155    /// Guest memory in MiB.
156    pub mem_mib: u32,
157    /// The in-guest entrypoint (argv) the init execs.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub entrypoint: Vec<String>,
160    /// Environment variables for the entrypoint.
161    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
162    pub env: BTreeMap<String, String>,
163    /// The TCP port the app listens on inside the guest (the gateway targets it).
164    pub port: u16,
165    /// Restart policy for the guest process.
166    #[serde(default)]
167    pub restart: RestartPolicy,
168    /// Seconds a freshly launched replica is given to become healthy before the
169    /// reconcile loop treats a `Running`-but-unhealthy replica as a broken launch to
170    /// stop + relaunch. Within this grace the replica is "starting" (left alone), so a
171    /// slow-initializing image — a stock database's first `initdb` — is not killed
172    /// mid-init into a crash loop. Generic default is 30s (see [`default_startup_grace_secs`]);
173    /// the managed-database synthesizer raises it per engine. `#[serde(default …)]` keeps
174    /// older stored specs (no field) deserializing with the default — schema stays v1.
175    #[serde(default = "default_startup_grace_secs")]
176    pub startup_grace_secs: u32,
177    /// Snapshot + stop when idle; restore on the next request (cold start).
178    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
179    pub scale_to_zero: bool,
180    /// Persistent volumes (opt-in).
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub volumes: Vec<VolumeRef>,
183    /// Allow a writable root filesystem instead of the hardened read-only-root
184    /// default. Opt-in and honored **only under the single-tenant isolation
185    /// posture** (a backend forces read-only root under the multi-tenant guard).
186    /// The idiomatic path for app writes remains a [`VolumeRef`]; this is for images
187    /// that write outside a declared volume. Default off; omitted from the wire + the
188    /// content hash when false (back-compat).
189    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
190    pub writable_root: bool,
191    /// Linux capabilities to add back on top of the dropped-`ALL` default of the
192    /// shared-kernel backends (docker / native container), so an image whose
193    /// entrypoint needs a specific capability (e.g. a stock database that `chown`s its
194    /// data dir and `gosu`-drops to its user) can init. Names are the short form
195    /// without the `CAP_` prefix (`"CHOWN"`, `"SETUID"`, …). Honored **only under the
196    /// single-tenant isolation posture** — the multi-tenant guard strips it, exactly
197    /// like [`writable_root`](Self::writable_root). Empty ⇒ omitted from the wire + the
198    /// content hash (back-compat).
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    pub cap_add: Vec<String>,
201    /// Run the entrypoint as this user instead of the backend default. `"uid"` or
202    /// `"uid:gid"` (numeric). On the shared-kernel backends this lets a stock image
203    /// run rootless against a pre-owned volume — the entrypoint skips the `chown` +
204    /// privilege-drop that would otherwise need capabilities, so it needs none. A
205    /// hardening (not a relaxation), so it is honored under any posture. `None` ⇒ the
206    /// backend default; omitted from the wire + the content hash (back-compat).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub user: Option<String>,
209    /// Isolation the workload requires; selects which backends are eligible.
210    /// Default `Trusted`; omitted from the serialized
211    /// spec when default, so existing specs keep their content hash.
212    #[serde(default, skip_serializing_if = "IsolationRequirement::is_trusted")]
213    pub isolation: IsolationRequirement,
214    /// Optional preferred backend id (`vmm`/`container`/`cloudflare`/`docker`);
215    /// the scheduler honors it when the backend is eligible, else falls back.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub prefer_backend: Option<String>,
218    /// Managed resources this workload depends on — the opaque-process analogue of a
219    /// handler's `imports`. boatramp resolves each to a **tenant-scoped** endpoint +
220    /// credential at launch and injects the address into the guest env, so a workload
221    /// reaches the managed `sql` (and, later, kv/blob/messaging) without a hand-glued
222    /// URL. Empty ⇒ omitted from the wire + the content hash (back-compat).
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub bindings: Vec<ComputeBinding>,
225}
226
227/// A managed-resource dependency a compute workload declares. It names a *resource*,
228/// never a project: the owning project comes from the workload's key
229/// (`project/<proj>/compute/<name>`), so a workload cannot request another tenant's
230/// data. boatramp resolves it at launch and injects the endpoint into the guest env.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct ComputeBinding {
234    /// Which managed resource kind.
235    pub kind: BindingKind,
236    /// The named database/store within the kind (`""` = the site default, matching
237    /// `sql.open("")`).
238    #[serde(default, skip_serializing_if = "String::is_empty")]
239    pub name: String,
240    /// The env var the resolved endpoint URL is injected as; `None` ⇒ the kind default
241    /// (`sql` → `BOATRAMP_SQL_URL`). The credential is injected as `<url_env>_AUTH_TOKEN`.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub url_env: Option<String>,
244}
245
246/// The managed-resource kinds a [`ComputeBinding`] may name. Phase 0 implements `Sql`;
247/// the others are reserved for the shared resolver mechanism.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "lowercase")]
250pub enum BindingKind {
251    /// The managed `sql` database (per-site libsql, or a named external DB).
252    Sql,
253    /// Per-site key-value store (Phase 2).
254    Kv,
255    /// Per-site blob store (Phase 2).
256    Blob,
257    /// Per-site pub/sub + queues (Phase 2).
258    Messaging,
259}
260
261impl ComputeBinding {
262    /// The env var the endpoint URL is injected as (explicit `url_env`, else the kind
263    /// default). The auth token is injected as this name plus `_AUTH_TOKEN`.
264    pub fn url_env(&self) -> String {
265        self.url_env
266            .clone()
267            .unwrap_or_else(|| self.kind.default_url_env().to_string())
268    }
269}
270
271impl BindingKind {
272    /// The default env var an endpoint of this kind is injected as.
273    pub fn default_url_env(self) -> &'static str {
274        match self {
275            Self::Sql => "BOATRAMP_SQL_URL",
276            Self::Kv => "BOATRAMP_KV_URL",
277            Self::Blob => "BOATRAMP_BLOB_URL",
278            Self::Messaging => "BOATRAMP_MESSAGING_URL",
279        }
280    }
281}
282
283impl ComputeSpec {
284    /// The content hash of this spec — its `computever/<hash>` id. Computed over
285    /// the canonical JSON so identical specs dedupe (like a deployment id).
286    pub fn id(&self) -> String {
287        let canonical = serde_json::to_vec(self).expect("ComputeSpec serializes");
288        sha256_hex(&canonical)
289    }
290}
291
292/// Placement constraints: where a workload's replicas may run.
293#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(default, deny_unknown_fields)]
295pub struct PlacementConstraints {
296    /// If non-empty, only nodes in one of these regions are eligible.
297    pub regions: Vec<String>,
298    /// Required node labels (all must match a node's advertised labels).
299    pub labels: BTreeMap<String, String>,
300}
301
302impl PlacementConstraints {
303    /// Whether a node with `node_region` + `node_labels` satisfies these
304    /// constraints.
305    pub fn allows(
306        &self,
307        node_region: Option<&str>,
308        node_labels: &BTreeMap<String, String>,
309    ) -> bool {
310        if !self.regions.is_empty() {
311            match node_region {
312                Some(r) if self.regions.iter().any(|want| want == r) => {}
313                _ => return false,
314            }
315        }
316        self.labels
317            .iter()
318            .all(|(k, v)| node_labels.get(k).is_some_and(|nv| nv == v))
319    }
320}
321
322/// The mutable desired state for a workload (`compute/<name>`): the active spec
323/// version, replica count, and placement. Activation is a pointer flip to a new
324/// spec hash — the same atomic, roll-back-able model as a site deployment.
325#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326#[serde(deny_unknown_fields)]
327pub struct ComputeWorkload {
328    /// Pinned schema discriminant (`v1`).
329    #[serde(default = "crate::schema_version")]
330    pub version: u32,
331    /// Human label (the workload name is the KV key).
332    pub name: String,
333    /// The active [`ComputeSpec`] content hash (`computever/<hash>`).
334    pub active: String,
335    /// Desired replica count.
336    pub replicas: u32,
337    /// Placement constraints.
338    #[serde(default)]
339    pub placement: PlacementConstraints,
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn spec() -> ComputeSpec {
347        ComputeSpec {
348            version: crate::SCHEMA_VERSION,
349            root: RootSource::Rootfs("a".repeat(64)),
350            kernel: "b".repeat(64),
351            kernel_cmdline: None,
352            vcpus: 2,
353            mem_mib: 512,
354            entrypoint: vec!["/app".into(), "--serve".into()],
355            env: BTreeMap::from([("PORT".to_string(), "8080".to_string())]),
356            port: 8080,
357            restart: RestartPolicy::Always,
358            startup_grace_secs: 30,
359            scale_to_zero: true,
360            volumes: vec![],
361            writable_root: false,
362            cap_add: Vec::new(),
363            user: None,
364            isolation: IsolationRequirement::Trusted,
365            prefer_backend: None,
366            bindings: vec![],
367        }
368    }
369
370    #[test]
371    fn empty_bindings_do_not_change_the_spec_hash() {
372        // A spec that declares no bindings serializes without the field, so it hashes
373        // identically to a pre-bindings spec (back-compat).
374        let a = spec();
375        let json = serde_json::to_string(&a).unwrap();
376        assert!(!json.contains("bindings"), "empty bindings are omitted");
377
378        // Declaring a binding is recorded and changes the content hash.
379        let mut b = spec();
380        b.bindings = vec![ComputeBinding {
381            kind: BindingKind::Sql,
382            name: String::new(),
383            url_env: None,
384        }];
385        assert_ne!(a.id(), b.id(), "a declared binding changes the id");
386        assert!(serde_json::to_string(&b).unwrap().contains("bindings"));
387    }
388
389    #[test]
390    fn binding_kind_parses_lowercase_and_url_env_defaults_per_kind() {
391        assert_eq!(
392            serde_json::from_str::<BindingKind>("\"sql\"").unwrap(),
393            BindingKind::Sql
394        );
395        let sql = ComputeBinding {
396            kind: BindingKind::Sql,
397            name: String::new(),
398            url_env: None,
399        };
400        assert_eq!(sql.url_env(), "BOATRAMP_SQL_URL");
401        let custom = ComputeBinding {
402            kind: BindingKind::Sql,
403            name: "analytics".into(),
404            url_env: Some("ANALYTICS_URL".into()),
405        };
406        assert_eq!(custom.url_env(), "ANALYTICS_URL");
407    }
408
409    #[test]
410    fn spec_id_is_stable_and_content_addressed() {
411        let a = spec();
412        let mut b = spec();
413        assert_eq!(a.id(), b.id(), "identical specs share an id");
414        b.vcpus = 4;
415        assert_ne!(a.id(), b.id(), "a changed field changes the id");
416        assert_eq!(a.id().len(), 64);
417    }
418
419    #[test]
420    fn default_isolation_does_not_change_the_spec_hash() {
421        // `Trusted` (default) is omitted from the JSON, so a spec that doesn't
422        // touch isolation hashes identically to one explicitly set to Trusted.
423        let mut a = spec();
424        a.isolation = IsolationRequirement::Trusted;
425        let json = serde_json::to_string(&a).unwrap();
426        assert!(!json.contains("isolation"), "default isolation is omitted");
427        // Untrusted is recorded and changes the hash.
428        let mut b = spec();
429        b.isolation = IsolationRequirement::Untrusted;
430        assert_ne!(a.id(), b.id());
431        assert!(serde_json::to_string(&b).unwrap().contains("untrusted"));
432    }
433
434    #[test]
435    fn spec_round_trips_through_json() {
436        let a = spec();
437        let json = serde_json::to_string(&a).unwrap();
438        assert_eq!(serde_json::from_str::<ComputeSpec>(&json).unwrap(), a);
439    }
440
441    #[test]
442    fn old_spec_without_startup_grace_deserializes_with_the_default() {
443        // An additive, `#[serde(default)]` field keeps schema at v1: a spec stored
444        // before `startup_grace_secs` existed (no such key) still deserializes, taking
445        // the generic default (30). `deny_unknown_fields` is on, so this proves the
446        // field is genuinely optional on read.
447        let old = r#"{
448            "version": 1,
449            "root": { "rootfs": "aaaa" },
450            "vcpus": 1,
451            "mem_mib": 256,
452            "port": 8080
453        }"#;
454        let spec: ComputeSpec = serde_json::from_str(old).expect("old spec deserializes");
455        assert_eq!(spec.version, 1, "schema stays v1");
456        assert_eq!(spec.startup_grace_secs, default_startup_grace_secs());
457        assert_eq!(spec.startup_grace_secs, 30);
458    }
459
460    #[test]
461    fn keyspace_helpers() {
462        assert_eq!(
463            workload_key("default", "api"),
464            "project/default/compute/api"
465        );
466        assert_eq!(workload_key("acme", "api"), "project/acme/compute/api");
467        assert_eq!(workloads_prefix("default"), "project/default/compute/");
468        // The spec body is content-addressed and stays global (dedup across projects).
469        assert_eq!(spec_key("deadbeef"), "computever/deadbeef");
470    }
471
472    #[test]
473    fn placement_matches_region_and_labels() {
474        let c = PlacementConstraints {
475            regions: vec!["eu".into()],
476            labels: BTreeMap::from([("gpu".to_string(), "yes".to_string())]),
477        };
478        let labels = BTreeMap::from([("gpu".to_string(), "yes".to_string())]);
479        assert!(c.allows(Some("eu"), &labels));
480        assert!(!c.allows(Some("us"), &labels), "wrong region");
481        assert!(!c.allows(Some("eu"), &BTreeMap::new()), "missing label");
482        // No constraints → any node.
483        assert!(PlacementConstraints::default().allows(None, &BTreeMap::new()));
484    }
485}