Skip to main content

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}