boatramp_core/compute.rs
1//! Compute: the wasm-clean workload model (re-exported from
2//! [`boatramp_types::compute`]) plus the native control-plane layer — the
3//! pluggable [`ComputeBackend`] trait, the backend-aware scheduler, the
4//! selection/isolation policy, and the pure reconcile planner.
5//!
6//! Everything here is backend-agnostic and cross-platform. The concrete backends
7//! (VMM = `boatramp-firecracker`, native container, remote docker, cloudflare)
8//! implement [`ComputeBackend`]; a leader-gated loop drives [`reconcile_plan`].
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use serde::{Deserialize, Serialize};
15
16pub use boatramp_types::compute::*;
17
18use crate::deploy::DeployStore;
19use crate::project::ProjectRef;
20
21// ---------------------------------------------------------------------------
22// Backend trait + value types
23// ---------------------------------------------------------------------------
24
25/// The isolation a backend **provides** (distinct from the workload's
26/// [`IsolationRequirement`], which is what it *needs*).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum IsolationClass {
30 /// A microVM with its own guest kernel under KVM (strongest).
31 VmKvm,
32 /// OS-level namespaces + cgroups, sharing the host kernel.
33 Namespace,
34 /// A container on a (possibly remote) container runtime.
35 Container,
36 /// A managed platform (e.g. Cloudflare Containers).
37 Platform,
38}
39
40impl IsolationClass {
41 /// Whether this class is strong enough for untrusted multi-tenant code
42 /// (a microVM or a managed platform — never a shared-kernel container).
43 pub fn is_strong(self) -> bool {
44 matches!(self, Self::VmKvm | Self::Platform)
45 }
46
47 /// Whether this class satisfies a workload's isolation requirement.
48 pub fn satisfies(self, req: IsolationRequirement) -> bool {
49 match req {
50 IsolationRequirement::Trusted => true,
51 IsolationRequirement::Untrusted => self.is_strong(),
52 }
53 }
54}
55
56/// What a backend can do in the current environment (for scheduling + policy).
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Capabilities {
59 /// The isolation class this backend provides.
60 pub isolation: IsolationClass,
61 /// Whether it supports snapshot/restore (scale-to-zero).
62 pub scale_to_zero: bool,
63 /// Whether it supports persistent volumes.
64 pub persistent_volumes: bool,
65 /// Max vCPUs per replica, if bounded.
66 pub max_vcpus: Option<u32>,
67 /// Max memory (MiB) per replica, if bounded.
68 pub max_mem_mib: Option<u32>,
69}
70
71/// A backend-specific, materialized artifact for a spec (what the backend boots).
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Artifact {
74 /// A microVM: an `ext4` rootfs + a guest kernel, as host paths.
75 VmImages {
76 /// Host path to the `ext4` rootfs.
77 rootfs_path: String,
78 /// Host path to the guest `vmlinux`.
79 kernel_path: String,
80 },
81 /// An unpacked rootfs directory (native container).
82 Rootfs {
83 /// Host path to the rootfs tree.
84 dir: String,
85 },
86 /// An OCI image reference a runtime/platform pulls (docker / cloudflare).
87 Image {
88 /// The image reference (`registry/repo:tag` or a digest).
89 reference: String,
90 },
91}
92
93/// The request to launch one replica.
94#[derive(Debug, Clone)]
95pub struct LaunchRequest {
96 /// The owning project — the first dimension of the replica identity, so two
97 /// projects each with a same-named workload can't collide on the backend's
98 /// derived id/cgroup/veth/IP. `default` yields the bare, pre-project identity
99 /// (see [`compute_instance_id`]).
100 pub project: String,
101 /// Workload name (for naming / teardown / logging).
102 pub workload: String,
103 /// Replica ordinal within the workload (`0..replicas`).
104 pub replica: u32,
105 /// The immutable spec to run.
106 pub spec: ComputeSpec,
107 /// The materialized artifact for `spec`.
108 pub artifact: Artifact,
109}
110
111/// An opaque handle to a launched replica (for `stop`/`health`).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct InstanceHandle {
114 /// The owning project — the first dimension of the replica identity, closing
115 /// the cross-tenant collision where two projects' same-named workloads derived
116 /// the same backend id/cgroup/veth/IP. `#[serde(default)]` keeps pre-v0.3.12
117 /// records deserializing; the reconcile/adoption **backfills** it from the
118 /// replica-state KV key (`project/<proj>/…`) when empty, so a legacy record gets
119 /// its real project — never `""` — before any id is derived. Schema stays v1.
120 #[serde(default)]
121 pub project: String,
122 /// Workload name.
123 pub workload: String,
124 /// Replica ordinal.
125 pub replica: u32,
126 /// Backend-specific reference (pid / container id / CF instance id / …).
127 pub backend_ref: String,
128}
129
130/// The backend-derived identity stem for a replica, unique across projects:
131/// `<project>-<workload>-<replica>` for a non-`default` project, but the bare
132/// `<workload>-<replica>` for the reserved `default` project (and for an empty
133/// project string — a not-yet-backfilled legacy handle — which is treated as
134/// `default`). This stem keys the cgroup path, the container/VM id, the hostname,
135/// the veth/tap stem, and the guest log filename; the bare default form keeps
136/// existing default-project deployments byte-identical on disk (mirroring how the
137/// managed-volume/credential names keep `default` bare). Project names carry no
138/// `/` ([`validate_resource_name`]) so the single `-` join stays unambiguous, and
139/// the components are all cgroup/interface/hostname-safe (resource names are
140/// `[a-z0-9-]`).
141pub fn compute_instance_id(project: &str, workload: &str, replica: u32) -> String {
142 if project.is_empty() || project == crate::project::DEFAULT_PROJECT {
143 format!("{workload}-{replica}")
144 } else {
145 format!("{project}-{workload}-{replica}")
146 }
147}
148
149/// URL scheme for a replica endpoint.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum Scheme {
153 /// Plain HTTP.
154 Http,
155 /// HTTPS.
156 Https,
157}
158
159impl Scheme {
160 /// The lowercase URL-scheme token (matches the serde `rename_all`).
161 pub fn as_str(self) -> &'static str {
162 match self {
163 Self::Http => "http",
164 Self::Https => "https",
165 }
166 }
167}
168
169impl std::fmt::Display for Scheme {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.write_str(self.as_str())
172 }
173}
174
175/// Where the gateway routes to reach a replica.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct Endpoint {
178 /// Scheme to reach the replica with.
179 pub scheme: Scheme,
180 /// Host or IP.
181 pub host: String,
182 /// TCP port.
183 pub port: u16,
184}
185
186impl Endpoint {
187 /// The endpoint as a base URL (`scheme://host:port`).
188 pub fn url(&self) -> String {
189 format!("{}://{}:{}", self.scheme, self.host, self.port)
190 }
191}
192
193/// A launched replica: its handle + the endpoint the gateway routes to.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct Instance {
196 /// Handle for later `stop`/`health`.
197 pub handle: InstanceHandle,
198 /// The endpoint to route ingress to.
199 pub endpoint: Endpoint,
200}
201
202/// Liveness/readiness of a running replica.
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum Health {
205 /// Up and serving.
206 Healthy,
207 /// Running but not serving (or exited).
208 Unhealthy,
209 /// Indeterminate (e.g. transient probe failure).
210 Unknown,
211}
212
213/// An opaque snapshot for scale-to-zero (persisted inside [`ObservedInstance`]
214/// while a replica is parked in the [`Zero`](ReplicaPhase::Zero) phase).
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct Snapshot {
217 /// The owning project, carried so a `restore` derives the same project-qualified
218 /// identity ([`compute_instance_id`]) / IPAM key the launch used. `#[serde(default)]`
219 /// keeps pre-v0.3.12 parked records deserializing (empty ⇒ treated as `default`);
220 /// backfilled alongside the handle from the replica-state key. Schema stays v1.
221 #[serde(default)]
222 pub project: String,
223 /// Workload the snapshot belongs to.
224 pub workload: String,
225 /// Replica ordinal.
226 pub replica: u32,
227 /// Backend-specific reference to the stored snapshot.
228 pub data_ref: String,
229}
230
231/// Why a backend operation failed.
232#[derive(Debug, thiserror::Error)]
233pub enum BackendError {
234 /// The backend doesn't support the requested operation.
235 #[error("operation not supported by this backend")]
236 Unsupported,
237 /// Staging the artifact failed.
238 #[error("materialize: {0}")]
239 Materialize(String),
240 /// Launching the replica failed.
241 #[error("launch: {0}")]
242 Launch(String),
243 /// Stopping the replica failed.
244 #[error("stop: {0}")]
245 Stop(String),
246 /// Any other failure.
247 #[error("{0}")]
248 Other(String),
249}
250
251/// A pluggable compute execution backend (VMM / container / cloudflare / docker).
252///
253/// The control plane only ever sees [`Instance`]/[`Endpoint`]; whether the
254/// backend runs the workload directly (VMM, container) or delegates to a
255/// platform/daemon (cloudflare, docker) is internal.
256/// The buffered result of running a one-shot command inside a running workload
257/// replica (`ComputeBackend::exec`). Non-streaming: stdout/stderr are captured to
258/// completion. `exit_code` is the command's status (128+signal if it was killed).
259#[derive(Debug, Clone)]
260pub struct ExecOutput {
261 /// The command's exit status (128 + signal number if terminated by a signal).
262 pub exit_code: i32,
263 /// Captured standard output.
264 pub stdout: Vec<u8>,
265 /// Captured standard error.
266 pub stderr: Vec<u8>,
267}
268
269/// Why an operator [`ComputeExec::exec`] failed (distinct from a backend launch
270/// error — this layer adds "no replica to target" and "backend can't exec").
271#[derive(Debug, thiserror::Error)]
272pub enum ExecError {
273 /// No running replica of the workload to exec inside.
274 #[error("workload {0:?} has no running replica to exec in")]
275 NoReplica(String),
276 /// The workload's backend doesn't support exec (VM / edge backends).
277 #[error("the {0} backend does not support exec")]
278 Unsupported(String),
279 /// Any other failure (backend error, resolution failure, …).
280 #[error("exec failed: {0}")]
281 Other(String),
282}
283
284/// The operator-facing "run a command inside a running workload" capability
285/// (docker-exec style), backing `POST /api/compute/{name}/exec` and `boatramp
286/// compute exec`. The node implementation resolves a workload's running replica,
287/// selects its backend, and calls [`ComputeBackend::exec`]; only the shared-kernel
288/// backends (native `container`, remote `docker`) support it. Gated by the
289/// `allow_compute_exec` security posture at the API.
290#[async_trait]
291pub trait ComputeExec: Send + Sync {
292 /// Run `argv` (feeding `stdin` when present) inside a running replica of
293 /// `workload` in `project`, returning its buffered output.
294 async fn exec(
295 &self,
296 project: &str,
297 workload: &str,
298 argv: &[String],
299 stdin: Option<&[u8]>,
300 ) -> Result<ExecOutput, ExecError>;
301}
302
303/// Why an operator [`ComputeControl`] operation failed.
304#[derive(Debug, thiserror::Error)]
305pub enum ControlError {
306 /// The workload's backend doesn't support the operation (e.g. a platform backend
307 /// with no explicit `stop`).
308 #[error("the {0} backend does not support this operation")]
309 Unsupported(String),
310 /// Any other failure (backend error, store error, …).
311 #[error("compute control failed: {0}")]
312 Other(String),
313}
314
315/// The operator-facing "kick the reconcile plane" capability — a targeted,
316/// operator-triggered counterpart to the periodic reconcile. Backs
317/// `POST /api/compute/maintenance/restart` (admin-scoped). The node implementation
318/// resolves the replica, stops it via its backend, and drops its persisted observed
319/// state, so the next reconcile pass relaunches a fresh replica (re-running IPAM) —
320/// the live workaround for a wedged replica or a stale IP assignment.
321#[async_trait]
322pub trait ComputeControl: Send + Sync {
323 /// Restart replica `replica` of `workload` in `project`: stop it and delete its
324 /// observed state so the reconcile loop relaunches it. `Ok(false)` if no such
325 /// replica is persisted (nothing to restart).
326 async fn restart(
327 &self,
328 project: &str,
329 workload: &str,
330 replica: u32,
331 ) -> Result<bool, ControlError>;
332}
333
334#[async_trait]
335pub trait ComputeBackend: Send + Sync {
336 /// Stable backend id (`"vmm"` / `"container"` / `"cloudflare"` / `"docker"`).
337 fn id(&self) -> &'static str;
338
339 /// What this backend can do here (used by the scheduler + policy gate).
340 fn capabilities(&self) -> Capabilities;
341
342 /// Stage `spec`'s artifact into whatever this backend boots from.
343 /// Idempotent + content-addressed (cache/dedup by spec id).
344 async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError>;
345
346 /// **Adopt** the guest IPs already assigned to persisted/running replicas of
347 /// this backend, so a fresh-on-boot IP pool reflects addresses in use before it
348 /// hands out any new one. Called once at node startup with every known replica as
349 /// `(project, workload, replica, endpoint_ip)`; the backend reserves the ones it
350 /// owns (those in its own subnet), skipping the rest, and remembers each replica's
351 /// address — keyed by `(project, workload, replica)` so two projects' same-named
352 /// workloads never share a slot — so a relaunch reclaims the same endpoint (stable)
353 /// rather than a fresh one. Without this a backend that rebuilds its pool each
354 /// process start (the native `container` backend) could re-hand a live address to
355 /// a different workload — the container-IP collision. Backends that don't own a
356 /// per-node IP pool (docker / cloudflare delegate addressing) default to a no-op,
357 /// so they are unaffected.
358 async fn reserve_in_use(&self, _replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {}
359
360 /// **Reconcile the IP pool against reality** (A2): reclaim addresses this backend
361 /// still holds for replicas whose container is actually gone. Adoption reserves the
362 /// IP of every persisted replica at boot; a replica whose container has since
363 /// crashed (or whose state was removed out-of-band) would otherwise keep its IP
364 /// reserved for the life of the process, slowly leaking the pool. This is called
365 /// periodically with `parked` — the `(project, workload, replica)` keys that are
366 /// intentionally down (scale-to-zero `Zero`) and MUST keep their IP for the wake —
367 /// so the backend releases only the addresses it holds for a key that is **neither**
368 /// live (no running container) **nor** parked. Backends without a per-node IP pool
369 /// default to a no-op. Conservative by construction: a key is reclaimed only when
370 /// the backend is sure the container is gone.
371 async fn gc_ip_pool(&self, _parked: &[(String, String, u32)]) {}
372
373 /// Launch one replica; returns its handle + routable endpoint.
374 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError>;
375
376 /// Stop + clean up a replica (idempotent; safe on a half-launched instance).
377 async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError>;
378
379 /// Liveness/readiness of a running replica.
380 async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError>;
381
382 /// Snapshot a replica for scale-to-zero (backends that support it).
383 async fn snapshot(&self, _handle: &InstanceHandle) -> Result<Option<Snapshot>, BackendError> {
384 Ok(None)
385 }
386
387 /// Restore a snapshotted replica.
388 async fn restore(&self, _snapshot: &Snapshot) -> Result<Instance, BackendError> {
389 Err(BackendError::Unsupported)
390 }
391
392 /// Run a one-shot command **inside** a running replica (docker-exec style) and
393 /// return its buffered output — for operator ops (migrations, `pg_dump`, debug).
394 /// `stdin` is fed to the command's standard input when present. Only the
395 /// shared-kernel backends that can re-enter a running container implement it
396 /// (native `container` via `setns`, remote `docker` via the exec API); the
397 /// VM/edge backends return [`BackendError::Unsupported`]. The caller gates this
398 /// behind the `allow_compute_exec` security posture.
399 async fn exec(
400 &self,
401 _handle: &InstanceHandle,
402 _argv: &[String],
403 _stdin: Option<&[u8]>,
404 ) -> Result<ExecOutput, BackendError> {
405 Err(BackendError::Unsupported)
406 }
407
408 /// List this backend's persistent volumes (the host-side backing for a
409 /// spec's [`VolumeRef`]s). Only the backends that own an on-node volume
410 /// directory implement it (the native `container` backend, under
411 /// `<data_dir>/compute/volumes/<name>`); the rest return the empty default,
412 /// so a listing across a mixed fleet simply omits them. Backs the operator
413 /// `GET /api/compute/volumes` volume-reclamation surface.
414 async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
415 Ok(Vec::new())
416 }
417
418 /// Remove the backing for persistent volume `name`, returning whether it
419 /// existed. Only the backends that own an on-node volume directory implement
420 /// it (native `container`); the rest return [`BackendError::Unsupported`].
421 /// The caller (the node volume capability) refuses to remove a volume still
422 /// referenced by a registered workload's spec unless forced — see
423 /// [`ComputeVolumes`]. Backs `DELETE /api/compute/volumes/{name}`.
424 async fn remove_volume(&self, _name: &str) -> Result<bool, BackendError> {
425 Err(BackendError::Unsupported)
426 }
427}
428
429/// A persistent volume as seen by an operator listing (`GET /api/compute/volumes`
430/// / `boatramp compute volume ls`): the volume `name` (which backs the on-node
431/// directory `<data_dir>/compute/volumes/<name>`) and its total on-disk size in
432/// bytes. Whether the volume is still referenced by a registered workload's spec
433/// (in use vs orphaned) is decided one layer up, by [`ComputeVolumes`], not by the
434/// backend, which only sees the on-disk directories.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct VolumeInfo {
437 /// The volume name (the single path component under `.../compute/volumes/`).
438 pub name: String,
439 /// The volume's total on-disk size in bytes (summed recursively).
440 pub size_bytes: u64,
441}
442
443/// Why a [`ComputeVolumes`] operation failed. Distinct from a raw
444/// [`BackendError`]: this layer adds the "still in use" refusal (a volume a
445/// registered workload's spec still mounts) and "no volume-capable backend".
446#[derive(Debug, thiserror::Error)]
447pub enum VolumeError {
448 /// The volume is still referenced by a registered workload's active spec, so
449 /// removing it could corrupt a running/relaunching replica. `compute rm` the
450 /// workload first, or force the removal.
451 #[error("volume {0:?} is in use by a registered workload")]
452 InUse(String),
453 /// No backend on this node backs persistent volumes (so nothing to list/remove).
454 #[error("no volume-capable backend on this node")]
455 Unsupported,
456 /// Any other failure (backend error, store read failure, …).
457 #[error("volume operation failed: {0}")]
458 Other(String),
459}
460
461/// The operator-facing persistent-volume reclamation capability, backing
462/// `GET /api/compute/volumes` + `DELETE /api/compute/volumes/{name}` and the
463/// `boatramp compute volume` subcommand. The node implementation lists the
464/// volume-capable backends' on-node volumes, flags which are still referenced by
465/// a registered workload's spec (in use vs orphaned), and refuses to remove an
466/// in-use volume unless forced. Admin-scoped at the API (the deny-safe
467/// `/api/compute/*` default).
468#[async_trait]
469pub trait ComputeVolumes: Send + Sync {
470 /// List every persistent volume on this node, each flagged with whether a
471 /// registered workload's active spec still references it (`in_use`).
472 async fn list(&self) -> Result<Vec<VolumeStatus>, VolumeError>;
473
474 /// Remove the backing for volume `name`. Refuses with [`VolumeError::InUse`]
475 /// when a registered workload's spec still references it, unless `force`.
476 /// Returns whether the volume existed (`false` ⇒ `404` at the API).
477 async fn remove(&self, name: &str, force: bool) -> Result<bool, VolumeError>;
478}
479
480/// A persistent volume plus whether a registered workload's spec still references
481/// it — the `GET /api/compute/volumes` row.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct VolumeStatus {
484 /// The underlying volume (name + on-disk size).
485 #[serde(flatten)]
486 pub info: VolumeInfo,
487 /// Whether a registered workload's active spec still mounts this volume (a
488 /// running/relaunching replica depends on it). Removal of an in-use volume is
489 /// refused unless forced.
490 pub in_use: bool,
491}
492
493// ---------------------------------------------------------------------------
494// Backend selection policy
495// ---------------------------------------------------------------------------
496
497/// Per-site/tenant backend policy: which backends a workload may use. Default
498/// permits any backend; `force` pins one (overrides allow/forbid).
499#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
500#[serde(default, deny_unknown_fields)]
501pub struct BackendPolicy {
502 /// If set, only these backend ids are permitted.
503 #[serde(skip_serializing_if = "Option::is_none")]
504 pub allow: Option<Vec<String>>,
505 /// Backend ids that are never permitted.
506 #[serde(skip_serializing_if = "Vec::is_empty")]
507 pub forbid: Vec<String>,
508 /// If set, the only permitted backend (e.g. force `vmm` for a tenant).
509 #[serde(skip_serializing_if = "Option::is_none")]
510 pub force: Option<String>,
511 /// Require a **strong** isolation class (VM/platform) for every placement,
512 /// making shared-kernel backends (native namespace / Docker) ineligible even
513 /// for a workload that only declares `Trusted`. Set by the
514 /// operator security posture (`!allow_shared_kernel_compute`); default `false`
515 /// preserves the prior behavior. Closes the "misclassified workload lands on
516 /// a weak backend" gap.
517 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
518 pub require_strong_isolation: bool,
519}
520
521impl BackendPolicy {
522 /// Whether backend `id` is permitted by this policy.
523 pub fn permits(&self, id: &str) -> bool {
524 if let Some(force) = &self.force {
525 return id == force;
526 }
527 if self.forbid.iter().any(|x| x == id) {
528 return false;
529 }
530 match &self.allow {
531 Some(allow) => allow.iter().any(|x| x == id),
532 None => true,
533 }
534 }
535
536 /// The placement policy implied by a security posture's shared-kernel stance.
537 /// When shared-kernel compute is disallowed (a strict posture), only
538 /// **strong-isolation** (VM/platform) backends are eligible — so a workload
539 /// that only declares `Trusted` still cannot land on a native-namespace /
540 /// Docker backend. `allow_shared_kernel = true` yields the default permissive
541 /// policy. The single source of truth for the mapping the serve + cluster
542 /// paths apply (previously inlined + duplicated in the binary).
543 pub fn from_shared_kernel_allowed(allow_shared_kernel: bool) -> Self {
544 Self {
545 require_strong_isolation: !allow_shared_kernel,
546 ..Default::default()
547 }
548 }
549}
550
551// ---------------------------------------------------------------------------
552// Scheduler (backend-aware placement)
553// ---------------------------------------------------------------------------
554
555/// A backend a node offers, with the capabilities the scheduler gates placement
556/// on: the isolation class it provides, plus whether it can back persistent
557/// volumes and scale a workload to zero. Populated from the backend's
558/// [`Capabilities`] at node advertisement.
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct BackendKind {
561 /// Backend id (`"vmm"`, …).
562 pub id: String,
563 /// Isolation class this backend provides on this node.
564 pub isolation: IsolationClass,
565 /// Whether this backend can attach the spec's persistent volumes. A spec with
566 /// `volumes` placed on a `false` backend would run storage-less (silent data
567 /// loss), so the scheduler treats it as ineligible.
568 pub persistent_volumes: bool,
569 /// Whether this backend can scale a workload to zero. A `scale_to_zero` spec on
570 /// a `false` backend would run always-on (a silently missed cost optimization),
571 /// so the scheduler treats it as ineligible rather than surprise the operator.
572 pub scale_to_zero: bool,
573}
574
575/// A node's advertised capacity, attributes, and the backends it offers
576/// (from cluster membership). The scheduler receives a snapshot.
577#[derive(Debug, Clone, PartialEq, Eq)]
578pub struct Node {
579 /// Cluster node id.
580 pub id: u64,
581 /// Region, for placement constraints.
582 pub region: Option<String>,
583 /// Advertised labels, for placement constraints.
584 pub labels: BTreeMap<String, String>,
585 /// Free vCPUs.
586 pub free_vcpus: u32,
587 /// Free memory in MiB.
588 pub free_mem_mib: u32,
589 /// Backends this node can run a replica on.
590 pub backends: Vec<BackendKind>,
591}
592
593impl Node {
594 /// The backend to use for `spec` on this node, honoring the spec's preferred
595 /// backend, the isolation requirement, and the policy. `None` ⇒ no eligible
596 /// backend here.
597 fn pick_backend(&self, spec: &ComputeSpec, policy: &BackendPolicy) -> Option<String> {
598 let eligible = |b: &BackendKind| {
599 policy.permits(&b.id)
600 && b.isolation.satisfies(spec.isolation)
601 // Strict posture: only strong isolation, regardless of the spec's
602 // (possibly misclassified) requirement.
603 && (!policy.require_strong_isolation || b.isolation.is_strong())
604 // A volume spec needs a volume-capable backend — else it would run
605 // storage-less (silent data loss). No capable backend ⇒ no placement.
606 && (spec.volumes.is_empty() || b.persistent_volumes)
607 // A scale-to-zero spec needs a scale-to-zero-capable backend — else it
608 // silently runs always-on. Fail loud (no placement) instead.
609 && (!spec.scale_to_zero || b.scale_to_zero)
610 };
611 if let Some(pref) = &spec.prefer_backend {
612 if let Some(b) = self.backends.iter().find(|b| &b.id == pref && eligible(b)) {
613 return Some(b.id.clone());
614 }
615 }
616 self.backends
617 .iter()
618 .find(|b| eligible(b))
619 .map(|b| b.id.clone())
620 }
621}
622
623/// One placed replica: the node + the backend chosen for it.
624#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct Placement {
626 /// Chosen node id.
627 pub node: u64,
628 /// Chosen backend id.
629 pub backend: String,
630}
631
632/// Place `count` replicas of `spec` (subject to `placement` + `policy`) across
633/// `nodes`. Eligibility = satisfies the placement constraints, currently fits
634/// the spec's CPU/mem, **and** offers a policy-allowed backend whose isolation
635/// satisfies the spec. Worst-fit (most-free node first) spreads load; capacity is
636/// decremented per placement. Returns fewer than `count` when capacity/eligible
637/// backends run out (the caller surfaces "insufficient capacity").
638pub fn place_replicas(
639 count: u32,
640 placement: &PlacementConstraints,
641 spec: &ComputeSpec,
642 nodes: &[Node],
643 policy: &BackendPolicy,
644) -> Vec<Placement> {
645 let need_cpu = spec.vcpus.max(1);
646 let need_mem = spec.mem_mib.max(1);
647
648 // Working copy: (id, free_cpu, free_mem, the node) for placement-eligible nodes.
649 let mut free: Vec<(u64, u32, u32, &Node)> = nodes
650 .iter()
651 .filter(|n| placement.allows(n.region.as_deref(), &n.labels))
652 .map(|n| (n.id, n.free_vcpus, n.free_mem_mib, n))
653 .collect();
654
655 let mut placements = Vec::new();
656 for _ in 0..count {
657 // Worst-fit among nodes that fit AND have an eligible backend.
658 let pick = free
659 .iter_mut()
660 .filter(|(_, c, m, n)| {
661 *c >= need_cpu && *m >= need_mem && n.pick_backend(spec, policy).is_some()
662 })
663 .max_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)));
664 match pick {
665 Some(slot) => {
666 let backend = slot
667 .3
668 .pick_backend(spec, policy)
669 .expect("filtered to nodes with an eligible backend");
670 placements.push(Placement {
671 node: slot.0,
672 backend,
673 });
674 slot.1 -= need_cpu;
675 slot.2 -= need_mem;
676 }
677 None => break, // no node can fit another eligible replica
678 }
679 }
680 placements
681}
682
683// ---------------------------------------------------------------------------
684// Reconcile planner (pure)
685// ---------------------------------------------------------------------------
686
687/// The lifecycle phase of an observed replica.
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
689pub enum ReplicaPhase {
690 /// Launched + serving (the normal phase; also the back-compat default).
691 #[default]
692 Running,
693 /// **Scaled to zero**: snapshotted + stopped to free node resources;
694 /// resumable from its [`ObservedInstance::snapshot`] on the next activity.
695 Zero,
696}
697
698/// An observed replica (the persisted control-plane state at
699/// `compute_state/<workload>/<replica>`; also the gateway's upstream source).
700/// Usually [`Running`](ReplicaPhase::Running); a scale-to-zero replica persists
701/// in the [`Zero`](ReplicaPhase::Zero) phase carrying its snapshot.
702#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
703pub struct ObservedInstance {
704 /// The replica's handle.
705 pub handle: InstanceHandle,
706 /// The node it runs on (for `Zero`, the node holding its snapshot — restore
707 /// is same-node until live migration lands).
708 pub node: u64,
709 /// The backend that runs it.
710 pub backend: String,
711 /// The endpoint the gateway routes to (the last-known endpoint while `Zero`).
712 pub endpoint: Endpoint,
713 /// The region of the node this replica runs on, denormalized from
714 /// [`Node::region`] at launch so the gateway's nearest-replica LB (FA-8) can
715 /// tag the replica's endpoint without a node lookup. `#[serde(default)]` keeps
716 /// older records (no field) deserializing — schema stays v1.
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub region: Option<String>,
719 /// Whether the last health check passed (always `false` while `Zero`).
720 pub healthy: bool,
721 /// Wall-clock unix seconds the replica was launched, set by `launch_one`. The
722 /// reconcile loop uses it to give a freshly launched replica a startup grace (see
723 /// [`ComputeSpec::startup_grace_secs`]) before treating a `Running`-but-unhealthy
724 /// replica as a broken launch to stop + relaunch — so a slow-initializing image is
725 /// not killed mid-init. `None` (older records, or a restored replica) is treated as
726 /// past-grace, preserving the prior immediate-relaunch behavior. `#[serde(default)]`
727 /// keeps older records deserializing — schema stays v1.
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub started_at: Option<u64>,
730 /// Lifecycle phase. `#[serde(default)]` keeps older records (no field)
731 /// deserializing as [`Running`](ReplicaPhase::Running) — schema stays v1.
732 #[serde(default)]
733 pub phase: ReplicaPhase,
734 /// The snapshot to restore from — `Some` iff `phase == Zero`.
735 #[serde(default)]
736 pub snapshot: Option<Snapshot>,
737}
738
739/// The observed-state key for a workload's replica, **project-scoped** (0.2.0):
740/// `project/<proj>/compute_state/<workload>/<replica>`.
741pub fn replica_state_key(project: &str, workload: &str, replica: u32) -> String {
742 format!("project/{project}/compute_state/{workload}/{replica}")
743}
744
745/// The key prefix listing one workload's replica states within a project.
746pub fn replica_state_prefix(project: &str, workload: &str) -> String {
747 format!("project/{project}/compute_state/{workload}/")
748}
749
750/// The key prefix listing **every** replica state in a project (all workloads).
751pub fn replica_states_project_prefix(project: &str) -> String {
752 format!("project/{project}/compute_state/")
753}
754
755/// A reconcile action the driver executes against a backend.
756#[derive(Debug, Clone, PartialEq, Eq)]
757pub enum Action {
758 /// Launch a new replica at `(node, backend)`.
759 Launch {
760 /// Workload name.
761 workload: String,
762 /// Replica ordinal to launch.
763 replica: u32,
764 /// Chosen node.
765 node: u64,
766 /// Chosen backend.
767 backend: String,
768 },
769 /// Stop a replica.
770 Stop {
771 /// The replica to stop.
772 handle: InstanceHandle,
773 },
774 /// **Sleep** a running replica for scale-to-zero: snapshot it, stop
775 /// it, and persist it in the [`Zero`](ReplicaPhase::Zero) phase.
776 Snapshot {
777 /// The running replica to snapshot + stop.
778 handle: InstanceHandle,
779 },
780 /// **Wake** a zeroed replica: restore it from its snapshot.
781 Restore {
782 /// The snapshot to restore.
783 snapshot: Snapshot,
784 /// The node to restore onto (same node that holds the snapshot).
785 node: u64,
786 /// The backend that owns the snapshot.
787 backend: String,
788 },
789}
790
791/// A workload's recent traffic, the input that drives scale-to-zero decisions.
792/// Sourced from the gateway; the reconcile loop treats it as opaque.
793#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
794pub enum WorkloadActivity {
795 /// Recent traffic (or unknown) — keep running, and **wake** if zeroed. The
796 /// default, so the loop never sleeps a workload absent a real idle signal.
797 #[default]
798 Active,
799 /// Idle past the scale-to-zero threshold — eligible to **sleep**.
800 Idle,
801}
802
803/// Compute the actions to converge `workload` (running `spec`) from `observed`
804/// to its desired replica count, honoring placement, the isolation requirement,
805/// and the backend `policy`. Pure: no IO, fully unit-tested.
806///
807/// Rules: replicas are addressed by ordinal `0..replicas`. A *healthy* in-range
808/// replica is kept. An out-of-range replica (scaled down) is **stopped**. An
809/// *unhealthy* in-range replica is **stopped** and its ordinal relaunched —
810/// unless (a) the restart policy is `Never`, in which case it is left as a terminal
811/// (completed) instance and not relaunched, or (b) it is still within its **startup
812/// grace**: a `Running`-but-unhealthy replica whose `started_at` is within
813/// [`ComputeSpec::startup_grace_secs`] of `now` is treated as **starting** — left
814/// alone (no Stop, no duplicate Launch), so a slow-initializing image (a stock
815/// database's first `initdb`) is not killed mid-init into a crash loop. A replica with
816/// `started_at == None` (older records / restored) is treated as past-grace, preserving
817/// the prior immediate stop + relaunch. Free ordinals are placed onto eligible nodes;
818/// if capacity runs out, fewer launches are emitted.
819///
820/// `now` is the current wall-clock unix seconds, compared against each replica's
821/// `started_at` for the startup-grace check.
822#[allow(clippy::too_many_arguments)]
823pub fn reconcile_plan(
824 workload: &ComputeWorkload,
825 spec: &ComputeSpec,
826 nodes: &[Node],
827 policy: &BackendPolicy,
828 observed: &[ObservedInstance],
829 activity: WorkloadActivity,
830 caps: &BTreeMap<String, Capabilities>,
831 now: u64,
832) -> Vec<Action> {
833 let desired = workload.replicas;
834 let mut actions = Vec::new();
835
836 // Scale-to-zero is in effect only when the workload opts in *and* the
837 // replica's backend advertises the capability.
838 let sleeps =
839 |backend: &str| spec.scale_to_zero && caps.get(backend).is_some_and(|c| c.scale_to_zero);
840
841 // Classify this workload's observed replicas by ordinal.
842 let mut healthy: BTreeSet<u32> = BTreeSet::new();
843 let mut terminal: BTreeSet<u32> = BTreeSet::new(); // Never + exited → done, don't relaunch
844 let mut zeroed: BTreeSet<u32> = BTreeSet::new(); // scaled-to-zero → wake on activity, never relaunch
845 let mut starting: BTreeSet<u32> = BTreeSet::new(); // launched, still within its startup grace → leave alone
846 for inst in observed
847 .iter()
848 .filter(|i| i.handle.workload == workload.name)
849 {
850 let ord = inst.handle.replica;
851 if ord >= desired {
852 // Out of range (also discards a Zero replica's snapshot — Stop is
853 // idempotent and the driver forgets the state).
854 actions.push(Action::Stop {
855 handle: inst.handle.clone(),
856 });
857 } else if inst.phase == ReplicaPhase::Zero {
858 zeroed.insert(ord);
859 // Wake on activity; otherwise stay parked.
860 if matches!(activity, WorkloadActivity::Active) {
861 if let Some(snapshot) = inst.snapshot.clone() {
862 actions.push(Action::Restore {
863 snapshot,
864 node: inst.node,
865 backend: inst.backend.clone(),
866 });
867 }
868 }
869 } else if inst.healthy {
870 healthy.insert(ord);
871 // Sleep on sustained idle (opt-in + capable backend).
872 if matches!(activity, WorkloadActivity::Idle) && sleeps(&inst.backend) {
873 actions.push(Action::Snapshot {
874 handle: inst.handle.clone(),
875 });
876 }
877 } else if matches!(spec.restart, RestartPolicy::Never) {
878 terminal.insert(ord); // run-to-completion: leave it, don't replace
879 } else if inst
880 .started_at
881 .is_some_and(|t| now.saturating_sub(t) < spec.startup_grace_secs as u64)
882 {
883 // Launched but still within its startup grace: it's *starting*, not broken.
884 // Leave it alone — don't Stop it (that would kill a slow first `initdb`
885 // mid-init) and don't relaunch its ordinal (excluded from `need` below).
886 // A missing `started_at` (older record / restored) falls through to the
887 // stop + relaunch arm, preserving the prior behavior.
888 starting.insert(ord);
889 } else {
890 actions.push(Action::Stop {
891 handle: inst.handle.clone(),
892 });
893 // ordinal becomes free below → relaunched
894 }
895 }
896
897 // Ordinals in range that need a (re)launch — excluding intentionally parked
898 // (Zero) replicas, which wake via Restore rather than a fresh Launch, and
899 // *starting* replicas, which are converging within their startup grace.
900 let need: Vec<u32> = (0..desired)
901 .filter(|ord| {
902 !healthy.contains(ord)
903 && !terminal.contains(ord)
904 && !zeroed.contains(ord)
905 && !starting.contains(ord)
906 })
907 .collect();
908 if need.is_empty() {
909 return actions;
910 }
911
912 // Place the needed count; zip ordinals with placements (a capacity shortfall
913 // simply leaves the tail unplaced — the caller logs it).
914 let placements = place_replicas(need.len() as u32, &workload.placement, spec, nodes, policy);
915 for (ord, place) in need.iter().zip(placements) {
916 actions.push(Action::Launch {
917 workload: workload.name.clone(),
918 replica: *ord,
919 node: place.node,
920 backend: place.backend,
921 });
922 }
923 actions
924}
925
926// ---------------------------------------------------------------------------
927// Reconcile driver (async — drives the backends to converge desired state)
928// ---------------------------------------------------------------------------
929
930/// The execution backends available to the reconcile loop, keyed by
931/// [`ComputeBackend::id`].
932pub type BackendRegistry = BTreeMap<String, Arc<dyn ComputeBackend>>;
933
934/// Where the reconcile loop reads each workload's recent traffic to drive
935/// scale-to-zero (sleep idle replicas / wake them on demand). The real source is
936/// the gateway's per-workload activity, aggregated across the cluster;
937/// [`AlwaysActive`] is the production-safe default until that lands — it never
938/// sleeps a workload, so scale-to-zero stays inert.
939#[async_trait]
940pub trait ActivitySource: Send + Sync {
941 /// The workload's current activity (queried once per reconcile pass).
942 async fn activity(&self, workload: &str) -> WorkloadActivity;
943}
944
945/// The default [`ActivitySource`]: every workload is [`Active`](WorkloadActivity::Active),
946/// so nothing is ever scaled to zero.
947pub struct AlwaysActive;
948
949#[async_trait]
950impl ActivitySource for AlwaysActive {
951 async fn activity(&self, _workload: &str) -> WorkloadActivity {
952 WorkloadActivity::Active
953 }
954}
955
956/// What one reconcile pass did (for logging + tests).
957#[derive(Debug, Default, Clone, PartialEq, Eq)]
958pub struct ReconcileReport {
959 /// Replicas launched this pass.
960 pub launched: usize,
961 /// Replicas stopped this pass.
962 pub stopped: usize,
963 /// Replicas slept (snapshotted + stopped → Zero) this pass.
964 pub slept: usize,
965 /// Replicas woken (restored from a snapshot) this pass.
966 pub woke: usize,
967 /// Per-action failures (the pass continues past them; retried next tick).
968 pub errors: Vec<String>,
969}
970
971/// Resolves a workload's declared [`ComputeBinding`]s (PLAN-compute-bindings) to
972/// env vars injected into the guest at launch, registering any backing shim state.
973/// The concrete impl (the sql-shim resolver) lives server-side, where the
974/// `SqlBackends` provider + the shim listener are; the reconcile only calls this
975/// trait. All methods are keyed by `(project, workload, replica)` and **idempotent**,
976/// so the reconcile can call `resolve` for every running replica each tick to keep
977/// the shim registry populated across a restart.
978#[async_trait]
979pub trait ComputeBindingResolver: Send + Sync {
980 /// Resolve `bindings` to `(env_key, env_value)` pairs to inject into the guest,
981 /// registering the shim state for `(project, workload, replica)`.
982 async fn resolve(
983 &self,
984 project: &str,
985 workload: &str,
986 replica: u32,
987 bindings: &[ComputeBinding],
988 ) -> Vec<(String, String)>;
989
990 /// Release the shim state for a torn-down replica.
991 async fn release(
992 &self,
993 project: &str,
994 workload: &str,
995 replica: u32,
996 bindings: &[ComputeBinding],
997 );
998}
999
1000/// Injects **server-initialization env** (`POSTGRES_*` / `MYSQL_*`) into a compute
1001/// workload that a handler `sql` binding manages (PLAN-managed-compute-sql, Phase
1002/// 2). The reverse of [`ComputeBindingResolver`]: that wires a *guest* to reach
1003/// boatramp's shims; this wires boatramp's managed **credential** into a *database
1004/// server* the guest then connects to. Given `(project, workload)` it returns the
1005/// env the DB image reads on first boot to create boatramp's user/password/database
1006/// — empty when `workload` is not a managed database. **Idempotent**: the credential
1007/// is generated once + sealed, then stable, so it is safe to call on every launch
1008/// (the DB, initialized with it, keeps accepting the same password across restarts).
1009/// The concrete impl lives in `boatramp-node`, where the handler sql config + the
1010/// sealed-credential store are; the reconcile only calls this trait.
1011#[async_trait]
1012pub trait ManagedDbEnvResolver: Send + Sync {
1013 /// Server-init env for `workload` if it is a managed database, else empty.
1014 async fn managed_db_env(&self, project: &str, workload: &str) -> Vec<(String, String)>;
1015
1016 /// The privilege strategy that lets `workload`'s stock DB image initialize on a
1017 /// shared-kernel backend, or `None` if `workload` is not a managed database.
1018 /// Sync + defaulted so a non-DB resolver needs no change. The reconcile applies it
1019 /// to the **launch** spec only (never the stored one), and only when the operator
1020 /// has not already set `user`/`cap_add`.
1021 fn managed_db_privilege(&self, _project: &str, _workload: &str) -> Option<PrivilegeDirective> {
1022 None
1023 }
1024}
1025
1026/// How a managed database is made able to initialize its stock image on a shared-kernel
1027/// backend despite the dropped-`ALL` default. Applied to the launch spec by the
1028/// reconcile (see [`ManagedDbEnvResolver::managed_db_privilege`]).
1029#[derive(Debug, Clone, PartialEq, Eq)]
1030pub enum PrivilegeDirective {
1031 /// Run rootless as `uid:gid` (the image's DB user) against its pre-owned volume —
1032 /// needs no capabilities and works under any posture. The preferred default.
1033 Rootless { uid: u32, gid: u32 },
1034 /// Grant these capabilities back (short names, no `CAP_` prefix). Single-tenant
1035 /// only — the backend's posture gate drops them under the multi-tenant guard.
1036 Caps(Vec<String>),
1037}
1038
1039impl PrivilegeDirective {
1040 /// Apply this directive to a **launch** `spec`, without overriding a value the
1041 /// operator set explicitly (an operator `user`/`cap_add` always wins).
1042 pub fn apply(&self, spec: &mut ComputeSpec) {
1043 match self {
1044 Self::Rootless { uid, gid } if spec.user.is_none() => {
1045 spec.user = Some(format!("{uid}:{gid}"));
1046 }
1047 Self::Caps(caps) if spec.cap_add.is_empty() => {
1048 spec.cap_add = caps.clone();
1049 }
1050 _ => {}
1051 }
1052 }
1053}
1054
1055/// The engine of a managed co-located database, selecting the stock OCI image, TCP
1056/// port, in-guest data directory, and entrypoint that [`managed_db_spec`]
1057/// synthesizes for it.
1058#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059pub enum ManagedDbEngine {
1060 /// PostgreSQL — the pgvector image by default (a superset of the official
1061 /// image, so `create extension vector` works out of the box).
1062 Postgres,
1063 /// MySQL — the official image.
1064 Mysql,
1065}
1066
1067impl ManagedDbEngine {
1068 /// The default stock OCI image for this engine.
1069 pub fn default_image(self) -> &'static str {
1070 match self {
1071 Self::Postgres => "pgvector/pgvector:pg16",
1072 Self::Mysql => "mysql:8.0",
1073 }
1074 }
1075
1076 /// The TCP port the server listens on.
1077 pub fn port(self) -> u16 {
1078 match self {
1079 Self::Postgres => 5432,
1080 Self::Mysql => 3306,
1081 }
1082 }
1083
1084 /// The in-guest data directory that must be backed by a persistent volume.
1085 pub fn data_dir(self) -> &'static str {
1086 match self {
1087 Self::Postgres => "/var/lib/postgresql/data",
1088 Self::Mysql => "/var/lib/mysql",
1089 }
1090 }
1091}
1092
1093/// Synthesize the immutable [`ComputeSpec`] for a managed co-located database from a
1094/// stock engine image, so the auto-registration path (node assembly) and the
1095/// capability gate build the **identical** workload — the gate proves exactly what
1096/// ships (the managed-DB spec never diverges from the tested one).
1097///
1098/// The spec carries only non-secret, image-shaping fields: the OCI image, the TCP
1099/// port, an **explicit entrypoint** (the shared-kernel backends apply the image's
1100/// filesystem, not its OCI config, so the argv plus `listen_addresses`/`bind-address`
1101/// must be supplied so the server answers on the container's bridge IP), the
1102/// non-secret `PATH`/data-dir env, and one persistent volume at the data directory.
1103/// The server-init credential env (`POSTGRES_*`/`MYSQL_*`) is injected at **launch**
1104/// from the sealed credential — never stored in this content-addressed spec. `user`
1105/// and `cap_add` are left unset so the reconcile's [`PrivilegeDirective`] (rootless
1106/// by default) sets them per posture, keeping the rootless default intact.
1107pub fn managed_db_spec(
1108 engine: ManagedDbEngine,
1109 image: Option<&str>,
1110 volume_size_mib: u32,
1111) -> ComputeSpec {
1112 // A stock database's *first* boot runs `initdb` before it opens its port, so the
1113 // reconcile loop must not mistake a still-initializing container for a broken
1114 // launch and kill it into a crash loop. These per-engine graces bound that window
1115 // (Postgres `initdb` ~10–30s; MySQL's first-boot bootstrap is markedly slower,
1116 // 60s+). Generic compute keeps the smaller `default_startup_grace_secs()` (30).
1117 const POSTGRES_STARTUP_GRACE_SECS: u32 = 60;
1118 const MYSQL_STARTUP_GRACE_SECS: u32 = 120;
1119
1120 let startup_grace_secs = match engine {
1121 ManagedDbEngine::Postgres => POSTGRES_STARTUP_GRACE_SECS,
1122 ManagedDbEngine::Mysql => MYSQL_STARTUP_GRACE_SECS,
1123 };
1124 let data_dir = engine.data_dir();
1125 let (entrypoint, env) = match engine {
1126 ManagedDbEngine::Postgres => (
1127 vec![
1128 "/usr/local/bin/docker-entrypoint.sh".to_string(),
1129 "postgres".to_string(),
1130 "-c".to_string(),
1131 "listen_addresses=*".to_string(),
1132 ],
1133 BTreeMap::from([
1134 (
1135 "PATH".to_string(),
1136 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:\
1137 /usr/lib/postgresql/16/bin"
1138 .to_string(),
1139 ),
1140 ("PGDATA".to_string(), data_dir.to_string()),
1141 ]),
1142 ),
1143 ManagedDbEngine::Mysql => (
1144 vec![
1145 "/usr/local/bin/docker-entrypoint.sh".to_string(),
1146 "mysqld".to_string(),
1147 "--bind-address=*".to_string(),
1148 ],
1149 BTreeMap::from([(
1150 "PATH".to_string(),
1151 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
1152 )]),
1153 ),
1154 };
1155 ComputeSpec {
1156 version: 1,
1157 root: RootSource::Image(image.unwrap_or_else(|| engine.default_image()).to_string()),
1158 kernel: String::new(),
1159 kernel_cmdline: None,
1160 vcpus: 1,
1161 mem_mib: 512,
1162 entrypoint,
1163 env,
1164 port: engine.port(),
1165 restart: RestartPolicy::Always,
1166 startup_grace_secs,
1167 scale_to_zero: false,
1168 volumes: vec![VolumeRef {
1169 mount: data_dir.to_string(),
1170 name: "data".to_string(),
1171 size_mib: volume_size_mib,
1172 }],
1173 writable_root: false,
1174 cap_add: Vec::new(),
1175 user: None,
1176 isolation: IsolationRequirement::Trusted,
1177 prefer_backend: None,
1178 bindings: vec![],
1179 }
1180}
1181
1182/// One reconcile pass: for every workload, refresh replica health, compute the
1183/// plan ([`reconcile_plan`]), and execute it against the chosen backends —
1184/// launching/stopping replicas and persisting their observed state (which the
1185/// gateway reads as the upstream pool). Per-action failures are collected (not
1186/// fatal) so one bad workload can't stall the rest; a top-level KV failure
1187/// aborts the pass. The caller leader-gates this (cron-style).
1188///
1189/// For now the chosen backend is invoked locally (the leader also runs it).
1190/// Cross-node dispatch via messaging is a later refinement.
1191pub async fn reconcile_once(
1192 deploy: &DeployStore,
1193 backends: &BackendRegistry,
1194 nodes: &[Node],
1195 policy: &BackendPolicy,
1196 activity: &dyn ActivitySource,
1197 resolver: Option<&dyn ComputeBindingResolver>,
1198 managed_db: Option<&dyn ManagedDbEnvResolver>,
1199) -> Result<ReconcileReport, crate::error::DeployError> {
1200 let mut report = ReconcileReport::default();
1201 // Per-backend capabilities (the planner gates scale-to-zero on them).
1202 let caps: BTreeMap<String, Capabilities> = backends
1203 .iter()
1204 .map(|(id, b)| (id.clone(), b.capabilities()))
1205 .collect();
1206 // The `(project, workload, replica)` keys that are intentionally **parked**
1207 // (scale-to-zero `Zero`), accumulated across every project. A parked replica keeps
1208 // its IP reserved for the wake, so the post-loop pool-vs-reality GC (A2) must never
1209 // reclaim it — it feeds this set to each backend's `gc_ip_pool`.
1210 let mut parked_keys: Vec<(String, String, u32)> = Vec::new();
1211 // Fan out over every project's workloads (compute is project-scoped in 0.2.0).
1212 // The owning project threads through every replica-state read/write below.
1213 for (project_name, workload) in deploy.list_compute_workloads_all().await? {
1214 let project = ProjectRef::new(&project_name);
1215 let Some(spec) = deploy.get_compute_spec(&workload.active).await? else {
1216 report
1217 .errors
1218 .push(format!("{}: active spec missing", workload.name));
1219 continue;
1220 };
1221
1222 // Observed replica state + a health refresh (skipping parked Zero
1223 // replicas — they're intentionally down).
1224 let mut observed = deploy.list_replica_states(project, &workload.name).await?;
1225 for state in &mut observed {
1226 if state.phase == ReplicaPhase::Zero {
1227 // Record the parked replica so the A2 GC keeps its IP reserved.
1228 parked_keys.push((
1229 project_name.clone(),
1230 state.handle.workload.clone(),
1231 state.handle.replica,
1232 ));
1233 continue;
1234 }
1235 if let Some(backend) = backends.get(&state.backend) {
1236 if let Ok(health) = backend.health(&state.handle).await {
1237 let now_healthy = matches!(health, Health::Healthy);
1238 // PERSIST a health transition. The endpoint resolver reads `healthy`
1239 // from the STORE, not from this in-memory refresh — so a replica that
1240 // becomes reachable *after* launch must have that recovery written
1241 // back. `launch_one` probes readiness *before* the guest binds its
1242 // port and persists `healthy: false`; without persisting the refresh,
1243 // that pre-bind `false` sticks forever and the resolver reports "no
1244 // healthy replica" for a perfectly reachable workload (a running
1245 // healthy replica needs no Launch/Stop action, so nothing else writes
1246 // it back). Only write on a change to keep the reconcile cheap.
1247 if now_healthy != state.healthy {
1248 state.healthy = now_healthy;
1249 if let Err(e) = deploy.set_replica_state(project, state).await {
1250 report.errors.push(format!(
1251 "{}/{}: persist health: {e}",
1252 state.handle.workload, state.handle.replica
1253 ));
1254 }
1255 }
1256 }
1257 }
1258 }
1259
1260 // Keep the shim registry populated for every running replica (idempotent),
1261 // so a workload's bindings keep working across a server restart while the
1262 // guest is still up.
1263 if let Some(resolver) = resolver {
1264 if !spec.bindings.is_empty() {
1265 for state in &observed {
1266 if state.phase == ReplicaPhase::Running {
1267 resolver
1268 .resolve(
1269 &project_name,
1270 &workload.name,
1271 state.handle.replica,
1272 &spec.bindings,
1273 )
1274 .await;
1275 }
1276 }
1277 }
1278 }
1279
1280 let workload_activity = activity.activity(&workload.name).await;
1281 for action in reconcile_plan(
1282 &workload,
1283 &spec,
1284 nodes,
1285 policy,
1286 &observed,
1287 workload_activity,
1288 &caps,
1289 crate::time::now_unix(),
1290 ) {
1291 match action {
1292 Action::Launch {
1293 workload: wl,
1294 replica,
1295 node,
1296 backend,
1297 } => {
1298 let Some(b) = backends.get(&backend) else {
1299 report
1300 .errors
1301 .push(format!("{wl}/{replica}: no backend {backend:?}"));
1302 continue;
1303 };
1304 let node_region = region_of_node(nodes, node);
1305 // Resolve declared bindings → env injected into the guest (registers
1306 // the shim token for this replica).
1307 let mut launch_env = match resolver {
1308 Some(r) if !spec.bindings.is_empty() => {
1309 r.resolve(&project_name, &wl, replica, &spec.bindings).await
1310 }
1311 _ => Vec::new(),
1312 };
1313 // If this workload is a managed database, inject its server-init
1314 // env (POSTGRES_*/MYSQL_*) from the sealed managed credential, so it
1315 // initializes on first boot with the user/password the handler will
1316 // connect as. Empty for a non-managed workload (idempotent).
1317 if let Some(m) = managed_db {
1318 launch_env.extend(m.managed_db_env(&project_name, &wl).await);
1319 }
1320 // A managed DB also gets a privilege strategy (rootless user or a
1321 // cap allowlist) so its stock image can init on a shared-kernel
1322 // backend; applied to the launch spec only, and only where the
1323 // operator has not set `user`/`cap_add` already.
1324 let privilege =
1325 managed_db.and_then(|m| m.managed_db_privilege(&project_name, &wl));
1326 match launch_one(
1327 b.as_ref(),
1328 &project_name,
1329 &wl,
1330 replica,
1331 node,
1332 node_region,
1333 &spec,
1334 &launch_env,
1335 privilege.as_ref(),
1336 )
1337 .await
1338 {
1339 Ok(state) => match deploy.set_replica_state(project, &state).await {
1340 Ok(()) => report.launched += 1,
1341 Err(e) => report.errors.push(format!("{wl}/{replica}: persist: {e}")),
1342 },
1343 Err(e) => report.errors.push(format!("{wl}/{replica}: launch: {e}")),
1344 }
1345 }
1346 Action::Stop { handle } => {
1347 if let Some(b) = observed
1348 .iter()
1349 .find(|o| o.handle == handle)
1350 .and_then(|o| backends.get(&o.backend))
1351 {
1352 if let Err(e) = b.stop(&handle).await {
1353 report
1354 .errors
1355 .push(format!("{}/{}: stop: {e}", handle.workload, handle.replica));
1356 }
1357 }
1358 match deploy
1359 .delete_replica_state(project, &handle.workload, handle.replica)
1360 .await
1361 {
1362 Ok(()) => report.stopped += 1,
1363 Err(e) => report.errors.push(format!(
1364 "{}/{}: forget: {e}",
1365 handle.workload, handle.replica
1366 )),
1367 }
1368 // Revoke this replica's shim tokens.
1369 if let Some(resolver) = resolver {
1370 if !spec.bindings.is_empty() {
1371 resolver
1372 .release(
1373 &project_name,
1374 &handle.workload,
1375 handle.replica,
1376 &spec.bindings,
1377 )
1378 .await;
1379 }
1380 }
1381 }
1382 Action::Snapshot { handle } => {
1383 let Some(obs) = observed.iter().find(|o| o.handle == handle).cloned() else {
1384 continue; // vanished between plan + execute
1385 };
1386 let Some(b) = backends.get(&obs.backend) else {
1387 report.errors.push(format!(
1388 "{}/{}: no backend {:?}",
1389 handle.workload, handle.replica, obs.backend
1390 ));
1391 continue;
1392 };
1393 match b.snapshot(&handle).await {
1394 // Park it: persist the Zero phase carrying the snapshot
1395 // (the backend's `snapshot` already stopped the replica).
1396 Ok(Some(mut snapshot)) => {
1397 // Stamp the owning project on the parked snapshot so a later
1398 // `restore` derives the same project-qualified identity/IPAM
1399 // key the launch used (independent of the backend).
1400 snapshot.project = project_name.clone();
1401 let parked = ObservedInstance {
1402 healthy: false,
1403 phase: ReplicaPhase::Zero,
1404 snapshot: Some(snapshot),
1405 ..obs
1406 };
1407 match deploy.set_replica_state(project, &parked).await {
1408 Ok(()) => report.slept += 1,
1409 Err(e) => report.errors.push(format!(
1410 "{}/{}: persist zero: {e}",
1411 handle.workload, handle.replica
1412 )),
1413 }
1414 }
1415 // Backend declined (e.g. not running) — leave it as is.
1416 Ok(None) => {}
1417 Err(e) => report.errors.push(format!(
1418 "{}/{}: snapshot: {e}",
1419 handle.workload, handle.replica
1420 )),
1421 }
1422 }
1423 Action::Restore {
1424 snapshot,
1425 node,
1426 backend,
1427 } => {
1428 let Some(b) = backends.get(&backend) else {
1429 report.errors.push(format!(
1430 "{}/{}: no backend {backend:?}",
1431 snapshot.workload, snapshot.replica
1432 ));
1433 continue;
1434 };
1435 match b.restore(&snapshot).await {
1436 Ok(instance) => {
1437 // Stamp the owning project on the restored handle so the
1438 // persisted record stays consistent (the backend's restore
1439 // works off the snapshot ref alone).
1440 let mut handle = instance.handle;
1441 handle.project = snapshot.project.clone();
1442 let state = ObservedInstance {
1443 handle,
1444 node,
1445 backend: backend.clone(),
1446 endpoint: instance.endpoint,
1447 region: region_of_node(nodes, node),
1448 healthy: true,
1449 started_at: Some(crate::time::now_unix()),
1450 phase: ReplicaPhase::Running,
1451 snapshot: None,
1452 };
1453 match deploy.set_replica_state(project, &state).await {
1454 Ok(()) => report.woke += 1,
1455 Err(e) => report.errors.push(format!(
1456 "{}/{}: persist running: {e}",
1457 snapshot.workload, snapshot.replica
1458 )),
1459 }
1460 }
1461 Err(e) => report.errors.push(format!(
1462 "{}/{}: restore: {e}",
1463 snapshot.workload, snapshot.replica
1464 )),
1465 }
1466 }
1467 }
1468 }
1469 }
1470
1471 // A2 pool-vs-reality reconciliation: after converging every workload, ask each
1472 // backend to reclaim any IP it still holds for a replica whose container is gone
1473 // (crashed, or state removed out-of-band) and that is NOT intentionally parked
1474 // (`parked_keys`). Adoption reserves every persisted replica's IP at boot, and a
1475 // stop releases on the reconcile path — but a crash between reconciles would leak
1476 // the address until the backend restarts; this sweeps those. Conservative: a
1477 // backend reclaims only when it is sure the container is gone, and never a parked
1478 // replica (whose IP is held for its wake). Backends without a per-node pool no-op.
1479 for backend in backends.values() {
1480 backend.gc_ip_pool(&parked_keys).await;
1481 }
1482
1483 Ok(report)
1484}
1485
1486/// The region of node `id` in `nodes`, for tagging a replica's endpoint (FA-8).
1487fn region_of_node(nodes: &[Node], id: u64) -> Option<String> {
1488 nodes
1489 .iter()
1490 .find(|n| n.id == id)
1491 .and_then(|n| n.region.clone())
1492}
1493
1494/// Materialize + launch one replica, returning its observed state.
1495#[allow(clippy::too_many_arguments)]
1496async fn launch_one(
1497 backend: &dyn ComputeBackend,
1498 project: &str,
1499 workload: &str,
1500 replica: u32,
1501 node: u64,
1502 node_region: Option<String>,
1503 spec: &ComputeSpec,
1504 extra_env: &[(String, String)],
1505 privilege: Option<&PrivilegeDirective>,
1506) -> Result<ObservedInstance, BackendError> {
1507 // The launch wall-clock time, so the next reconcile tick can grant this replica a
1508 // startup grace before treating it as a broken launch (see `reconcile_plan`).
1509 let started_at = crate::time::now_unix();
1510 let artifact = backend.materialize(spec).await?;
1511 // Fold the resolved binding env into the launched spec. The workload's own env
1512 // wins on a collision, so a hand-set value is never clobbered by a binding.
1513 let mut spec = spec.clone();
1514 for (k, v) in extra_env {
1515 spec.env.entry(k.clone()).or_insert_with(|| v.clone());
1516 }
1517 // A managed-DB privilege strategy (rootless user / cap allowlist) — launch spec
1518 // only; never overrides an operator-set `user`/`cap_add`.
1519 if let Some(p) = privilege {
1520 p.apply(&mut spec);
1521 }
1522 let instance = backend
1523 .launch(&LaunchRequest {
1524 project: project.to_string(),
1525 workload: workload.to_string(),
1526 replica,
1527 spec: spec.clone(),
1528 artifact,
1529 })
1530 .await?;
1531 // Probe readiness right after launch instead of asserting `healthy: true`
1532 // unconditionally. A stock DB image (Postgres/MySQL) takes a moment to `initdb`
1533 // and open its port, and a *first* launch can also fail outright (e.g. a broken
1534 // gateway, a stale volume) yet still return a handle — so a blind `true` recorded
1535 // a broken replica as healthy, and nothing ever retried it: only an unrelated
1536 // process restart (whose health refresh finally observed it down → Stop → relaunch)
1537 // "fixed" it. Recording the *actual* readiness here means the very next reconcile
1538 // tick's health refresh + plan self-heals a broken first launch, no restart needed.
1539 //
1540 // The probe is bounded by the backend's own `health` timeout (a couple of seconds),
1541 // so it never stalls the reconcile; `Unknown`/`Unhealthy`/`Err` all record
1542 // `healthy: false` (the readiness is re-confirmed on the next tick regardless). The
1543 // phase stays `Running` — the replica IS launched — so the next tick treats a
1544 // still-unhealthy replica as a launched-but-unready one (Stop + relaunch) rather
1545 // than a missing one (spurious extra launch).
1546 let healthy = matches!(backend.health(&instance.handle).await, Ok(Health::Healthy));
1547 // The reconcile is the source of truth for the owning project; stamp it on the
1548 // returned handle so the persisted record always carries it (independent of the
1549 // backend) and the key/state stay consistent.
1550 let mut handle = instance.handle;
1551 handle.project = project.to_string();
1552 Ok(ObservedInstance {
1553 handle,
1554 node,
1555 backend: backend.id().to_string(),
1556 endpoint: instance.endpoint,
1557 region: node_region,
1558 healthy,
1559 started_at: Some(started_at),
1560 phase: ReplicaPhase::Running,
1561 snapshot: None,
1562 })
1563}
1564
1565#[cfg(test)]
1566mod tests {
1567 use super::*;
1568
1569 #[test]
1570 fn compute_instance_id_qualifies_by_project_but_keeps_default_bare() {
1571 // The default-project identity is BYTE-IDENTICAL to the pre-v0.3.12 bare
1572 // `<workload>-<replica>` (so existing deployments' cgroup/veth/hostname/IP
1573 // slot are undisturbed). An empty project string (a not-yet-backfilled legacy
1574 // handle) is treated as `default`, so it too stays bare.
1575 assert_eq!(compute_instance_id("default", "web", 0), "web-0");
1576 assert_eq!(compute_instance_id("", "web", 0), "web-0");
1577 assert_eq!(compute_instance_id("default", "api-v2", 3), "api-v2-3");
1578
1579 // A non-default project is qualified with the project prefix, so two projects'
1580 // same-named workloads derive DISTINCT ids (→ distinct cgroup/veth/hostname).
1581 assert_eq!(compute_instance_id("acme", "web", 0), "acme-web-0");
1582 assert_eq!(compute_instance_id("beta", "web", 0), "beta-web-0");
1583 assert_ne!(
1584 compute_instance_id("acme", "web", 0),
1585 compute_instance_id("beta", "web", 0),
1586 "same-named workloads in different projects must NOT collide on id"
1587 );
1588 // And the qualified form never collides with the bare default form.
1589 assert_ne!(
1590 compute_instance_id("acme", "web", 0),
1591 compute_instance_id("default", "web", 0)
1592 );
1593 }
1594
1595 #[test]
1596 fn managed_db_spec_is_launchable_and_privilege_deferred() {
1597 // Postgres: default image, port, one volume at the data dir, explicit
1598 // entrypoint (the shared-kernel backends don't apply OCI config), and
1599 // `user`/`cap_add` LEFT UNSET so the privilege directive owns them.
1600 let pg = managed_db_spec(ManagedDbEngine::Postgres, None, 2048);
1601 assert_eq!(pg.root, RootSource::Image("pgvector/pgvector:pg16".into()));
1602 assert_eq!(pg.port, 5432);
1603 assert!(pg.user.is_none(), "rootless directive sets user at launch");
1604 assert!(pg.cap_add.is_empty());
1605 assert!(matches!(pg.restart, RestartPolicy::Always));
1606 assert!(!pg.scale_to_zero, "a database must not snapshot when idle");
1607 assert_eq!(pg.volumes.len(), 1);
1608 assert_eq!(pg.volumes[0].mount, "/var/lib/postgresql/data");
1609 assert_eq!(pg.volumes[0].size_mib, 2048);
1610 assert!(pg.entrypoint.iter().any(|a| a == "listen_addresses=*"));
1611 assert_eq!(
1612 pg.env.get("PGDATA").map(String::as_str),
1613 Some("/var/lib/postgresql/data")
1614 );
1615 // No secret env in the content-addressed spec — the credential is injected at launch.
1616 assert!(!pg.env.contains_key("POSTGRES_PASSWORD"));
1617
1618 // The rootless directive then makes it launchable as the image's DB user.
1619 let mut launched = pg.clone();
1620 PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut launched);
1621 assert_eq!(launched.user.as_deref(), Some("999:999"));
1622
1623 // An explicit image override is honored; MySQL uses its own port/data dir.
1624 let my = managed_db_spec(ManagedDbEngine::Mysql, Some("mysql:8.4"), 512);
1625 assert_eq!(my.root, RootSource::Image("mysql:8.4".into()));
1626 assert_eq!(my.port, 3306);
1627 assert_eq!(my.volumes[0].mount, "/var/lib/mysql");
1628
1629 // Per-engine startup graces (slow first `initdb`): Postgres 60, MySQL 120 —
1630 // both above the generic 30 a plain compute spec carries.
1631 assert_eq!(pg.startup_grace_secs, 60);
1632 assert_eq!(my.startup_grace_secs, 120);
1633 assert_eq!(default_startup_grace_secs(), 30);
1634 assert_eq!(spec(1, 64).startup_grace_secs, 30);
1635 }
1636
1637 #[test]
1638 fn privilege_directive_applies_without_overriding_operator_values() {
1639 // Rootless sets `user` when unset.
1640 let mut s = spec(1, 64);
1641 PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
1642 assert_eq!(s.user.as_deref(), Some("999:999"));
1643 assert!(s.cap_add.is_empty());
1644
1645 // An operator-set `user` is never overridden.
1646 let mut s = spec(1, 64);
1647 s.user = Some("1000".into());
1648 PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
1649 assert_eq!(s.user.as_deref(), Some("1000"));
1650
1651 // Caps fills `cap_add` when empty…
1652 let mut s = spec(1, 64);
1653 PrivilegeDirective::Caps(vec!["CHOWN".into(), "SETUID".into()]).apply(&mut s);
1654 assert_eq!(s.cap_add, vec!["CHOWN".to_string(), "SETUID".to_string()]);
1655
1656 // …but not over an operator-set allowlist.
1657 let mut s = spec(1, 64);
1658 s.cap_add = vec!["NET_BIND_SERVICE".into()];
1659 PrivilegeDirective::Caps(vec!["CHOWN".into()]).apply(&mut s);
1660 assert_eq!(s.cap_add, vec!["NET_BIND_SERVICE".to_string()]);
1661 }
1662
1663 fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
1664 ComputeSpec {
1665 version: 1,
1666 root: RootSource::Rootfs("r".repeat(64)),
1667 kernel: "k".repeat(64),
1668 kernel_cmdline: None,
1669 vcpus,
1670 mem_mib,
1671 entrypoint: vec![],
1672 env: BTreeMap::new(),
1673 port: 80,
1674 restart: RestartPolicy::Always,
1675 startup_grace_secs: 30,
1676 scale_to_zero: false,
1677 volumes: vec![],
1678 writable_root: false,
1679 cap_add: Vec::new(),
1680 user: None,
1681 isolation: IsolationRequirement::Trusted,
1682 prefer_backend: None,
1683 bindings: vec![],
1684 }
1685 }
1686
1687 fn workload(replicas: u32, placement: PlacementConstraints) -> ComputeWorkload {
1688 ComputeWorkload {
1689 version: 1,
1690 name: "w".into(),
1691 active: "h".into(),
1692 replicas,
1693 placement,
1694 }
1695 }
1696
1697 fn node(
1698 id: u64,
1699 region: &str,
1700 cpus: u32,
1701 mem: u32,
1702 backends: &[(&str, IsolationClass)],
1703 ) -> Node {
1704 Node {
1705 id,
1706 region: Some(region.into()),
1707 labels: BTreeMap::new(),
1708 free_vcpus: cpus,
1709 free_mem_mib: mem,
1710 backends: backends
1711 .iter()
1712 .map(|(id, iso)| BackendKind {
1713 id: (*id).to_string(),
1714 isolation: *iso,
1715 // Fully-capable fixture: the volume / scale-to-zero gates only
1716 // *refuse* on absent capability, so a capable fixture leaves every
1717 // existing placement test unaffected; negative cases build their
1718 // own incapable `BackendKind`.
1719 persistent_volumes: true,
1720 scale_to_zero: true,
1721 })
1722 .collect(),
1723 }
1724 }
1725
1726 fn vmm(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1727 node(id, region, cpus, mem, &[("vmm", IsolationClass::VmKvm)])
1728 }
1729
1730 fn container(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1731 node(
1732 id,
1733 region,
1734 cpus,
1735 mem,
1736 &[("container", IsolationClass::Namespace)],
1737 )
1738 }
1739
1740 #[test]
1741 fn isolation_class_strength_and_satisfaction() {
1742 assert!(IsolationClass::VmKvm.is_strong());
1743 assert!(IsolationClass::Platform.is_strong());
1744 assert!(!IsolationClass::Namespace.is_strong());
1745 assert!(!IsolationClass::Container.is_strong());
1746 // Untrusted needs strong; trusted accepts any.
1747 assert!(IsolationClass::Namespace.satisfies(IsolationRequirement::Trusted));
1748 assert!(!IsolationClass::Namespace.satisfies(IsolationRequirement::Untrusted));
1749 assert!(IsolationClass::VmKvm.satisfies(IsolationRequirement::Untrusted));
1750 }
1751
1752 #[test]
1753 fn endpoint_url() {
1754 assert_eq!(
1755 Endpoint {
1756 scheme: Scheme::Http,
1757 host: "10.0.0.5".into(),
1758 port: 8080
1759 }
1760 .url(),
1761 "http://10.0.0.5:8080"
1762 );
1763 }
1764
1765 #[test]
1766 fn policy_permits_force_forbid_allow() {
1767 assert!(BackendPolicy::default().permits("vmm"));
1768 let forbid = BackendPolicy {
1769 forbid: vec!["container".into()],
1770 ..Default::default()
1771 };
1772 assert!(forbid.permits("vmm"));
1773 assert!(!forbid.permits("container"));
1774 let allow = BackendPolicy {
1775 allow: Some(vec!["vmm".into()]),
1776 ..Default::default()
1777 };
1778 assert!(allow.permits("vmm"));
1779 assert!(!allow.permits("docker"));
1780 let force = BackendPolicy {
1781 force: Some("vmm".into()),
1782 forbid: vec!["vmm".into()],
1783 ..Default::default()
1784 };
1785 assert!(force.permits("vmm"), "force overrides forbid");
1786 assert!(!force.permits("container"));
1787 }
1788
1789 #[test]
1790 fn policy_from_shared_kernel_allowed_maps_posture_to_strong_isolation() {
1791 // Strict posture (shared-kernel disallowed) ⇒ require strong isolation.
1792 assert!(BackendPolicy::from_shared_kernel_allowed(false).require_strong_isolation);
1793 // Permissive posture ⇒ the default (no strong-isolation requirement).
1794 let permissive = BackendPolicy::from_shared_kernel_allowed(true);
1795 assert!(!permissive.require_strong_isolation);
1796 assert_eq!(permissive, BackendPolicy::default());
1797 }
1798
1799 #[test]
1800 fn worst_fit_spreads_and_picks_a_backend() {
1801 let nodes = vec![vmm(1, "eu", 4, 4096), vmm(2, "eu", 4, 4096)];
1802 let placed = place_replicas(
1803 2,
1804 &PlacementConstraints::default(),
1805 &spec(1, 256),
1806 &nodes,
1807 &BackendPolicy::default(),
1808 );
1809 assert_eq!(placed.len(), 2);
1810 assert_ne!(placed[0].node, placed[1].node, "worst-fit → one each");
1811 assert!(placed.iter().all(|p| p.backend == "vmm"));
1812 }
1813
1814 #[test]
1815 fn capacity_shortfall_returns_fewer() {
1816 let nodes = vec![vmm(1, "eu", 4, 8192)];
1817 let placed = place_replicas(
1818 5,
1819 &PlacementConstraints::default(),
1820 &spec(2, 256),
1821 &nodes,
1822 &BackendPolicy::default(),
1823 );
1824 assert_eq!(placed.len(), 2, "only two 2-vCPU replicas fit");
1825 }
1826
1827 #[test]
1828 fn untrusted_skips_shared_kernel_nodes() {
1829 // A container-only node can't satisfy an untrusted workload.
1830 let nodes = vec![container(1, "eu", 8, 8192)];
1831 let mut s = spec(1, 128);
1832 s.isolation = IsolationRequirement::Untrusted;
1833 assert!(place_replicas(
1834 2,
1835 &PlacementConstraints::default(),
1836 &s,
1837 &nodes,
1838 &BackendPolicy::default()
1839 )
1840 .is_empty());
1841 // A vmm node satisfies it.
1842 let nodes = vec![vmm(1, "eu", 8, 8192)];
1843 let placed = place_replicas(
1844 2,
1845 &PlacementConstraints::default(),
1846 &s,
1847 &nodes,
1848 &BackendPolicy::default(),
1849 );
1850 assert_eq!(placed.len(), 2);
1851 assert!(placed.iter().all(|p| p.backend == "vmm"));
1852 }
1853
1854 /// A node offering one backend with the given capabilities — for the negative
1855 /// gate cases (the shared fixtures are deliberately fully-capable).
1856 fn node_with_caps(id: &str, iso: IsolationClass, volumes: bool, s2z: bool) -> Node {
1857 Node {
1858 id: 1,
1859 region: Some("eu".into()),
1860 labels: BTreeMap::new(),
1861 free_vcpus: 8,
1862 free_mem_mib: 8192,
1863 backends: vec![BackendKind {
1864 id: id.into(),
1865 isolation: iso,
1866 persistent_volumes: volumes,
1867 scale_to_zero: s2z,
1868 }],
1869 }
1870 }
1871
1872 #[test]
1873 fn volume_spec_needs_a_volume_capable_backend() {
1874 let mut s = spec(1, 128);
1875 s.volumes = vec![VolumeRef {
1876 mount: "/data".into(),
1877 name: "db".into(),
1878 size_mib: 64,
1879 }];
1880 // A backend that can't back volumes ⇒ no placement (fail loud, not
1881 // silently storage-less).
1882 let no_vol = vec![node_with_caps(
1883 "container",
1884 IsolationClass::Namespace,
1885 false,
1886 false,
1887 )];
1888 assert!(
1889 place_replicas(
1890 1,
1891 &PlacementConstraints::default(),
1892 &s,
1893 &no_vol,
1894 &BackendPolicy::default()
1895 )
1896 .is_empty(),
1897 "a volume spec must not place on a volume-incapable backend"
1898 );
1899 // A volume-capable backend places it.
1900 let vol_ok = vec![node_with_caps("vmm", IsolationClass::VmKvm, true, false)];
1901 assert_eq!(
1902 place_replicas(
1903 1,
1904 &PlacementConstraints::default(),
1905 &s,
1906 &vol_ok,
1907 &BackendPolicy::default()
1908 )
1909 .len(),
1910 1
1911 );
1912 }
1913
1914 #[test]
1915 fn scale_to_zero_spec_needs_a_capable_backend() {
1916 let mut s = spec(1, 128);
1917 s.scale_to_zero = true;
1918 // A backend that can't scale to zero ⇒ no placement, rather than silently
1919 // running always-on.
1920 let no_s2z = vec![node_with_caps(
1921 "docker",
1922 IsolationClass::Container,
1923 false,
1924 false,
1925 )];
1926 assert!(
1927 place_replicas(
1928 1,
1929 &PlacementConstraints::default(),
1930 &s,
1931 &no_s2z,
1932 &BackendPolicy::default()
1933 )
1934 .is_empty(),
1935 "a scale-to-zero spec must not place on a scale-to-zero-incapable backend"
1936 );
1937 // A scale-to-zero-capable backend places it.
1938 let s2z_ok = vec![node_with_caps(
1939 "container",
1940 IsolationClass::Namespace,
1941 false,
1942 true,
1943 )];
1944 assert_eq!(
1945 place_replicas(
1946 1,
1947 &PlacementConstraints::default(),
1948 &s,
1949 &s2z_ok,
1950 &BackendPolicy::default()
1951 )
1952 .len(),
1953 1
1954 );
1955 }
1956
1957 #[test]
1958 fn strict_posture_skips_shared_kernel_even_for_trusted_workload() {
1959 // A Trusted (possibly misclassified) workload normally lands
1960 // on a shared-kernel container node...
1961 let nodes = vec![container(1, "eu", 8, 8192)];
1962 let s = spec(1, 128); // default isolation = Trusted
1963 assert_eq!(
1964 place_replicas(
1965 2,
1966 &PlacementConstraints::default(),
1967 &s,
1968 &nodes,
1969 &BackendPolicy::default()
1970 )
1971 .len(),
1972 2,
1973 "a trusted workload uses the shared-kernel node by default"
1974 );
1975 // ...but the strict posture makes shared-kernel ineligible regardless.
1976 let strict = BackendPolicy {
1977 require_strong_isolation: true,
1978 ..Default::default()
1979 };
1980 assert!(
1981 place_replicas(2, &PlacementConstraints::default(), &s, &nodes, &strict).is_empty(),
1982 "strict posture refuses shared-kernel even for a trusted workload"
1983 );
1984 // A vmm (strong) node still satisfies it under the strict posture.
1985 let vnodes = vec![vmm(1, "eu", 8, 8192)];
1986 assert_eq!(
1987 place_replicas(2, &PlacementConstraints::default(), &s, &vnodes, &strict).len(),
1988 2
1989 );
1990 }
1991
1992 #[test]
1993 fn prefer_backend_is_honored_when_eligible() {
1994 let n = node(
1995 1,
1996 "eu",
1997 8,
1998 8192,
1999 &[
2000 ("vmm", IsolationClass::VmKvm),
2001 ("container", IsolationClass::Namespace),
2002 ],
2003 );
2004 let mut s = spec(1, 128);
2005 s.prefer_backend = Some("container".into());
2006 let placed = place_replicas(
2007 1,
2008 &PlacementConstraints::default(),
2009 &s,
2010 &[n],
2011 &BackendPolicy::default(),
2012 );
2013 assert_eq!(placed[0].backend, "container");
2014 }
2015
2016 #[test]
2017 fn policy_force_overrides_preference() {
2018 let n = node(
2019 1,
2020 "eu",
2021 8,
2022 8192,
2023 &[
2024 ("vmm", IsolationClass::VmKvm),
2025 ("container", IsolationClass::Namespace),
2026 ],
2027 );
2028 let mut s = spec(1, 128);
2029 s.prefer_backend = Some("container".into());
2030 let policy = BackendPolicy {
2031 force: Some("vmm".into()),
2032 ..Default::default()
2033 };
2034 let placed = place_replicas(1, &PlacementConstraints::default(), &s, &[n], &policy);
2035 assert_eq!(
2036 placed[0].backend, "vmm",
2037 "policy force beats the spec preference"
2038 );
2039 }
2040
2041 fn observed(workload: &str, replica: u32, node: u64, healthy: bool) -> ObservedInstance {
2042 ObservedInstance {
2043 handle: InstanceHandle {
2044 project: "default".into(),
2045 workload: workload.into(),
2046 replica,
2047 backend_ref: format!("ref-{replica}"),
2048 },
2049 node,
2050 backend: "vmm".into(),
2051 endpoint: Endpoint {
2052 scheme: Scheme::Http,
2053 host: "10.0.0.2".into(),
2054 port: 80,
2055 },
2056 region: None,
2057 healthy,
2058 // No launch time: treated as past-grace (the baseline behavior these
2059 // helpers exercise); the startup-grace tests set `started_at` explicitly.
2060 started_at: None,
2061 phase: ReplicaPhase::Running,
2062 snapshot: None,
2063 }
2064 }
2065
2066 /// A scaled-to-zero observed replica (phase `Zero` + a snapshot to wake from).
2067 fn zeroed(workload: &str, replica: u32, node: u64) -> ObservedInstance {
2068 let mut o = observed(workload, replica, node, false);
2069 o.phase = ReplicaPhase::Zero;
2070 o.snapshot = Some(Snapshot {
2071 project: "default".into(),
2072 workload: workload.into(),
2073 replica,
2074 data_ref: format!("snap-{replica}"),
2075 });
2076 o
2077 }
2078
2079 /// Wrapper for the baseline tests: `Active` activity + no scale-to-zero
2080 /// capable backends, so the sleep/wake paths stay inert (behavior unchanged).
2081 fn plan(
2082 wl: &ComputeWorkload,
2083 spec: &ComputeSpec,
2084 nodes: &[Node],
2085 policy: &BackendPolicy,
2086 observed: &[ObservedInstance],
2087 ) -> Vec<Action> {
2088 // A `now` far past any `started_at` (the baseline helpers set `started_at:
2089 // None` anyway, which is unconditionally past-grace) so the startup-grace path
2090 // stays inert here — the grace tests drive `reconcile_plan` directly.
2091 reconcile_plan(
2092 wl,
2093 spec,
2094 nodes,
2095 policy,
2096 observed,
2097 WorkloadActivity::Active,
2098 &BTreeMap::new(),
2099 u64::MAX,
2100 )
2101 }
2102
2103 /// A capability map advertising scale-to-zero for the `vmm` backend (the id
2104 /// the `observed`/`zeroed` helpers use).
2105 fn s2z_caps() -> BTreeMap<String, Capabilities> {
2106 let mut m = BTreeMap::new();
2107 m.insert(
2108 "vmm".to_string(),
2109 Capabilities {
2110 isolation: IsolationClass::VmKvm,
2111 scale_to_zero: true,
2112 persistent_volumes: false,
2113 max_vcpus: None,
2114 max_mem_mib: None,
2115 },
2116 );
2117 m
2118 }
2119
2120 /// A spec that opts into scale-to-zero.
2121 fn s2z_spec() -> ComputeSpec {
2122 let mut s = spec(1, 256);
2123 s.scale_to_zero = true;
2124 s
2125 }
2126
2127 #[test]
2128 fn idle_running_replica_is_snapshotted_when_scale_to_zero() {
2129 let nodes = vec![vmm(1, "eu", 8, 8192)];
2130 let obs = vec![observed("w", 0, 1, true)];
2131 let actions = reconcile_plan(
2132 &workload(1, Default::default()),
2133 &s2z_spec(),
2134 &nodes,
2135 &BackendPolicy::default(),
2136 &obs,
2137 WorkloadActivity::Idle,
2138 &s2z_caps(),
2139 u64::MAX,
2140 );
2141 assert_eq!(actions.len(), 1);
2142 assert!(matches!(&actions[0], Action::Snapshot { handle } if handle.replica == 0));
2143 }
2144
2145 #[test]
2146 fn idle_replica_not_snapshotted_without_opt_in_or_capability() {
2147 let nodes = vec![vmm(1, "eu", 8, 8192)];
2148 let obs = vec![observed("w", 0, 1, true)];
2149 // Opted in, but the backend isn't capable → no snapshot.
2150 let no_cap = reconcile_plan(
2151 &workload(1, Default::default()),
2152 &s2z_spec(),
2153 &nodes,
2154 &BackendPolicy::default(),
2155 &obs,
2156 WorkloadActivity::Idle,
2157 &BTreeMap::new(),
2158 u64::MAX,
2159 );
2160 assert!(no_cap.is_empty(), "no capable backend: {no_cap:?}");
2161 // Capable backend, but the spec didn't opt in → no snapshot.
2162 let no_opt = reconcile_plan(
2163 &workload(1, Default::default()),
2164 &spec(1, 256),
2165 &nodes,
2166 &BackendPolicy::default(),
2167 &obs,
2168 WorkloadActivity::Idle,
2169 &s2z_caps(),
2170 u64::MAX,
2171 );
2172 assert!(no_opt.is_empty(), "not opted in: {no_opt:?}");
2173 }
2174
2175 #[test]
2176 fn zeroed_replica_wakes_on_activity() {
2177 let nodes = vec![vmm(1, "eu", 8, 8192)];
2178 let obs = vec![zeroed("w", 0, 1)];
2179 let actions = reconcile_plan(
2180 &workload(1, Default::default()),
2181 &s2z_spec(),
2182 &nodes,
2183 &BackendPolicy::default(),
2184 &obs,
2185 WorkloadActivity::Active,
2186 &s2z_caps(),
2187 u64::MAX,
2188 );
2189 assert_eq!(actions.len(), 1);
2190 assert!(
2191 matches!(&actions[0], Action::Restore { snapshot, node, .. } if snapshot.replica == 0 && *node == 1)
2192 );
2193 }
2194
2195 #[test]
2196 fn zeroed_replica_stays_parked_when_idle_and_is_not_relaunched() {
2197 let nodes = vec![vmm(1, "eu", 8, 8192)];
2198 let obs = vec![zeroed("w", 0, 1)];
2199 let actions = reconcile_plan(
2200 &workload(1, Default::default()),
2201 &s2z_spec(),
2202 &nodes,
2203 &BackendPolicy::default(),
2204 &obs,
2205 WorkloadActivity::Idle,
2206 &s2z_caps(),
2207 u64::MAX,
2208 );
2209 // Idle → no restore, and crucially no Launch (the parked ordinal is not
2210 // treated as a missing replica).
2211 assert!(
2212 actions.is_empty(),
2213 "parked replica left untouched: {actions:?}"
2214 );
2215 }
2216
2217 #[test]
2218 fn out_of_range_zeroed_replica_is_stopped_not_restored() {
2219 let nodes = vec![vmm(1, "eu", 8, 8192)];
2220 let obs = vec![zeroed("w", 1, 1)]; // ordinal 1, desired 1 → out of range
2221 let actions = reconcile_plan(
2222 &workload(1, Default::default()),
2223 &s2z_spec(),
2224 &nodes,
2225 &BackendPolicy::default(),
2226 &obs,
2227 WorkloadActivity::Active,
2228 &s2z_caps(),
2229 u64::MAX,
2230 );
2231 assert!(actions
2232 .iter()
2233 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
2234 assert!(
2235 !actions.iter().any(|a| matches!(a, Action::Restore { .. })),
2236 "out-of-range parked replica is stopped, not restored"
2237 );
2238 }
2239
2240 #[test]
2241 fn reconcile_scales_up_from_nothing() {
2242 let nodes = vec![vmm(1, "eu", 8, 8192), vmm(2, "eu", 8, 8192)];
2243 let actions = plan(
2244 &workload(2, Default::default()),
2245 &spec(1, 256),
2246 &nodes,
2247 &BackendPolicy::default(),
2248 &[],
2249 );
2250 let launches: Vec<u32> = actions
2251 .iter()
2252 .filter_map(|a| match a {
2253 Action::Launch { replica, .. } => Some(*replica),
2254 _ => None,
2255 })
2256 .collect();
2257 assert_eq!(launches, vec![0, 1], "both ordinals launched");
2258 assert!(!actions.iter().any(|a| matches!(a, Action::Stop { .. })));
2259 }
2260
2261 #[test]
2262 fn reconcile_is_noop_when_at_desired() {
2263 let nodes = vec![vmm(1, "eu", 8, 8192)];
2264 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, true)];
2265 let actions = plan(
2266 &workload(2, Default::default()),
2267 &spec(1, 256),
2268 &nodes,
2269 &BackendPolicy::default(),
2270 &obs,
2271 );
2272 assert!(actions.is_empty(), "already converged");
2273 }
2274
2275 #[test]
2276 fn reconcile_scales_down_stops_out_of_range() {
2277 let nodes = vec![vmm(1, "eu", 8, 8192)];
2278 let obs = vec![
2279 observed("w", 0, 1, true),
2280 observed("w", 1, 1, true),
2281 observed("w", 2, 1, true),
2282 ];
2283 let actions = plan(
2284 &workload(2, Default::default()),
2285 &spec(1, 256),
2286 &nodes,
2287 &BackendPolicy::default(),
2288 &obs,
2289 );
2290 assert_eq!(actions.len(), 1);
2291 assert!(matches!(&actions[0], Action::Stop { handle } if handle.replica == 2));
2292 }
2293
2294 #[test]
2295 fn reconcile_replaces_unhealthy_when_restart_always() {
2296 let nodes = vec![vmm(1, "eu", 8, 8192)];
2297 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
2298 let actions = plan(
2299 &workload(2, Default::default()),
2300 &spec(1, 256),
2301 &nodes,
2302 &BackendPolicy::default(),
2303 &obs,
2304 );
2305 // ordinal 1 is stopped AND relaunched.
2306 assert!(actions
2307 .iter()
2308 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
2309 assert!(actions
2310 .iter()
2311 .any(|a| matches!(a, Action::Launch { replica: 1, .. })));
2312 }
2313
2314 /// An `observed` replica with an explicit `started_at`, for the startup-grace path.
2315 fn observed_started(
2316 workload: &str,
2317 replica: u32,
2318 node: u64,
2319 healthy: bool,
2320 started_at: Option<u64>,
2321 ) -> ObservedInstance {
2322 ObservedInstance {
2323 started_at,
2324 ..observed(workload, replica, node, healthy)
2325 }
2326 }
2327
2328 #[test]
2329 fn reconcile_leaves_a_starting_replica_within_its_startup_grace() {
2330 // A Running-but-unhealthy replica launched 10s ago, grace 60s → still starting.
2331 let nodes = vec![vmm(1, "eu", 8, 8192)];
2332 let now = 1_000_000u64;
2333 let mut s = spec(1, 256);
2334 s.startup_grace_secs = 60;
2335 let obs = vec![observed_started("w", 0, 1, false, Some(now - 10))];
2336 let actions = reconcile_plan(
2337 &workload(1, Default::default()),
2338 &s,
2339 &nodes,
2340 &BackendPolicy::default(),
2341 &obs,
2342 WorkloadActivity::Active,
2343 &BTreeMap::new(),
2344 now,
2345 );
2346 // Mid-init: neither stopped nor relaunched (it counts toward `desired`).
2347 assert!(
2348 actions.is_empty(),
2349 "a replica within its startup grace is left alone: {actions:?}"
2350 );
2351 }
2352
2353 #[test]
2354 fn reconcile_relaunches_a_replica_past_its_startup_grace() {
2355 // Same replica, launched 120s ago, grace 60s → past grace → broken → self-heal.
2356 let nodes = vec![vmm(1, "eu", 8, 8192)];
2357 let now = 1_000_000u64;
2358 let mut s = spec(1, 256);
2359 s.startup_grace_secs = 60;
2360 let obs = vec![observed_started("w", 0, 1, false, Some(now - 120))];
2361 let actions = reconcile_plan(
2362 &workload(1, Default::default()),
2363 &s,
2364 &nodes,
2365 &BackendPolicy::default(),
2366 &obs,
2367 WorkloadActivity::Active,
2368 &BTreeMap::new(),
2369 now,
2370 );
2371 assert!(
2372 actions
2373 .iter()
2374 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 0)),
2375 "past-grace unhealthy replica is stopped: {actions:?}"
2376 );
2377 assert!(
2378 actions
2379 .iter()
2380 .any(|a| matches!(a, Action::Launch { replica: 0, .. })),
2381 "and its ordinal relaunched: {actions:?}"
2382 );
2383 }
2384
2385 #[test]
2386 fn reconcile_treats_started_at_none_as_past_grace() {
2387 // `started_at: None` (older record / restored) → immediate stop + relaunch,
2388 // exactly the prior behavior — even with a large grace and `now`.
2389 let nodes = vec![vmm(1, "eu", 8, 8192)];
2390 let mut s = spec(1, 256);
2391 s.startup_grace_secs = 3600;
2392 let obs = vec![observed_started("w", 0, 1, false, None)];
2393 let actions = reconcile_plan(
2394 &workload(1, Default::default()),
2395 &s,
2396 &nodes,
2397 &BackendPolicy::default(),
2398 &obs,
2399 WorkloadActivity::Active,
2400 &BTreeMap::new(),
2401 1_000_000,
2402 );
2403 assert!(
2404 actions
2405 .iter()
2406 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 0)),
2407 "None started_at preserves the prior immediate relaunch: {actions:?}"
2408 );
2409 assert!(actions
2410 .iter()
2411 .any(|a| matches!(a, Action::Launch { replica: 0, .. })));
2412 }
2413
2414 #[test]
2415 fn a_starting_replica_counts_toward_desired_and_is_not_duplicated() {
2416 // desired=1 with one starting replica → NO new Launch (it's not a missing slot).
2417 let nodes = vec![vmm(1, "eu", 8, 8192)];
2418 let now = 1_000_000u64;
2419 let mut s = spec(1, 256);
2420 s.startup_grace_secs = 60;
2421 let obs = vec![observed_started("w", 0, 1, false, Some(now - 5))];
2422 let actions = reconcile_plan(
2423 &workload(1, Default::default()),
2424 &s,
2425 &nodes,
2426 &BackendPolicy::default(),
2427 &obs,
2428 WorkloadActivity::Active,
2429 &BTreeMap::new(),
2430 now,
2431 );
2432 assert!(
2433 !actions.iter().any(|a| matches!(a, Action::Launch { .. })),
2434 "a starting replica fills its ordinal — no duplicate Launch: {actions:?}"
2435 );
2436 }
2437
2438 #[test]
2439 fn reconcile_leaves_terminal_replicas_for_restart_never() {
2440 let nodes = vec![vmm(1, "eu", 8, 8192)];
2441 let mut s = spec(1, 256);
2442 s.restart = RestartPolicy::Never;
2443 let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
2444 let actions = plan(
2445 &workload(2, Default::default()),
2446 &s,
2447 &nodes,
2448 &BackendPolicy::default(),
2449 &obs,
2450 );
2451 // The exited (unhealthy) Never replica is left alone — no stop, no relaunch.
2452 assert!(
2453 actions.is_empty(),
2454 "run-to-completion replica is terminal: {actions:?}"
2455 );
2456 }
2457
2458 #[test]
2459 fn reconcile_only_touches_its_own_workload() {
2460 let nodes = vec![vmm(1, "eu", 8, 8192)];
2461 let obs = vec![observed("other", 0, 1, true), observed("other", 5, 1, true)];
2462 let actions = plan(
2463 &workload(1, Default::default()),
2464 &spec(1, 256),
2465 &nodes,
2466 &BackendPolicy::default(),
2467 &obs,
2468 );
2469 // Launches ordinal 0 for "w"; ignores "other"'s replicas entirely.
2470 assert_eq!(actions.len(), 1);
2471 assert!(
2472 matches!(&actions[0], Action::Launch { workload, replica: 0, .. } if workload == "w")
2473 );
2474 }
2475
2476 // A trivial in-memory backend, exercising the trait end-to-end.
2477 struct FakeBackend;
2478
2479 #[async_trait]
2480 impl ComputeBackend for FakeBackend {
2481 fn id(&self) -> &'static str {
2482 "fake"
2483 }
2484 fn capabilities(&self) -> Capabilities {
2485 Capabilities {
2486 isolation: IsolationClass::Namespace,
2487 scale_to_zero: false,
2488 persistent_volumes: false,
2489 max_vcpus: None,
2490 max_mem_mib: None,
2491 }
2492 }
2493 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
2494 Ok(Artifact::Image {
2495 reference: "img:latest".into(),
2496 })
2497 }
2498 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
2499 Ok(Instance {
2500 handle: InstanceHandle {
2501 project: req.project.clone(),
2502 workload: req.workload.clone(),
2503 replica: req.replica,
2504 backend_ref: format!("fake-{}", req.replica),
2505 },
2506 endpoint: Endpoint {
2507 scheme: Scheme::Http,
2508 host: "127.0.0.1".into(),
2509 port: 8080,
2510 },
2511 })
2512 }
2513 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
2514 Ok(())
2515 }
2516 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
2517 Ok(Health::Healthy)
2518 }
2519 }
2520
2521 #[tokio::test]
2522 async fn fake_backend_round_trips_through_the_trait() {
2523 let backend: Box<dyn ComputeBackend> = Box::new(FakeBackend);
2524 assert_eq!(backend.id(), "fake");
2525 let s = spec(1, 128);
2526 let artifact = backend.materialize(&s).await.unwrap();
2527 let inst = backend
2528 .launch(&LaunchRequest {
2529 project: "default".into(),
2530 workload: "w".into(),
2531 replica: 0,
2532 spec: s,
2533 artifact,
2534 })
2535 .await
2536 .unwrap();
2537 assert_eq!(inst.endpoint.url(), "http://127.0.0.1:8080");
2538 assert_eq!(backend.health(&inst.handle).await.unwrap(), Health::Healthy);
2539 backend.stop(&inst.handle).await.unwrap();
2540 // Default snapshot/restore: unsupported.
2541 assert!(backend.snapshot(&inst.handle).await.unwrap().is_none());
2542 }
2543
2544 /// A backend whose replicas launch but are **not yet ready** — `health` returns
2545 /// `Unhealthy` (a stock DB image mid-`initdb`, or a broken first launch). Used to
2546 /// prove `launch_one` records the real readiness rather than a blind `true`.
2547 struct NotReadyBackend;
2548
2549 #[async_trait]
2550 impl ComputeBackend for NotReadyBackend {
2551 fn id(&self) -> &'static str {
2552 "fake"
2553 }
2554 fn capabilities(&self) -> Capabilities {
2555 Capabilities {
2556 isolation: IsolationClass::Namespace,
2557 scale_to_zero: false,
2558 persistent_volumes: false,
2559 max_vcpus: None,
2560 max_mem_mib: None,
2561 }
2562 }
2563 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
2564 Ok(Artifact::Image {
2565 reference: "img:latest".into(),
2566 })
2567 }
2568 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
2569 Ok(Instance {
2570 handle: InstanceHandle {
2571 project: req.project.clone(),
2572 workload: req.workload.clone(),
2573 replica: req.replica,
2574 backend_ref: format!("fake-{}", req.replica),
2575 },
2576 endpoint: Endpoint {
2577 scheme: Scheme::Http,
2578 host: "127.0.0.1".into(),
2579 port: 8080,
2580 },
2581 })
2582 }
2583 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
2584 Ok(())
2585 }
2586 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
2587 Ok(Health::Unhealthy)
2588 }
2589 }
2590
2591 /// Fix 3: `launch_one` probes readiness post-launch. A backend that launches but is
2592 /// not yet ready must be recorded `healthy: false` (phase still `Running`), so the
2593 /// next reconcile tick's health refresh + plan self-heals it (Stop + relaunch) with
2594 /// no process restart — where the old unconditional `healthy: true` hid it forever.
2595 #[tokio::test]
2596 async fn launch_one_records_probed_readiness_not_a_blind_true() {
2597 let s = spec(1, 128);
2598 // Not-ready backend → healthy:false, but the replica IS launched (phase Running).
2599 let unready = launch_one(&NotReadyBackend, "default", "pg", 0, 1, None, &s, &[], None)
2600 .await
2601 .unwrap();
2602 assert!(
2603 !unready.healthy,
2604 "a launched-but-unready replica is recorded unhealthy so the next tick relaunches it"
2605 );
2606 assert_eq!(unready.phase, ReplicaPhase::Running);
2607
2608 // A ready backend still records healthy:true (the happy path is unchanged).
2609 let ready = launch_one(&FakeBackend, "default", "pg", 0, 1, None, &s, &[], None)
2610 .await
2611 .unwrap();
2612 assert!(ready.healthy);
2613
2614 // And the plan then Stops+relaunches the unhealthy one (RestartPolicy::Always),
2615 // proving the self-heal — the whole point of recording real readiness.
2616 let mut always = s.clone();
2617 always.restart = RestartPolicy::Always;
2618 let wl = ComputeWorkload {
2619 version: 1,
2620 name: "pg".into(),
2621 active: "spec".into(),
2622 replicas: 1,
2623 placement: PlacementConstraints::default(),
2624 };
2625 let caps: BTreeMap<String, Capabilities> =
2626 [("fake".to_string(), FakeBackend.capabilities())]
2627 .into_iter()
2628 .collect();
2629 // Advance `now` past the startup grace so the self-heal fires — this test is
2630 // about a *genuinely broken* launch (still unhealthy after its grace), not a
2631 // mid-init replica (which the startup-grace test covers).
2632 let past_grace = unready.started_at.unwrap() + always.startup_grace_secs as u64 + 1;
2633 let actions = reconcile_plan(
2634 &wl,
2635 &always,
2636 &[fake_node()],
2637 &BackendPolicy::default(),
2638 &[unready],
2639 WorkloadActivity::Active,
2640 &caps,
2641 past_grace,
2642 );
2643 assert!(
2644 actions
2645 .iter()
2646 .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 0)),
2647 "the unhealthy first launch is stopped: {actions:?}"
2648 );
2649 assert!(
2650 actions
2651 .iter()
2652 .any(|a| matches!(a, Action::Launch { replica: 0, .. })),
2653 "and its ordinal relaunched: {actions:?}"
2654 );
2655 }
2656
2657 /// A do-nothing blob backend so the driver test can build a `DeployStore`
2658 /// (the reconcile loop only touches the KV-backed methods).
2659 struct NullStorage;
2660
2661 #[async_trait]
2662 impl crate::Storage for NullStorage {
2663 async fn get(&self, _: &str) -> Result<crate::GetObject, crate::StorageError> {
2664 Err(crate::StorageError::NotFound(String::new()))
2665 }
2666 async fn get_range(
2667 &self,
2668 _: &str,
2669 _: u64,
2670 _: Option<u64>,
2671 ) -> Result<crate::GetObject, crate::StorageError> {
2672 Err(crate::StorageError::NotFound(String::new()))
2673 }
2674 async fn put(
2675 &self,
2676 _: &str,
2677 _: crate::ByteStream,
2678 _: crate::PutMeta,
2679 ) -> Result<crate::ObjectMeta, crate::StorageError> {
2680 Err(crate::StorageError::unsupported("null"))
2681 }
2682 async fn head(&self, _: &str) -> Result<crate::ObjectMeta, crate::StorageError> {
2683 Err(crate::StorageError::NotFound(String::new()))
2684 }
2685 async fn delete(&self, _: &str) -> Result<(), crate::StorageError> {
2686 Ok(())
2687 }
2688 async fn list(&self, _: &str) -> Result<Vec<crate::ObjectMeta>, crate::StorageError> {
2689 Ok(Vec::new())
2690 }
2691 }
2692
2693 fn fake_node() -> Node {
2694 Node {
2695 id: 1,
2696 region: Some("eu".into()),
2697 labels: BTreeMap::new(),
2698 free_vcpus: 8,
2699 free_mem_mib: 8192,
2700 backends: vec![BackendKind {
2701 id: "fake".into(),
2702 isolation: IsolationClass::Namespace,
2703 // Fully-capable so the scale-to-zero reconcile tests (which reuse this
2704 // fixture) still place; negative gate tests build their own node.
2705 persistent_volumes: true,
2706 scale_to_zero: true,
2707 }],
2708 }
2709 }
2710
2711 /// A scale-to-zero-capable backend: `snapshot` always parks (returns a
2712 /// snapshot), `restore` brings it back. Reuses id `"fake"` (the node's
2713 /// backend) so placement still works.
2714 struct S2zBackend;
2715
2716 #[async_trait]
2717 impl ComputeBackend for S2zBackend {
2718 fn id(&self) -> &'static str {
2719 "fake"
2720 }
2721 fn capabilities(&self) -> Capabilities {
2722 Capabilities {
2723 isolation: IsolationClass::Namespace,
2724 scale_to_zero: true,
2725 persistent_volumes: false,
2726 max_vcpus: None,
2727 max_mem_mib: None,
2728 }
2729 }
2730 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
2731 Ok(Artifact::Image {
2732 reference: "img:latest".into(),
2733 })
2734 }
2735 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
2736 Ok(Instance {
2737 handle: InstanceHandle {
2738 project: req.project.clone(),
2739 workload: req.workload.clone(),
2740 replica: req.replica,
2741 backend_ref: format!("fake-{}", req.replica),
2742 },
2743 endpoint: Endpoint {
2744 scheme: Scheme::Http,
2745 host: "127.0.0.1".into(),
2746 port: 8080,
2747 },
2748 })
2749 }
2750 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
2751 Ok(())
2752 }
2753 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
2754 Ok(Health::Healthy)
2755 }
2756 async fn snapshot(
2757 &self,
2758 handle: &InstanceHandle,
2759 ) -> Result<Option<Snapshot>, BackendError> {
2760 Ok(Some(Snapshot {
2761 project: handle.project.clone(),
2762 workload: handle.workload.clone(),
2763 replica: handle.replica,
2764 data_ref: format!("snap-{}", handle.replica),
2765 }))
2766 }
2767 async fn restore(&self, snapshot: &Snapshot) -> Result<Instance, BackendError> {
2768 Ok(Instance {
2769 handle: InstanceHandle {
2770 project: snapshot.project.clone(),
2771 workload: snapshot.workload.clone(),
2772 replica: snapshot.replica,
2773 backend_ref: format!("restored-{}", snapshot.replica),
2774 },
2775 endpoint: Endpoint {
2776 scheme: Scheme::Http,
2777 host: "127.0.0.1".into(),
2778 port: 8080,
2779 },
2780 })
2781 }
2782 }
2783
2784 /// An [`ActivitySource`] that reports the same activity for every workload.
2785 struct FixedActivity(WorkloadActivity);
2786
2787 #[async_trait]
2788 impl ActivitySource for FixedActivity {
2789 async fn activity(&self, _workload: &str) -> WorkloadActivity {
2790 self.0
2791 }
2792 }
2793
2794 #[tokio::test]
2795 async fn reconcile_sleeps_idle_replica_then_wakes_it_on_activity() {
2796 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
2797 let mut s = spec(1, 128);
2798 s.scale_to_zero = true;
2799 let hash = deploy.put_compute_spec(&s).await.unwrap();
2800 deploy
2801 .set_compute_workload(
2802 crate::project::ProjectRef::DEFAULT,
2803 &ComputeWorkload {
2804 version: 1,
2805 name: "w".into(),
2806 active: hash,
2807 replicas: 1,
2808 placement: Default::default(),
2809 },
2810 )
2811 .await
2812 .unwrap();
2813 let mut backends: BackendRegistry = BTreeMap::new();
2814 backends.insert("fake".into(), Arc::new(S2zBackend));
2815 let nodes = vec![fake_node()];
2816 let policy = BackendPolicy::default();
2817
2818 // Active → launch the replica.
2819 let r = reconcile_once(
2820 &deploy,
2821 &backends,
2822 &nodes,
2823 &policy,
2824 &FixedActivity(WorkloadActivity::Active),
2825 None,
2826 None,
2827 )
2828 .await
2829 .unwrap();
2830 assert_eq!(r.launched, 1, "{:?}", r.errors);
2831
2832 // Idle → sleep it: snapshot + park in Zero.
2833 let r = reconcile_once(
2834 &deploy,
2835 &backends,
2836 &nodes,
2837 &policy,
2838 &FixedActivity(WorkloadActivity::Idle),
2839 None,
2840 None,
2841 )
2842 .await
2843 .unwrap();
2844 assert_eq!(r.slept, 1, "{:?}", r.errors);
2845 let parked = deploy
2846 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2847 .await
2848 .unwrap();
2849 assert_eq!(parked.len(), 1);
2850 assert_eq!(parked[0].phase, ReplicaPhase::Zero);
2851 assert!(parked[0].snapshot.is_some(), "carries its snapshot");
2852 assert!(!parked[0].healthy);
2853
2854 // Idle again → stays parked (no churn).
2855 let r = reconcile_once(
2856 &deploy,
2857 &backends,
2858 &nodes,
2859 &policy,
2860 &FixedActivity(WorkloadActivity::Idle),
2861 None,
2862 None,
2863 )
2864 .await
2865 .unwrap();
2866 assert_eq!((r.slept, r.woke, r.launched), (0, 0, 0), "{:?}", r.errors);
2867
2868 // Active → wake it: restore → Running.
2869 let r = reconcile_once(
2870 &deploy,
2871 &backends,
2872 &nodes,
2873 &policy,
2874 &FixedActivity(WorkloadActivity::Active),
2875 None,
2876 None,
2877 )
2878 .await
2879 .unwrap();
2880 assert_eq!(r.woke, 1, "{:?}", r.errors);
2881 let woken = deploy
2882 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2883 .await
2884 .unwrap();
2885 assert_eq!(woken.len(), 1);
2886 assert_eq!(woken[0].phase, ReplicaPhase::Running);
2887 assert!(woken[0].snapshot.is_none());
2888 assert!(woken[0].healthy);
2889 }
2890
2891 #[tokio::test]
2892 async fn reconcile_once_launches_converges_then_stops() {
2893 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
2894 let s = spec(1, 128);
2895 let hash = deploy.put_compute_spec(&s).await.unwrap();
2896 deploy
2897 .set_compute_workload(
2898 crate::project::ProjectRef::DEFAULT,
2899 &ComputeWorkload {
2900 version: 1,
2901 name: "w".into(),
2902 active: hash.clone(),
2903 replicas: 2,
2904 placement: Default::default(),
2905 },
2906 )
2907 .await
2908 .unwrap();
2909 let nodes = vec![fake_node()];
2910 let mut backends: BackendRegistry = BTreeMap::new();
2911 backends.insert("fake".into(), Arc::new(FakeBackend));
2912 let policy = BackendPolicy::default();
2913
2914 // Pass 1: launches both replicas + persists their state.
2915 let r = reconcile_once(
2916 &deploy,
2917 &backends,
2918 &nodes,
2919 &policy,
2920 &AlwaysActive,
2921 None,
2922 None,
2923 )
2924 .await
2925 .unwrap();
2926 assert_eq!((r.launched, r.stopped), (2, 0), "{:?}", r.errors);
2927 assert!(r.errors.is_empty(), "{:?}", r.errors);
2928 let states = deploy
2929 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2930 .await
2931 .unwrap();
2932 assert_eq!(states.len(), 2);
2933 // FA-8: each launched replica inherits its node's region tag.
2934 assert!(
2935 states.iter().all(|s| s.region.as_deref() == Some("eu")),
2936 "replicas carry their node's region"
2937 );
2938
2939 // Pass 2: already converged (FakeBackend reports Healthy) → no-op.
2940 let r2 = reconcile_once(
2941 &deploy,
2942 &backends,
2943 &nodes,
2944 &policy,
2945 &AlwaysActive,
2946 None,
2947 None,
2948 )
2949 .await
2950 .unwrap();
2951 assert_eq!((r2.launched, r2.stopped), (0, 0));
2952
2953 // Scale to zero → both stopped + state cleared.
2954 deploy
2955 .set_compute_workload(
2956 crate::project::ProjectRef::DEFAULT,
2957 &ComputeWorkload {
2958 version: 1,
2959 name: "w".into(),
2960 active: hash,
2961 replicas: 0,
2962 placement: Default::default(),
2963 },
2964 )
2965 .await
2966 .unwrap();
2967 let r3 = reconcile_once(
2968 &deploy,
2969 &backends,
2970 &nodes,
2971 &policy,
2972 &AlwaysActive,
2973 None,
2974 None,
2975 )
2976 .await
2977 .unwrap();
2978 assert_eq!(r3.stopped, 2);
2979 assert!(deploy
2980 .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2981 .await
2982 .unwrap()
2983 .is_empty());
2984 }
2985
2986 /// A2: a backend that records the `gc_ip_pool` parked-key set it was handed, so we
2987 /// can assert the reconcile loop runs the pool-vs-reality GC and passes it exactly
2988 /// the intentionally-parked (`Zero`) replicas (whose IPs must be kept).
2989 struct GcSpyBackend {
2990 gc_parked: std::sync::Mutex<Option<Vec<(String, String, u32)>>>,
2991 }
2992 #[async_trait]
2993 impl ComputeBackend for GcSpyBackend {
2994 fn id(&self) -> &'static str {
2995 "fake"
2996 }
2997 fn capabilities(&self) -> Capabilities {
2998 Capabilities {
2999 isolation: IsolationClass::Namespace,
3000 scale_to_zero: true, // so a Zero replica is left parked, not stopped
3001 persistent_volumes: false,
3002 max_vcpus: None,
3003 max_mem_mib: None,
3004 }
3005 }
3006 async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
3007 Ok(Artifact::Image {
3008 reference: "img:latest".into(),
3009 })
3010 }
3011 async fn gc_ip_pool(&self, parked: &[(String, String, u32)]) {
3012 *self.gc_parked.lock().unwrap() = Some(parked.to_vec());
3013 }
3014 async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
3015 Ok(Instance {
3016 handle: InstanceHandle {
3017 project: req.project.clone(),
3018 workload: req.workload.clone(),
3019 replica: req.replica,
3020 backend_ref: format!("fake-{}", req.replica),
3021 },
3022 endpoint: Endpoint {
3023 scheme: Scheme::Http,
3024 host: "127.0.0.1".into(),
3025 port: 8080,
3026 },
3027 })
3028 }
3029 async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
3030 Ok(())
3031 }
3032 async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
3033 Ok(Health::Healthy)
3034 }
3035 }
3036
3037 #[tokio::test]
3038 async fn reconcile_runs_ip_gc_with_the_parked_replicas() {
3039 // One workload with a persisted **parked** (Zero) replica: the reconcile's IP
3040 // GC must run and be told that replica is parked (so its IP is preserved).
3041 let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
3042 let s = spec(1, 128);
3043 let hash = deploy.put_compute_spec(&s).await.unwrap();
3044 deploy
3045 .set_compute_workload(
3046 crate::project::ProjectRef::DEFAULT,
3047 &ComputeWorkload {
3048 version: 1,
3049 name: "w".into(),
3050 active: hash,
3051 replicas: 1,
3052 placement: Default::default(),
3053 },
3054 )
3055 .await
3056 .unwrap();
3057 // Persist replica 0 as parked (Zero) with a snapshot, so the planner leaves it.
3058 let parked = ObservedInstance {
3059 handle: InstanceHandle {
3060 project: "default".into(),
3061 workload: "w".into(),
3062 replica: 0,
3063 backend_ref: "10.0.0.2:8080".into(),
3064 },
3065 node: 1,
3066 backend: "fake".into(),
3067 endpoint: Endpoint {
3068 scheme: Scheme::Http,
3069 host: "10.0.0.2".into(),
3070 port: 8080,
3071 },
3072 region: None,
3073 healthy: false,
3074 started_at: None,
3075 phase: ReplicaPhase::Zero,
3076 snapshot: Some(Snapshot {
3077 project: "default".into(),
3078 workload: "w".into(),
3079 replica: 0,
3080 data_ref: "snap".into(),
3081 }),
3082 };
3083 deploy
3084 .set_replica_state(crate::project::ProjectRef::DEFAULT, &parked)
3085 .await
3086 .unwrap();
3087
3088 let nodes = vec![fake_node()];
3089 let mut backends: BackendRegistry = BTreeMap::new();
3090 let spy = Arc::new(GcSpyBackend {
3091 gc_parked: std::sync::Mutex::new(None),
3092 });
3093 backends.insert("fake".into(), spy.clone());
3094 let policy = BackendPolicy::default();
3095
3096 reconcile_once(
3097 &deploy,
3098 &backends,
3099 &nodes,
3100 &policy,
3101 &FixedActivity(WorkloadActivity::Idle), // idle → the Zero replica stays parked
3102 None,
3103 None,
3104 )
3105 .await
3106 .unwrap();
3107
3108 let got = spy.gc_parked.lock().unwrap().clone();
3109 let got = got.expect("gc_ip_pool was called during the reconcile");
3110 assert!(
3111 got.contains(&("default".to_string(), "w".to_string(), 0)),
3112 "the parked (Zero) replica must be handed to gc_ip_pool so its IP is kept, got {got:?}"
3113 );
3114 }
3115}