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    /// Idempotent setup actions boatramp runs against this workload AFTER its runner
226    /// reports up and it passes its service-readiness probe, and before the replica is
227    /// marked ready (`Provisioned`) and routed to. The general, user-declared half of the
228    /// post-launch primitive (a stock image's one-off init / migrate / warmup step); the
229    /// managed-database `CREATE DATABASE` / role setup is a separate *derived* source that
230    /// is not part of the content hash. Actions MUST be idempotent — a relaunch (crash,
231    /// node move, reboot) re-enters the launch path and re-runs them (there is no
232    /// first-launch marker). Empty ⇒ omitted from the wire + the content hash (back-compat).
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub post_launch: Vec<PostLaunchAction>,
235}
236
237/// A single [`ComputeSpec::post_launch`] action. Extensible: `Exec` is the canonical
238/// general kind (run a command in the launched container via the backend's `exec`);
239/// future kinds (an HTTP warmup, a SQL script) are additive variants.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum PostLaunchAction {
243    /// Run a command inside the launched container via the backend's `exec` (needs a
244    /// backend that supports exec — docker / native container; a backend without exec
245    /// support fails the action, surfaced as the replica's provisioning failure). The
246    /// command inherits the container's environment; a non-zero exit fails the action.
247    Exec {
248        /// The command + arguments to exec in the container.
249        argv: Vec<String>,
250    },
251}
252
253/// A managed-resource dependency a compute workload declares. It names a *resource*,
254/// never a project: the owning project comes from the workload's key
255/// (`project/<proj>/compute/<name>`), so a workload cannot request another tenant's
256/// data. boatramp resolves it at launch and injects the endpoint into the guest env.
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct ComputeBinding {
260    /// Which managed resource kind.
261    pub kind: BindingKind,
262    /// The named database/store within the kind (`""` = the site default, matching
263    /// `sql.open("")`).
264    #[serde(default, skip_serializing_if = "String::is_empty")]
265    pub name: String,
266    /// The env var the resolved endpoint URL is injected as; `None` ⇒ the kind default
267    /// (`sql` → `BOATRAMP_SQL_URL`). The credential is injected as `<url_env>_AUTH_TOKEN`.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub url_env: Option<String>,
270}
271
272/// The managed-resource kinds a [`ComputeBinding`] may name. Phase 0 implements `Sql`;
273/// the others are reserved for the shared resolver mechanism.
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
275#[serde(rename_all = "lowercase")]
276pub enum BindingKind {
277    /// The managed `sql` database (per-site libsql, or a named external DB).
278    Sql,
279    /// Per-site key-value store (Phase 2).
280    Kv,
281    /// Per-site blob store (Phase 2).
282    Blob,
283    /// Per-site pub/sub + queues (Phase 2).
284    Messaging,
285}
286
287impl ComputeBinding {
288    /// The env var the endpoint URL is injected as (explicit `url_env`, else the kind
289    /// default). The auth token is injected as this name plus `_AUTH_TOKEN`.
290    pub fn url_env(&self) -> String {
291        self.url_env
292            .clone()
293            .unwrap_or_else(|| self.kind.default_url_env().to_string())
294    }
295}
296
297impl BindingKind {
298    /// A **stable** 1-byte discriminant for this kind, used where a derivation must not shift if the
299    /// enum is ever reordered or a variant renamed — the compute shims' token HMAC, where
300    /// `format!("{:?}", kind)` would silently rotate every live token on a rename. Append-only:
301    /// NEVER renumber an existing variant.
302    pub fn stable_tag(self) -> u8 {
303        match self {
304            Self::Sql => 1,
305            Self::Kv => 2,
306            Self::Blob => 3,
307            Self::Messaging => 4,
308        }
309    }
310
311    /// The default env var an endpoint of this kind is injected as.
312    pub fn default_url_env(self) -> &'static str {
313        match self {
314            Self::Sql => "BOATRAMP_SQL_URL",
315            Self::Kv => "BOATRAMP_KV_URL",
316            Self::Blob => "BOATRAMP_BLOB_URL",
317            Self::Messaging => "BOATRAMP_MESSAGING_URL",
318        }
319    }
320}
321
322impl ComputeSpec {
323    /// The content hash of this spec — its `computever/<hash>` id. Computed over
324    /// the canonical JSON so identical specs dedupe (like a deployment id).
325    pub fn id(&self) -> String {
326        let canonical = serde_json::to_vec(self).expect("ComputeSpec serializes");
327        sha256_hex(&canonical)
328    }
329}
330
331/// Placement constraints: where a workload's replicas may run.
332#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(default, deny_unknown_fields)]
334pub struct PlacementConstraints {
335    /// If non-empty, only nodes in one of these regions are eligible.
336    pub regions: Vec<String>,
337    /// Required node labels (all must match a node's advertised labels).
338    pub labels: BTreeMap<String, String>,
339}
340
341impl PlacementConstraints {
342    /// Whether a node with `node_region` + `node_labels` satisfies these
343    /// constraints.
344    pub fn allows(
345        &self,
346        node_region: Option<&str>,
347        node_labels: &BTreeMap<String, String>,
348    ) -> bool {
349        if !self.regions.is_empty() {
350            match node_region {
351                Some(r) if self.regions.iter().any(|want| want == r) => {}
352                _ => return false,
353            }
354        }
355        self.labels
356            .iter()
357            .all(|(k, v)| node_labels.get(k).is_some_and(|nv| nv == v))
358    }
359}
360
361/// The mutable desired state for a workload (`compute/<name>`): the active spec
362/// version, replica count, and placement. Activation is a pointer flip to a new
363/// spec hash — the same atomic, roll-back-able model as a site deployment.
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(deny_unknown_fields)]
366pub struct ComputeWorkload {
367    /// Pinned schema discriminant (`v1`).
368    #[serde(default = "crate::schema_version")]
369    pub version: u32,
370    /// Human label (the workload name is the KV key).
371    pub name: String,
372    /// The active [`ComputeSpec`] content hash (`computever/<hash>`).
373    pub active: String,
374    /// Desired replica count.
375    pub replicas: u32,
376    /// Placement constraints.
377    #[serde(default)]
378    pub placement: PlacementConstraints,
379}
380
381/// The default replica count for a workload (1) — a service runs one replica
382/// unless the manifest / request asks for more.
383pub fn default_replicas() -> u32 {
384    1
385}
386
387/// The body of `PUT /api/compute/:name` — an immutable [`ComputeSpec`] plus the
388/// desired replica count and [`PlacementConstraints`]. This is the SINGLE typed
389/// schema for a compute workload's desired state: both the server handler
390/// (`put_compute`) and the client manifest ([`crate::…::ApplyCompute`] mirrors
391/// these fields) deserialize into it, so there is no schema duplication and the
392/// client can fail fast on a malformed spec BEFORE the request leaves the host.
393/// The server still runs its own semantic validation (kernel defaulting, name
394/// screening) on top — the typed client parse is an additive gate, not a
395/// replacement.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(deny_unknown_fields)]
398pub struct PutComputeRequest {
399    /// The immutable workload spec (rootfs/kernel blob hashes + sizing).
400    pub spec: ComputeSpec,
401    /// Desired replica count (default 1).
402    #[serde(default = "default_replicas")]
403    pub replicas: u32,
404    /// Placement constraints.
405    #[serde(default)]
406    pub placement: PlacementConstraints,
407}
408
409// ---------------------------------------------------------------------------
410// Declarative managed-database surface (v0.6.0, #501 Stage B).
411// ---------------------------------------------------------------------------
412
413/// The engine a declared managed database runs — the SAFE, typed subset of the
414/// node-static `ExternalDatabaseConfig.kind` string. A manifest may only declare an
415/// engine boatramp fully manages (provisions + seals the credential for): Postgres
416/// or MySQL. `libsql` is deliberately absent — the embedded engine is the per-site
417/// default and needs no declaration, and its `path` source is host-fs (excluded).
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(rename_all = "lowercase")]
420pub enum ApplyDatabaseKind {
421    /// PostgreSQL (lowered to `ExternalDatabaseConfig.kind = "postgres"`).
422    Postgres,
423    /// MySQL (lowered to `ExternalDatabaseConfig.kind = "mysql"`).
424    Mysql,
425}
426
427impl ApplyDatabaseKind {
428    /// The node-static `ExternalDatabaseConfig.kind` string this lowers to.
429    pub fn as_config_kind(self) -> &'static str {
430        match self {
431            Self::Postgres => "postgres",
432            Self::Mysql => "mysql",
433        }
434    }
435}
436
437/// How a declared managed database is physically isolated per tenant — the typed
438/// projection of `ExternalDatabaseConfig.tenant` (`TenantIsolation`). `Single` = a
439/// dedicated server (container) per tenant; `Shared` = one server hosting a
440/// per-tenant database + login role (grant-isolated).
441#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
442#[serde(rename_all = "lowercase")]
443pub enum ApplyDatabaseTenant {
444    /// A dedicated database server (its own container) per tenant. The default.
445    #[default]
446    Single,
447    /// One shared server hosting a per-tenant database + role (grant-isolated).
448    Shared,
449}
450
451/// The grain of a tenant for a declared managed database — the typed projection of
452/// `ExternalDatabaseConfig.tenant_scope` (`TenantScope`). `Project` (default) or
453/// `Site`.
454#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(rename_all = "lowercase")]
456pub enum ApplyDatabaseScope {
457    /// A tenant is a **project** (the default).
458    #[default]
459    Project,
460    /// A tenant is a **site** (finer than project).
461    Site,
462}
463
464/// How "ready" a managed-DB replica must be before a surface treats it as usable — the
465/// configurable readiness gate (v0.45.0). The strict default, [`Provisioned`], means the
466/// replica has completed its post-launch provisioning (its `CREATE DATABASE` / role DDL).
467/// Relaxed to [`ServiceReady`], a surface uses the replica as soon as it answers a real
468/// `SELECT 1`, before provisioning has finished. Shared between the manifest
469/// [`ApplyDatabase`] and the serving `ExternalDatabaseConfig`.
470#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub enum ReadinessLevel {
473    /// Require full post-launch provisioning (the strict default).
474    #[default]
475    Provisioned,
476    /// Require only that the service answers `SELECT 1` (relaxed).
477    ServiceReady,
478}
479
480/// Per-surface readiness gate for a managed database (v0.45.0). Each surface defaults to
481/// [`ReadinessLevel::Provisioned`]; an operator may relax any one independently:
482/// - `answerability` — gateway/internal-DNS: whether a replica is an answerable upstream.
483/// - `routing` — the handler-runtime SQL connection: whether a request is routed to it.
484/// - `migration` — operator `sql exec` / `migrate` / `ensure`-await (container-backed DBs).
485#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(default)]
487pub struct ReadinessGate {
488    /// Gate for gateway/internal-DNS answerability. The strict default (`Provisioned`) is
489    /// honored across all workloads today; per-DB relaxation to `service_ready` for this
490    /// surface is a follow-up (the node-global DNS source must become config-aware first).
491    pub answerability: ReadinessLevel,
492    /// Gate for request-path handler SQL routing.
493    pub routing: ReadinessLevel,
494    /// Gate for operator migration/ensure (container-backed DBs).
495    pub migration: ReadinessLevel,
496}
497
498/// A coarse sizing **preset** for a declared managed database — NOT raw VM knobs.
499/// The manifest author picks a t-shirt size; boatramp maps it to concrete
500/// vcpus/mem/volume at lowering time (see [`ApplyDatabaseSize::resources`]), so the
501/// declarative surface never exposes an unbounded resource request (the errno-28
502/// disk-full / DoS guard). Default `Small`.
503#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "lowercase")]
505pub enum ApplyDatabaseSize {
506    /// 1 vCPU / 512 MiB / 10 GiB volume — the default, matching the stock
507    /// `managed_db_spec` sizing.
508    #[default]
509    Small,
510    /// 2 vCPU / 2 GiB / 50 GiB volume.
511    Medium,
512    /// 4 vCPU / 8 GiB / 200 GiB volume.
513    Large,
514}
515
516/// How a managed-DB **config change that only takes effect at server start** is applied
517/// (e.g. toggling `delta_capture`, which flips `wal_level`/binlog in the launch argv and so
518/// remints the content-addressed spec). The change is always STAGED by rolling the workload's
519/// active spec; this policy decides whether the DB is also restarted to adopt it NOW.
520#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "snake_case")]
522pub enum DbRestartPolicy {
523    /// Stage the change and mark the DB "restart pending" — the operator restarts it
524    /// explicitly (`boatramp db restart`) at a chosen time. The safe default: a managed DB
525    /// holds production data, so never cause surprise downtime on a config re-apply.
526    #[default]
527    Manual,
528    /// Apply the change and restart the DB immediately so it takes effect at once (brief
529    /// downtime while the container relaunches onto the reattached data volume).
530    Auto,
531}
532
533impl DbRestartPolicy {
534    /// Whether a staged restart-requiring change should restart the DB immediately.
535    #[must_use]
536    pub fn is_auto(self) -> bool {
537        matches!(self, Self::Auto)
538    }
539}
540
541impl ApplyDatabaseSize {
542    /// The `(vcpus, mem_mib, volume_size_mib)` this preset maps to. The single
543    /// authority for the preset → resources mapping, so the CLI (rendering) and the
544    /// server (lowering) agree. Deliberately bounded: `Large` is the ceiling a
545    /// manifest can request — there is no raw-knob escape hatch.
546    pub fn resources(self) -> (u32, u32, u32) {
547        match self {
548            // (vcpus, mem_mib, volume_size_mib)
549            Self::Small => (1, 512, 10 * 1024),
550            Self::Medium => (2, 2 * 1024, 50 * 1024),
551            Self::Large => (4, 8 * 1024, 200 * 1024),
552        }
553    }
554}
555
556/// The operator ceilings a declared database's optional tuning knobs are CAPPED to
557/// (a DoS / resource guard — the manifest author can lower a knob, never raise it
558/// past the ceiling). Applied at lowering time in `boatramp-node`; centralized here
559/// so the cap is one authority.
560pub mod apply_db_caps {
561    /// Maximum `pool_max` a manifest may request (connections). A too-large pool
562    /// exhausts the server's connection slots.
563    pub const MAX_POOL: u32 = 64;
564    /// Maximum `connect_timeout_secs` a manifest may request.
565    pub const MAX_CONNECT_TIMEOUT_SECS: u64 = 60;
566    /// Maximum `startup_grace_secs` a manifest may request (bounds how long a
567    /// never-healthy replica is left running before the reconcile relaunches it).
568    pub const MAX_STARTUP_GRACE_SECS: u32 = 600;
569
570    /// Default operator ceiling on the **number** of declared databases a single
571    /// project may hold (#501 Stage B MEDIUM-1 — the disk-exhaustion / errno-28 guard).
572    /// Each declared DB eagerly provisions a 10–200 GiB volume, so without a per-project
573    /// cap a `Project·Admin` could declare an unbounded number and exhaust the node's
574    /// disk. Operator-overridable via `handlers.bindings.sql.max_declared_databases`;
575    /// enforced fail-closed at `declare`.
576    pub const DEFAULT_MAX_DECLARED_DATABASES_PER_PROJECT: usize = 16;
577
578    /// Default operator ceiling on the **aggregate provisioned volume** (MiB) across all
579    /// of a project's declared databases (#501 Stage B MEDIUM-1). 512 GiB — comfortably
580    /// above the 16-DB count cap at the Small preset (16 × 10 GiB = 160 GiB) yet a hard
581    /// backstop against a handful of Large (200 GiB) declarations. Operator-overridable
582    /// via `handlers.bindings.sql.max_declared_volume_mib`; enforced fail-closed at
583    /// `declare` by summing the existing declarations' volumes + the incoming one.
584    pub const DEFAULT_MAX_DECLARED_VOLUME_MIB: u64 = 512 * 1024;
585}
586
587/// A **declared managed database** in an [`ApplyManifest`](crate) `databases:` block
588/// (v0.6.0, #501 Stage B). This is a TYPED PROJECTION of the node-static
589/// `ExternalDatabaseConfig` restricted to the fields that are SAFE for a project
590/// author to declare — the security contract.
591///
592/// # The security contract (what is DELIBERATELY absent)
593///
594/// A declared database is DEFINITIONALLY the "boatramp fully manages + seals the
595/// credential" path: it carries **no** secret / env-var / URL / host-fs / image
596/// field, so a manifest can neither reference a secret nor pick an arbitrary OCI
597/// image. The EXCLUDED fields (NOT part of this struct — that exclusion IS the
598/// contract):
599///
600/// - `image` — arbitrary-OCI RCE (no allowlist); boatramp always picks the stock
601///   engine image at lowering.
602/// - `password_env` — omitting it is what selects the managed-credential path
603///   (`ExternalDatabaseConfig::is_managed_credential`); a declared DB can NEVER bring
604///   its own password.
605/// - `url_env` / `read_url_env` / `migration_url_env` — BYO-secret / SSRF /
606///   arbitrary-host reach.
607/// - `path` — host-fs traversal.
608/// - `compute` — the manifest does NOT let the author name an arbitrary compute
609///   workload; boatramp DERIVES the per-project managed DB server workload name at
610///   lowering, so a manifest can only ever provision onto its own project's server.
611///
612/// The struct uses `deny_unknown_fields` so a manifest that tries to smuggle one of
613/// the excluded keys fails to parse rather than being silently ignored.
614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
615#[serde(deny_unknown_fields)]
616pub struct ApplyDatabase {
617    /// The binding name — how a guest reaches it via `sql.open("<name>")` and the
618    /// `{name}` path segment of the `project/{project}/database/{name}` store key.
619    pub name: String,
620    /// The engine (Postgres or MySQL). Lowered to `ExternalDatabaseConfig.kind`.
621    pub kind: ApplyDatabaseKind,
622    /// The engine major version (e.g. `16` for Postgres). Advisory metadata carried
623    /// on the stored binding; a version CHANGE on re-apply routes through the
624    /// owner-gated migrate/repair path (never a silent re-init). `None` ⇒ the engine
625    /// default.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub version: Option<u32>,
628    /// Trusted extensions to make available (Postgres). Advisory metadata; enabling
629    /// one still routes through the owner-gated migration `Extension` step +
630    /// operator allowlist — this does NOT bypass that gate.
631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
632    pub extensions: Vec<String>,
633    /// Coarse sizing preset (`small`/`medium`/`large`). Maps to bounded
634    /// vcpus/mem/volume at lowering — NOT raw VM knobs.
635    #[serde(default)]
636    pub size: ApplyDatabaseSize,
637    /// Isolation mechanism (`single` = dedicated container per tenant; `shared` =
638    /// one server, per-tenant db + role). Lowered to `ExternalDatabaseConfig.tenant`.
639    /// A CHANGE on re-apply is REFUSED (silent data-loss/orphan guard).
640    #[serde(default)]
641    pub tenant: ApplyDatabaseTenant,
642    /// Tenant grain (`project`/`site`). Lowered to
643    /// `ExternalDatabaseConfig.tenant_scope`. A CHANGE on re-apply is REFUSED.
644    #[serde(default)]
645    pub tenant_scope: ApplyDatabaseScope,
646    /// Open every transaction `READ ONLY`.
647    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
648    pub read_only: bool,
649    /// Opt-in native-RLS session injection (`rls_session`). See
650    /// `ExternalDatabaseConfig.rls_session`.
651    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
652    pub rls_session: bool,
653    /// The Postgres session GUC for the host-resolved tenant (RLS backstop). Only
654    /// honored with `rls_session` on + Postgres.
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub tenant_guc: Option<String>,
657    /// The session GUC for the anonymous session axis (RLS backstop).
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub session_guc: Option<String>,
660    /// The reserved sentinel written to `tenant_guc` on an `all`-scoped read.
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub tenant_all_marker: Option<String>,
663    /// Maximum pooled connections. CAPPED to [`apply_db_caps::MAX_POOL`] at lowering.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub pool_max: Option<u32>,
666    /// Connection/acquire timeout in seconds. CAPPED to
667    /// [`apply_db_caps::MAX_CONNECT_TIMEOUT_SECS`] at lowering.
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub connect_timeout_secs: Option<u64>,
670    /// Startup grace in seconds for the managed server's first `initdb`. CAPPED to
671    /// [`apply_db_caps::MAX_STARTUP_GRACE_SECS`] at lowering.
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub startup_grace_secs: Option<u32>,
674    /// The per-surface **readiness gate** (v0.45.0): the readiness level each surface
675    /// requires before treating a replica as usable. Default: every surface `provisioned`
676    /// (strict). Passes through to the serving config unchanged.
677    #[serde(default, skip_serializing_if = "readiness_gate_is_default")]
678    pub readiness_gate: ReadinessGate,
679    /// **Opt-in** (default `false`): enable row-level change capture on this managed DB so it
680    /// can be the source of an incremental/delta export's SQL true-delta (Postgres logical WAL
681    /// / MySQL binlog). Flips `wal_level=logical` / `--log-bin` in the launch argv, so turning
682    /// it on remints the spec and needs a restart (see `restart_policy`). Off ⇒ no WAL/binlog
683    /// cost; a delta export of this DB falls back to a full dump. Ignored for a bring-your-own DB.
684    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
685    pub delta_capture: bool,
686    /// How a restart-requiring config change (e.g. toggling `delta_capture`) is applied —
687    /// `manual` (default: stage + mark pending) or `auto` (apply + restart now).
688    #[serde(default, skip_serializing_if = "restart_policy_is_default")]
689    pub restart_policy: DbRestartPolicy,
690}
691
692/// Omit `restart_policy` from a serialized manifest when it is the `manual` default.
693fn restart_policy_is_default(p: &DbRestartPolicy) -> bool {
694    *p == DbRestartPolicy::default()
695}
696
697/// Omit `readiness_gate` from a serialized manifest when it is the all-`provisioned`
698/// default (manifest hygiene — the common case writes nothing).
699fn readiness_gate_is_default(g: &ReadinessGate) -> bool {
700    *g == ReadinessGate::default()
701}
702
703impl ApplyDatabase {
704    /// The identity fields a re-apply may NEVER change (silent data-loss / orphan
705    /// guard): `kind`, `tenant`, `tenant_scope`. (There is no author-supplied
706    /// `compute` to compare — boatramp derives it, so it cannot drift.) Returns the
707    /// first differing field name, or `None` if the two declarations agree on all
708    /// identity fields. `version`/`extensions`/`size`/rls-knobs are intentionally NOT
709    /// compared — those route through migrate/repair or are documented re-tuning.
710    pub fn identity_change(&self, prior: &Self) -> Option<&'static str> {
711        if self.kind != prior.kind {
712            return Some("kind");
713        }
714        if self.tenant != prior.tenant {
715            return Some("tenant");
716        }
717        if self.tenant_scope != prior.tenant_scope {
718            return Some("tenant_scope");
719        }
720        None
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    fn spec() -> ComputeSpec {
729        ComputeSpec {
730            version: crate::SCHEMA_VERSION,
731            root: RootSource::Rootfs("a".repeat(64)),
732            kernel: "b".repeat(64),
733            kernel_cmdline: None,
734            vcpus: 2,
735            mem_mib: 512,
736            entrypoint: vec!["/app".into(), "--serve".into()],
737            env: BTreeMap::from([("PORT".to_string(), "8080".to_string())]),
738            port: 8080,
739            restart: RestartPolicy::Always,
740            startup_grace_secs: 30,
741            scale_to_zero: true,
742            volumes: vec![],
743            writable_root: false,
744            cap_add: Vec::new(),
745            user: None,
746            isolation: IsolationRequirement::Trusted,
747            prefer_backend: None,
748            bindings: vec![],
749            post_launch: vec![],
750        }
751    }
752
753    #[test]
754    fn empty_post_launch_does_not_change_the_spec_hash() {
755        // A spec that declares no post-launch actions serializes without the field, so it
756        // hashes identically to a pre-post_launch spec (back-compat / schema stays v1).
757        let a = spec();
758        let json = serde_json::to_string(&a).unwrap();
759        assert!(
760            !json.contains("post_launch"),
761            "empty post_launch is omitted"
762        );
763
764        // Declaring an action is recorded and changes the content hash (it is part of the
765        // workload's identity — changing it remints the spec and rolls the container).
766        let mut b = spec();
767        b.post_launch = vec![PostLaunchAction::Exec {
768            argv: vec!["/bin/true".into()],
769        }];
770        assert_ne!(
771            a.id(),
772            b.id(),
773            "a declared post-launch action changes the id"
774        );
775    }
776
777    #[test]
778    fn empty_bindings_do_not_change_the_spec_hash() {
779        // A spec that declares no bindings serializes without the field, so it hashes
780        // identically to a pre-bindings spec (back-compat).
781        let a = spec();
782        let json = serde_json::to_string(&a).unwrap();
783        assert!(!json.contains("bindings"), "empty bindings are omitted");
784
785        // Declaring a binding is recorded and changes the content hash.
786        let mut b = spec();
787        b.bindings = vec![ComputeBinding {
788            kind: BindingKind::Sql,
789            name: String::new(),
790            url_env: None,
791        }];
792        assert_ne!(a.id(), b.id(), "a declared binding changes the id");
793        assert!(serde_json::to_string(&b).unwrap().contains("bindings"));
794    }
795
796    #[test]
797    fn binding_kind_parses_lowercase_and_url_env_defaults_per_kind() {
798        assert_eq!(
799            serde_json::from_str::<BindingKind>("\"sql\"").unwrap(),
800            BindingKind::Sql
801        );
802        let sql = ComputeBinding {
803            kind: BindingKind::Sql,
804            name: String::new(),
805            url_env: None,
806        };
807        assert_eq!(sql.url_env(), "BOATRAMP_SQL_URL");
808        let custom = ComputeBinding {
809            kind: BindingKind::Sql,
810            name: "analytics".into(),
811            url_env: Some("ANALYTICS_URL".into()),
812        };
813        assert_eq!(custom.url_env(), "ANALYTICS_URL");
814    }
815
816    #[test]
817    fn spec_id_is_stable_and_content_addressed() {
818        let a = spec();
819        let mut b = spec();
820        assert_eq!(a.id(), b.id(), "identical specs share an id");
821        b.vcpus = 4;
822        assert_ne!(a.id(), b.id(), "a changed field changes the id");
823        assert_eq!(a.id().len(), 64);
824    }
825
826    #[test]
827    fn default_isolation_does_not_change_the_spec_hash() {
828        // `Trusted` (default) is omitted from the JSON, so a spec that doesn't
829        // touch isolation hashes identically to one explicitly set to Trusted.
830        let mut a = spec();
831        a.isolation = IsolationRequirement::Trusted;
832        let json = serde_json::to_string(&a).unwrap();
833        assert!(!json.contains("isolation"), "default isolation is omitted");
834        // Untrusted is recorded and changes the hash.
835        let mut b = spec();
836        b.isolation = IsolationRequirement::Untrusted;
837        assert_ne!(a.id(), b.id());
838        assert!(serde_json::to_string(&b).unwrap().contains("untrusted"));
839    }
840
841    #[test]
842    fn spec_round_trips_through_json() {
843        let a = spec();
844        let json = serde_json::to_string(&a).unwrap();
845        assert_eq!(serde_json::from_str::<ComputeSpec>(&json).unwrap(), a);
846    }
847
848    #[test]
849    fn old_spec_without_startup_grace_deserializes_with_the_default() {
850        // An additive, `#[serde(default)]` field keeps schema at v1: a spec stored
851        // before `startup_grace_secs` existed (no such key) still deserializes, taking
852        // the generic default (30). `deny_unknown_fields` is on, so this proves the
853        // field is genuinely optional on read.
854        let old = r#"{
855            "version": 1,
856            "root": { "rootfs": "aaaa" },
857            "vcpus": 1,
858            "mem_mib": 256,
859            "port": 8080
860        }"#;
861        let spec: ComputeSpec = serde_json::from_str(old).expect("old spec deserializes");
862        assert_eq!(spec.version, 1, "schema stays v1");
863        assert_eq!(spec.startup_grace_secs, default_startup_grace_secs());
864        assert_eq!(spec.startup_grace_secs, 30);
865    }
866
867    #[test]
868    fn keyspace_helpers() {
869        assert_eq!(
870            workload_key("default", "api"),
871            "project/default/compute/api"
872        );
873        assert_eq!(workload_key("acme", "api"), "project/acme/compute/api");
874        assert_eq!(workloads_prefix("default"), "project/default/compute/");
875        // The spec body is content-addressed and stays global (dedup across projects).
876        assert_eq!(spec_key("deadbeef"), "computever/deadbeef");
877    }
878
879    #[test]
880    fn placement_matches_region_and_labels() {
881        let c = PlacementConstraints {
882            regions: vec!["eu".into()],
883            labels: BTreeMap::from([("gpu".to_string(), "yes".to_string())]),
884        };
885        let labels = BTreeMap::from([("gpu".to_string(), "yes".to_string())]);
886        assert!(c.allows(Some("eu"), &labels));
887        assert!(!c.allows(Some("us"), &labels), "wrong region");
888        assert!(!c.allows(Some("eu"), &BTreeMap::new()), "missing label");
889        // No constraints → any node.
890        assert!(PlacementConstraints::default().allows(None, &BTreeMap::new()));
891    }
892}