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/// An immutable, content-addressed compute workload version (the analogue of a
125/// deployment manifest). Stored at `computever/<hash>`; the rootfs + kernel are
126/// blob hashes in the shared blob store (deduped, cached forever).
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129pub struct ComputeSpec {
130 /// Pinned schema discriminant (`v1`).
131 #[serde(default = "crate::schema_version")]
132 pub version: u32,
133 /// The source of the workload's root filesystem: an OCI image reference, a tar
134 /// rootfs archive, or a rootfs filesystem image, per the target substrate (see
135 /// [`RootSource`]).
136 pub root: RootSource,
137 /// Blob hash of the `vmlinux` kernel (shared across workloads). Applies only to
138 /// the micro-VM substrate (a [`RootSource::Rootfs`] source); ignored otherwise, so
139 /// an image/tar workload omits it (empty ⇒ absent from the wire + the content hash).
140 #[serde(default, skip_serializing_if = "String::is_empty")]
141 pub kernel: String,
142 /// Kernel boot cmdline override; `None` uses the executor default.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub kernel_cmdline: Option<String>,
145 /// Virtual CPUs.
146 pub vcpus: u32,
147 /// Guest memory in MiB.
148 pub mem_mib: u32,
149 /// The in-guest entrypoint (argv) the init execs.
150 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub entrypoint: Vec<String>,
152 /// Environment variables for the entrypoint.
153 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
154 pub env: BTreeMap<String, String>,
155 /// The TCP port the app listens on inside the guest (the gateway targets it).
156 pub port: u16,
157 /// Restart policy for the guest process.
158 #[serde(default)]
159 pub restart: RestartPolicy,
160 /// Snapshot + stop when idle; restore on the next request (cold start).
161 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
162 pub scale_to_zero: bool,
163 /// Persistent volumes (opt-in).
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub volumes: Vec<VolumeRef>,
166 /// Allow a writable root filesystem instead of the hardened read-only-root
167 /// default. Opt-in and honored **only under the single-tenant isolation
168 /// posture** (a backend forces read-only root under the multi-tenant guard).
169 /// The idiomatic path for app writes remains a [`VolumeRef`]; this is for images
170 /// that write outside a declared volume. Default off; omitted from the wire + the
171 /// content hash when false (back-compat).
172 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
173 pub writable_root: bool,
174 /// Linux capabilities to add back on top of the dropped-`ALL` default of the
175 /// shared-kernel backends (docker / native container), so an image whose
176 /// entrypoint needs a specific capability (e.g. a stock database that `chown`s its
177 /// data dir and `gosu`-drops to its user) can init. Names are the short form
178 /// without the `CAP_` prefix (`"CHOWN"`, `"SETUID"`, …). Honored **only under the
179 /// single-tenant isolation posture** — the multi-tenant guard strips it, exactly
180 /// like [`writable_root`](Self::writable_root). Empty ⇒ omitted from the wire + the
181 /// content hash (back-compat).
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub cap_add: Vec<String>,
184 /// Run the entrypoint as this user instead of the backend default. `"uid"` or
185 /// `"uid:gid"` (numeric). On the shared-kernel backends this lets a stock image
186 /// run rootless against a pre-owned volume — the entrypoint skips the `chown` +
187 /// privilege-drop that would otherwise need capabilities, so it needs none. A
188 /// hardening (not a relaxation), so it is honored under any posture. `None` ⇒ the
189 /// backend default; omitted from the wire + the content hash (back-compat).
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub user: Option<String>,
192 /// Isolation the workload requires; selects which backends are eligible.
193 /// Default `Trusted`; omitted from the serialized
194 /// spec when default, so existing specs keep their content hash.
195 #[serde(default, skip_serializing_if = "IsolationRequirement::is_trusted")]
196 pub isolation: IsolationRequirement,
197 /// Optional preferred backend id (`vmm`/`container`/`cloudflare`/`docker`);
198 /// the scheduler honors it when the backend is eligible, else falls back.
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub prefer_backend: Option<String>,
201 /// Managed resources this workload depends on — the opaque-process analogue of a
202 /// handler's `imports`. boatramp resolves each to a **tenant-scoped** endpoint +
203 /// credential at launch and injects the address into the guest env, so a workload
204 /// reaches the managed `sql` (and, later, kv/blob/messaging) without a hand-glued
205 /// URL. Empty ⇒ omitted from the wire + the content hash (back-compat).
206 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub bindings: Vec<ComputeBinding>,
208}
209
210/// A managed-resource dependency a compute workload declares. It names a *resource*,
211/// never a project: the owning project comes from the workload's key
212/// (`project/<proj>/compute/<name>`), so a workload cannot request another tenant's
213/// data. boatramp resolves it at launch and injects the endpoint into the guest env.
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct ComputeBinding {
217 /// Which managed resource kind.
218 pub kind: BindingKind,
219 /// The named database/store within the kind (`""` = the site default, matching
220 /// `sql.open("")`).
221 #[serde(default, skip_serializing_if = "String::is_empty")]
222 pub name: String,
223 /// The env var the resolved endpoint URL is injected as; `None` ⇒ the kind default
224 /// (`sql` → `BOATRAMP_SQL_URL`). The credential is injected as `<url_env>_AUTH_TOKEN`.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub url_env: Option<String>,
227}
228
229/// The managed-resource kinds a [`ComputeBinding`] may name. Phase 0 implements `Sql`;
230/// the others are reserved for the shared resolver mechanism.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "lowercase")]
233pub enum BindingKind {
234 /// The managed `sql` database (per-site libsql, or a named external DB).
235 Sql,
236 /// Per-site key-value store (Phase 2).
237 Kv,
238 /// Per-site blob store (Phase 2).
239 Blob,
240 /// Per-site pub/sub + queues (Phase 2).
241 Messaging,
242}
243
244impl ComputeBinding {
245 /// The env var the endpoint URL is injected as (explicit `url_env`, else the kind
246 /// default). The auth token is injected as this name plus `_AUTH_TOKEN`.
247 pub fn url_env(&self) -> String {
248 self.url_env
249 .clone()
250 .unwrap_or_else(|| self.kind.default_url_env().to_string())
251 }
252}
253
254impl BindingKind {
255 /// The default env var an endpoint of this kind is injected as.
256 pub fn default_url_env(self) -> &'static str {
257 match self {
258 Self::Sql => "BOATRAMP_SQL_URL",
259 Self::Kv => "BOATRAMP_KV_URL",
260 Self::Blob => "BOATRAMP_BLOB_URL",
261 Self::Messaging => "BOATRAMP_MESSAGING_URL",
262 }
263 }
264}
265
266impl ComputeSpec {
267 /// The content hash of this spec — its `computever/<hash>` id. Computed over
268 /// the canonical JSON so identical specs dedupe (like a deployment id).
269 pub fn id(&self) -> String {
270 let canonical = serde_json::to_vec(self).expect("ComputeSpec serializes");
271 sha256_hex(&canonical)
272 }
273}
274
275/// Placement constraints: where a workload's replicas may run.
276#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(default, deny_unknown_fields)]
278pub struct PlacementConstraints {
279 /// If non-empty, only nodes in one of these regions are eligible.
280 pub regions: Vec<String>,
281 /// Required node labels (all must match a node's advertised labels).
282 pub labels: BTreeMap<String, String>,
283}
284
285impl PlacementConstraints {
286 /// Whether a node with `node_region` + `node_labels` satisfies these
287 /// constraints.
288 pub fn allows(
289 &self,
290 node_region: Option<&str>,
291 node_labels: &BTreeMap<String, String>,
292 ) -> bool {
293 if !self.regions.is_empty() {
294 match node_region {
295 Some(r) if self.regions.iter().any(|want| want == r) => {}
296 _ => return false,
297 }
298 }
299 self.labels
300 .iter()
301 .all(|(k, v)| node_labels.get(k).is_some_and(|nv| nv == v))
302 }
303}
304
305/// The mutable desired state for a workload (`compute/<name>`): the active spec
306/// version, replica count, and placement. Activation is a pointer flip to a new
307/// spec hash — the same atomic, roll-back-able model as a site deployment.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
309#[serde(deny_unknown_fields)]
310pub struct ComputeWorkload {
311 /// Pinned schema discriminant (`v1`).
312 #[serde(default = "crate::schema_version")]
313 pub version: u32,
314 /// Human label (the workload name is the KV key).
315 pub name: String,
316 /// The active [`ComputeSpec`] content hash (`computever/<hash>`).
317 pub active: String,
318 /// Desired replica count.
319 pub replicas: u32,
320 /// Placement constraints.
321 #[serde(default)]
322 pub placement: PlacementConstraints,
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn spec() -> ComputeSpec {
330 ComputeSpec {
331 version: crate::SCHEMA_VERSION,
332 root: RootSource::Rootfs("a".repeat(64)),
333 kernel: "b".repeat(64),
334 kernel_cmdline: None,
335 vcpus: 2,
336 mem_mib: 512,
337 entrypoint: vec!["/app".into(), "--serve".into()],
338 env: BTreeMap::from([("PORT".to_string(), "8080".to_string())]),
339 port: 8080,
340 restart: RestartPolicy::Always,
341 scale_to_zero: true,
342 volumes: vec![],
343 writable_root: false,
344 cap_add: Vec::new(),
345 user: None,
346 isolation: IsolationRequirement::Trusted,
347 prefer_backend: None,
348 bindings: vec![],
349 }
350 }
351
352 #[test]
353 fn empty_bindings_do_not_change_the_spec_hash() {
354 // A spec that declares no bindings serializes without the field, so it hashes
355 // identically to a pre-bindings spec (back-compat).
356 let a = spec();
357 let json = serde_json::to_string(&a).unwrap();
358 assert!(!json.contains("bindings"), "empty bindings are omitted");
359
360 // Declaring a binding is recorded and changes the content hash.
361 let mut b = spec();
362 b.bindings = vec![ComputeBinding {
363 kind: BindingKind::Sql,
364 name: String::new(),
365 url_env: None,
366 }];
367 assert_ne!(a.id(), b.id(), "a declared binding changes the id");
368 assert!(serde_json::to_string(&b).unwrap().contains("bindings"));
369 }
370
371 #[test]
372 fn binding_kind_parses_lowercase_and_url_env_defaults_per_kind() {
373 assert_eq!(
374 serde_json::from_str::<BindingKind>("\"sql\"").unwrap(),
375 BindingKind::Sql
376 );
377 let sql = ComputeBinding {
378 kind: BindingKind::Sql,
379 name: String::new(),
380 url_env: None,
381 };
382 assert_eq!(sql.url_env(), "BOATRAMP_SQL_URL");
383 let custom = ComputeBinding {
384 kind: BindingKind::Sql,
385 name: "analytics".into(),
386 url_env: Some("ANALYTICS_URL".into()),
387 };
388 assert_eq!(custom.url_env(), "ANALYTICS_URL");
389 }
390
391 #[test]
392 fn spec_id_is_stable_and_content_addressed() {
393 let a = spec();
394 let mut b = spec();
395 assert_eq!(a.id(), b.id(), "identical specs share an id");
396 b.vcpus = 4;
397 assert_ne!(a.id(), b.id(), "a changed field changes the id");
398 assert_eq!(a.id().len(), 64);
399 }
400
401 #[test]
402 fn default_isolation_does_not_change_the_spec_hash() {
403 // `Trusted` (default) is omitted from the JSON, so a spec that doesn't
404 // touch isolation hashes identically to one explicitly set to Trusted.
405 let mut a = spec();
406 a.isolation = IsolationRequirement::Trusted;
407 let json = serde_json::to_string(&a).unwrap();
408 assert!(!json.contains("isolation"), "default isolation is omitted");
409 // Untrusted is recorded and changes the hash.
410 let mut b = spec();
411 b.isolation = IsolationRequirement::Untrusted;
412 assert_ne!(a.id(), b.id());
413 assert!(serde_json::to_string(&b).unwrap().contains("untrusted"));
414 }
415
416 #[test]
417 fn spec_round_trips_through_json() {
418 let a = spec();
419 let json = serde_json::to_string(&a).unwrap();
420 assert_eq!(serde_json::from_str::<ComputeSpec>(&json).unwrap(), a);
421 }
422
423 #[test]
424 fn keyspace_helpers() {
425 assert_eq!(
426 workload_key("default", "api"),
427 "project/default/compute/api"
428 );
429 assert_eq!(workload_key("acme", "api"), "project/acme/compute/api");
430 assert_eq!(workloads_prefix("default"), "project/default/compute/");
431 // The spec body is content-addressed and stays global (dedup across projects).
432 assert_eq!(spec_key("deadbeef"), "computever/deadbeef");
433 }
434
435 #[test]
436 fn placement_matches_region_and_labels() {
437 let c = PlacementConstraints {
438 regions: vec!["eu".into()],
439 labels: BTreeMap::from([("gpu".to_string(), "yes".to_string())]),
440 };
441 let labels = BTreeMap::from([("gpu".to_string(), "yes".to_string())]);
442 assert!(c.allows(Some("eu"), &labels));
443 assert!(!c.allows(Some("us"), &labels), "wrong region");
444 assert!(!c.allows(Some("eu"), &BTreeMap::new()), "missing label");
445 // No constraints → any node.
446 assert!(PlacementConstraints::default().allows(None, &BTreeMap::new()));
447 }
448}