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    /// Workload name (for naming / teardown / logging).
97    pub workload: String,
98    /// Replica ordinal within the workload (`0..replicas`).
99    pub replica: u32,
100    /// The immutable spec to run.
101    pub spec: ComputeSpec,
102    /// The materialized artifact for `spec`.
103    pub artifact: Artifact,
104}
105
106/// An opaque handle to a launched replica (for `stop`/`health`).
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct InstanceHandle {
109    /// Workload name.
110    pub workload: String,
111    /// Replica ordinal.
112    pub replica: u32,
113    /// Backend-specific reference (pid / container id / CF instance id / …).
114    pub backend_ref: String,
115}
116
117/// URL scheme for a replica endpoint.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "lowercase")]
120pub enum Scheme {
121    /// Plain HTTP.
122    Http,
123    /// HTTPS.
124    Https,
125}
126
127impl Scheme {
128    /// The lowercase URL-scheme token (matches the serde `rename_all`).
129    pub fn as_str(self) -> &'static str {
130        match self {
131            Self::Http => "http",
132            Self::Https => "https",
133        }
134    }
135}
136
137impl std::fmt::Display for Scheme {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.write_str(self.as_str())
140    }
141}
142
143/// Where the gateway routes to reach a replica.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct Endpoint {
146    /// Scheme to reach the replica with.
147    pub scheme: Scheme,
148    /// Host or IP.
149    pub host: String,
150    /// TCP port.
151    pub port: u16,
152}
153
154impl Endpoint {
155    /// The endpoint as a base URL (`scheme://host:port`).
156    pub fn url(&self) -> String {
157        format!("{}://{}:{}", self.scheme, self.host, self.port)
158    }
159}
160
161/// A launched replica: its handle + the endpoint the gateway routes to.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct Instance {
164    /// Handle for later `stop`/`health`.
165    pub handle: InstanceHandle,
166    /// The endpoint to route ingress to.
167    pub endpoint: Endpoint,
168}
169
170/// Liveness/readiness of a running replica.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Health {
173    /// Up and serving.
174    Healthy,
175    /// Running but not serving (or exited).
176    Unhealthy,
177    /// Indeterminate (e.g. transient probe failure).
178    Unknown,
179}
180
181/// An opaque snapshot for scale-to-zero (persisted inside [`ObservedInstance`]
182/// while a replica is parked in the [`Zero`](ReplicaPhase::Zero) phase).
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Snapshot {
185    /// Workload the snapshot belongs to.
186    pub workload: String,
187    /// Replica ordinal.
188    pub replica: u32,
189    /// Backend-specific reference to the stored snapshot.
190    pub data_ref: String,
191}
192
193/// Why a backend operation failed.
194#[derive(Debug, thiserror::Error)]
195pub enum BackendError {
196    /// The backend doesn't support the requested operation.
197    #[error("operation not supported by this backend")]
198    Unsupported,
199    /// Staging the artifact failed.
200    #[error("materialize: {0}")]
201    Materialize(String),
202    /// Launching the replica failed.
203    #[error("launch: {0}")]
204    Launch(String),
205    /// Stopping the replica failed.
206    #[error("stop: {0}")]
207    Stop(String),
208    /// Any other failure.
209    #[error("{0}")]
210    Other(String),
211}
212
213/// A pluggable compute execution backend (VMM / container / cloudflare / docker).
214///
215/// The control plane only ever sees [`Instance`]/[`Endpoint`]; whether the
216/// backend runs the workload directly (VMM, container) or delegates to a
217/// platform/daemon (cloudflare, docker) is internal.
218#[async_trait]
219pub trait ComputeBackend: Send + Sync {
220    /// Stable backend id (`"vmm"` / `"container"` / `"cloudflare"` / `"docker"`).
221    fn id(&self) -> &'static str;
222
223    /// What this backend can do here (used by the scheduler + policy gate).
224    fn capabilities(&self) -> Capabilities;
225
226    /// Stage `spec`'s artifact into whatever this backend boots from.
227    /// Idempotent + content-addressed (cache/dedup by spec id).
228    async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError>;
229
230    /// Launch one replica; returns its handle + routable endpoint.
231    async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError>;
232
233    /// Stop + clean up a replica (idempotent; safe on a half-launched instance).
234    async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError>;
235
236    /// Liveness/readiness of a running replica.
237    async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError>;
238
239    /// Snapshot a replica for scale-to-zero (backends that support it).
240    async fn snapshot(&self, _handle: &InstanceHandle) -> Result<Option<Snapshot>, BackendError> {
241        Ok(None)
242    }
243
244    /// Restore a snapshotted replica.
245    async fn restore(&self, _snapshot: &Snapshot) -> Result<Instance, BackendError> {
246        Err(BackendError::Unsupported)
247    }
248}
249
250// ---------------------------------------------------------------------------
251// Backend selection policy
252// ---------------------------------------------------------------------------
253
254/// Per-site/tenant backend policy: which backends a workload may use. Default
255/// permits any backend; `force` pins one (overrides allow/forbid).
256#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(default, deny_unknown_fields)]
258pub struct BackendPolicy {
259    /// If set, only these backend ids are permitted.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub allow: Option<Vec<String>>,
262    /// Backend ids that are never permitted.
263    #[serde(skip_serializing_if = "Vec::is_empty")]
264    pub forbid: Vec<String>,
265    /// If set, the only permitted backend (e.g. force `vmm` for a tenant).
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub force: Option<String>,
268    /// Require a **strong** isolation class (VM/platform) for every placement,
269    /// making shared-kernel backends (native namespace / Docker) ineligible even
270    /// for a workload that only declares `Trusted`. Set by the
271    /// operator security posture (`!allow_shared_kernel_compute`); default `false`
272    /// preserves the prior behavior. Closes the "misclassified workload lands on
273    /// a weak backend" gap.
274    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
275    pub require_strong_isolation: bool,
276}
277
278impl BackendPolicy {
279    /// Whether backend `id` is permitted by this policy.
280    pub fn permits(&self, id: &str) -> bool {
281        if let Some(force) = &self.force {
282            return id == force;
283        }
284        if self.forbid.iter().any(|x| x == id) {
285            return false;
286        }
287        match &self.allow {
288            Some(allow) => allow.iter().any(|x| x == id),
289            None => true,
290        }
291    }
292
293    /// The placement policy implied by a security posture's shared-kernel stance.
294    /// When shared-kernel compute is disallowed (a strict posture), only
295    /// **strong-isolation** (VM/platform) backends are eligible — so a workload
296    /// that only declares `Trusted` still cannot land on a native-namespace /
297    /// Docker backend. `allow_shared_kernel = true` yields the default permissive
298    /// policy. The single source of truth for the mapping the serve + cluster
299    /// paths apply (previously inlined + duplicated in the binary).
300    pub fn from_shared_kernel_allowed(allow_shared_kernel: bool) -> Self {
301        Self {
302            require_strong_isolation: !allow_shared_kernel,
303            ..Default::default()
304        }
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Scheduler (backend-aware placement)
310// ---------------------------------------------------------------------------
311
312/// A backend a node offers, with the capabilities the scheduler gates placement
313/// on: the isolation class it provides, plus whether it can back persistent
314/// volumes and scale a workload to zero. Populated from the backend's
315/// [`Capabilities`] at node advertisement.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct BackendKind {
318    /// Backend id (`"vmm"`, …).
319    pub id: String,
320    /// Isolation class this backend provides on this node.
321    pub isolation: IsolationClass,
322    /// Whether this backend can attach the spec's persistent volumes. A spec with
323    /// `volumes` placed on a `false` backend would run storage-less (silent data
324    /// loss), so the scheduler treats it as ineligible.
325    pub persistent_volumes: bool,
326    /// Whether this backend can scale a workload to zero. A `scale_to_zero` spec on
327    /// a `false` backend would run always-on (a silently missed cost optimization),
328    /// so the scheduler treats it as ineligible rather than surprise the operator.
329    pub scale_to_zero: bool,
330}
331
332/// A node's advertised capacity, attributes, and the backends it offers
333/// (from cluster membership). The scheduler receives a snapshot.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub struct Node {
336    /// Cluster node id.
337    pub id: u64,
338    /// Region, for placement constraints.
339    pub region: Option<String>,
340    /// Advertised labels, for placement constraints.
341    pub labels: BTreeMap<String, String>,
342    /// Free vCPUs.
343    pub free_vcpus: u32,
344    /// Free memory in MiB.
345    pub free_mem_mib: u32,
346    /// Backends this node can run a replica on.
347    pub backends: Vec<BackendKind>,
348}
349
350impl Node {
351    /// The backend to use for `spec` on this node, honoring the spec's preferred
352    /// backend, the isolation requirement, and the policy. `None` ⇒ no eligible
353    /// backend here.
354    fn pick_backend(&self, spec: &ComputeSpec, policy: &BackendPolicy) -> Option<String> {
355        let eligible = |b: &BackendKind| {
356            policy.permits(&b.id)
357                && b.isolation.satisfies(spec.isolation)
358                // Strict posture: only strong isolation, regardless of the spec's
359                // (possibly misclassified) requirement.
360                && (!policy.require_strong_isolation || b.isolation.is_strong())
361                // A volume spec needs a volume-capable backend — else it would run
362                // storage-less (silent data loss). No capable backend ⇒ no placement.
363                && (spec.volumes.is_empty() || b.persistent_volumes)
364                // A scale-to-zero spec needs a scale-to-zero-capable backend — else it
365                // silently runs always-on. Fail loud (no placement) instead.
366                && (!spec.scale_to_zero || b.scale_to_zero)
367        };
368        if let Some(pref) = &spec.prefer_backend {
369            if let Some(b) = self.backends.iter().find(|b| &b.id == pref && eligible(b)) {
370                return Some(b.id.clone());
371            }
372        }
373        self.backends
374            .iter()
375            .find(|b| eligible(b))
376            .map(|b| b.id.clone())
377    }
378}
379
380/// One placed replica: the node + the backend chosen for it.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct Placement {
383    /// Chosen node id.
384    pub node: u64,
385    /// Chosen backend id.
386    pub backend: String,
387}
388
389/// Place `count` replicas of `spec` (subject to `placement` + `policy`) across
390/// `nodes`. Eligibility = satisfies the placement constraints, currently fits
391/// the spec's CPU/mem, **and** offers a policy-allowed backend whose isolation
392/// satisfies the spec. Worst-fit (most-free node first) spreads load; capacity is
393/// decremented per placement. Returns fewer than `count` when capacity/eligible
394/// backends run out (the caller surfaces "insufficient capacity").
395pub fn place_replicas(
396    count: u32,
397    placement: &PlacementConstraints,
398    spec: &ComputeSpec,
399    nodes: &[Node],
400    policy: &BackendPolicy,
401) -> Vec<Placement> {
402    let need_cpu = spec.vcpus.max(1);
403    let need_mem = spec.mem_mib.max(1);
404
405    // Working copy: (id, free_cpu, free_mem, the node) for placement-eligible nodes.
406    let mut free: Vec<(u64, u32, u32, &Node)> = nodes
407        .iter()
408        .filter(|n| placement.allows(n.region.as_deref(), &n.labels))
409        .map(|n| (n.id, n.free_vcpus, n.free_mem_mib, n))
410        .collect();
411
412    let mut placements = Vec::new();
413    for _ in 0..count {
414        // Worst-fit among nodes that fit AND have an eligible backend.
415        let pick = free
416            .iter_mut()
417            .filter(|(_, c, m, n)| {
418                *c >= need_cpu && *m >= need_mem && n.pick_backend(spec, policy).is_some()
419            })
420            .max_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)));
421        match pick {
422            Some(slot) => {
423                let backend = slot
424                    .3
425                    .pick_backend(spec, policy)
426                    .expect("filtered to nodes with an eligible backend");
427                placements.push(Placement {
428                    node: slot.0,
429                    backend,
430                });
431                slot.1 -= need_cpu;
432                slot.2 -= need_mem;
433            }
434            None => break, // no node can fit another eligible replica
435        }
436    }
437    placements
438}
439
440// ---------------------------------------------------------------------------
441// Reconcile planner (pure)
442// ---------------------------------------------------------------------------
443
444/// The lifecycle phase of an observed replica.
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
446pub enum ReplicaPhase {
447    /// Launched + serving (the normal phase; also the back-compat default).
448    #[default]
449    Running,
450    /// **Scaled to zero**: snapshotted + stopped to free node resources;
451    /// resumable from its [`ObservedInstance::snapshot`] on the next activity.
452    Zero,
453}
454
455/// An observed replica (the persisted control-plane state at
456/// `compute_state/<workload>/<replica>`; also the gateway's upstream source).
457/// Usually [`Running`](ReplicaPhase::Running); a scale-to-zero replica persists
458/// in the [`Zero`](ReplicaPhase::Zero) phase carrying its snapshot.
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460pub struct ObservedInstance {
461    /// The replica's handle.
462    pub handle: InstanceHandle,
463    /// The node it runs on (for `Zero`, the node holding its snapshot — restore
464    /// is same-node until live migration lands).
465    pub node: u64,
466    /// The backend that runs it.
467    pub backend: String,
468    /// The endpoint the gateway routes to (the last-known endpoint while `Zero`).
469    pub endpoint: Endpoint,
470    /// The region of the node this replica runs on, denormalized from
471    /// [`Node::region`] at launch so the gateway's nearest-replica LB (FA-8) can
472    /// tag the replica's endpoint without a node lookup. `#[serde(default)]` keeps
473    /// older records (no field) deserializing — schema stays v1.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub region: Option<String>,
476    /// Whether the last health check passed (always `false` while `Zero`).
477    pub healthy: bool,
478    /// Lifecycle phase. `#[serde(default)]` keeps older records (no field)
479    /// deserializing as [`Running`](ReplicaPhase::Running) — schema stays v1.
480    #[serde(default)]
481    pub phase: ReplicaPhase,
482    /// The snapshot to restore from — `Some` iff `phase == Zero`.
483    #[serde(default)]
484    pub snapshot: Option<Snapshot>,
485}
486
487/// The observed-state key for a workload's replica, **project-scoped** (0.2.0):
488/// `project/<proj>/compute_state/<workload>/<replica>`.
489pub fn replica_state_key(project: &str, workload: &str, replica: u32) -> String {
490    format!("project/{project}/compute_state/{workload}/{replica}")
491}
492
493/// The key prefix listing one workload's replica states within a project.
494pub fn replica_state_prefix(project: &str, workload: &str) -> String {
495    format!("project/{project}/compute_state/{workload}/")
496}
497
498/// The key prefix listing **every** replica state in a project (all workloads).
499pub fn replica_states_project_prefix(project: &str) -> String {
500    format!("project/{project}/compute_state/")
501}
502
503/// A reconcile action the driver executes against a backend.
504#[derive(Debug, Clone, PartialEq, Eq)]
505pub enum Action {
506    /// Launch a new replica at `(node, backend)`.
507    Launch {
508        /// Workload name.
509        workload: String,
510        /// Replica ordinal to launch.
511        replica: u32,
512        /// Chosen node.
513        node: u64,
514        /// Chosen backend.
515        backend: String,
516    },
517    /// Stop a replica.
518    Stop {
519        /// The replica to stop.
520        handle: InstanceHandle,
521    },
522    /// **Sleep** a running replica for scale-to-zero: snapshot it, stop
523    /// it, and persist it in the [`Zero`](ReplicaPhase::Zero) phase.
524    Snapshot {
525        /// The running replica to snapshot + stop.
526        handle: InstanceHandle,
527    },
528    /// **Wake** a zeroed replica: restore it from its snapshot.
529    Restore {
530        /// The snapshot to restore.
531        snapshot: Snapshot,
532        /// The node to restore onto (same node that holds the snapshot).
533        node: u64,
534        /// The backend that owns the snapshot.
535        backend: String,
536    },
537}
538
539/// A workload's recent traffic, the input that drives scale-to-zero decisions.
540/// Sourced from the gateway; the reconcile loop treats it as opaque.
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
542pub enum WorkloadActivity {
543    /// Recent traffic (or unknown) — keep running, and **wake** if zeroed. The
544    /// default, so the loop never sleeps a workload absent a real idle signal.
545    #[default]
546    Active,
547    /// Idle past the scale-to-zero threshold — eligible to **sleep**.
548    Idle,
549}
550
551/// Compute the actions to converge `workload` (running `spec`) from `observed`
552/// to its desired replica count, honoring placement, the isolation requirement,
553/// and the backend `policy`. Pure: no IO, fully unit-tested.
554///
555/// Rules: replicas are addressed by ordinal `0..replicas`. A *healthy* in-range
556/// replica is kept. An out-of-range replica (scaled down) is **stopped**. An
557/// *unhealthy* in-range replica is **stopped** and its ordinal relaunched —
558/// unless the restart policy is `Never`, in which case it is left as a terminal
559/// (completed) instance and not relaunched. Free ordinals are placed onto
560/// eligible nodes; if capacity runs out, fewer launches are emitted.
561pub fn reconcile_plan(
562    workload: &ComputeWorkload,
563    spec: &ComputeSpec,
564    nodes: &[Node],
565    policy: &BackendPolicy,
566    observed: &[ObservedInstance],
567    activity: WorkloadActivity,
568    caps: &BTreeMap<String, Capabilities>,
569) -> Vec<Action> {
570    let desired = workload.replicas;
571    let mut actions = Vec::new();
572
573    // Scale-to-zero is in effect only when the workload opts in *and* the
574    // replica's backend advertises the capability.
575    let sleeps =
576        |backend: &str| spec.scale_to_zero && caps.get(backend).is_some_and(|c| c.scale_to_zero);
577
578    // Classify this workload's observed replicas by ordinal.
579    let mut healthy: BTreeSet<u32> = BTreeSet::new();
580    let mut terminal: BTreeSet<u32> = BTreeSet::new(); // Never + exited → done, don't relaunch
581    let mut zeroed: BTreeSet<u32> = BTreeSet::new(); // scaled-to-zero → wake on activity, never relaunch
582    for inst in observed
583        .iter()
584        .filter(|i| i.handle.workload == workload.name)
585    {
586        let ord = inst.handle.replica;
587        if ord >= desired {
588            // Out of range (also discards a Zero replica's snapshot — Stop is
589            // idempotent and the driver forgets the state).
590            actions.push(Action::Stop {
591                handle: inst.handle.clone(),
592            });
593        } else if inst.phase == ReplicaPhase::Zero {
594            zeroed.insert(ord);
595            // Wake on activity; otherwise stay parked.
596            if matches!(activity, WorkloadActivity::Active) {
597                if let Some(snapshot) = inst.snapshot.clone() {
598                    actions.push(Action::Restore {
599                        snapshot,
600                        node: inst.node,
601                        backend: inst.backend.clone(),
602                    });
603                }
604            }
605        } else if inst.healthy {
606            healthy.insert(ord);
607            // Sleep on sustained idle (opt-in + capable backend).
608            if matches!(activity, WorkloadActivity::Idle) && sleeps(&inst.backend) {
609                actions.push(Action::Snapshot {
610                    handle: inst.handle.clone(),
611                });
612            }
613        } else if matches!(spec.restart, RestartPolicy::Never) {
614            terminal.insert(ord); // run-to-completion: leave it, don't replace
615        } else {
616            actions.push(Action::Stop {
617                handle: inst.handle.clone(),
618            });
619            // ordinal becomes free below → relaunched
620        }
621    }
622
623    // Ordinals in range that need a (re)launch — excluding intentionally parked
624    // (Zero) replicas, which wake via Restore rather than a fresh Launch.
625    let need: Vec<u32> = (0..desired)
626        .filter(|ord| !healthy.contains(ord) && !terminal.contains(ord) && !zeroed.contains(ord))
627        .collect();
628    if need.is_empty() {
629        return actions;
630    }
631
632    // Place the needed count; zip ordinals with placements (a capacity shortfall
633    // simply leaves the tail unplaced — the caller logs it).
634    let placements = place_replicas(need.len() as u32, &workload.placement, spec, nodes, policy);
635    for (ord, place) in need.iter().zip(placements) {
636        actions.push(Action::Launch {
637            workload: workload.name.clone(),
638            replica: *ord,
639            node: place.node,
640            backend: place.backend,
641        });
642    }
643    actions
644}
645
646// ---------------------------------------------------------------------------
647// Reconcile driver (async — drives the backends to converge desired state)
648// ---------------------------------------------------------------------------
649
650/// The execution backends available to the reconcile loop, keyed by
651/// [`ComputeBackend::id`].
652pub type BackendRegistry = BTreeMap<String, Arc<dyn ComputeBackend>>;
653
654/// Where the reconcile loop reads each workload's recent traffic to drive
655/// scale-to-zero (sleep idle replicas / wake them on demand). The real source is
656/// the gateway's per-workload activity, aggregated across the cluster;
657/// [`AlwaysActive`] is the production-safe default until that lands — it never
658/// sleeps a workload, so scale-to-zero stays inert.
659#[async_trait]
660pub trait ActivitySource: Send + Sync {
661    /// The workload's current activity (queried once per reconcile pass).
662    async fn activity(&self, workload: &str) -> WorkloadActivity;
663}
664
665/// The default [`ActivitySource`]: every workload is [`Active`](WorkloadActivity::Active),
666/// so nothing is ever scaled to zero.
667pub struct AlwaysActive;
668
669#[async_trait]
670impl ActivitySource for AlwaysActive {
671    async fn activity(&self, _workload: &str) -> WorkloadActivity {
672        WorkloadActivity::Active
673    }
674}
675
676/// What one reconcile pass did (for logging + tests).
677#[derive(Debug, Default, Clone, PartialEq, Eq)]
678pub struct ReconcileReport {
679    /// Replicas launched this pass.
680    pub launched: usize,
681    /// Replicas stopped this pass.
682    pub stopped: usize,
683    /// Replicas slept (snapshotted + stopped → Zero) this pass.
684    pub slept: usize,
685    /// Replicas woken (restored from a snapshot) this pass.
686    pub woke: usize,
687    /// Per-action failures (the pass continues past them; retried next tick).
688    pub errors: Vec<String>,
689}
690
691/// Resolves a workload's declared [`ComputeBinding`]s (PLAN-compute-bindings) to
692/// env vars injected into the guest at launch, registering any backing shim state.
693/// The concrete impl (the sql-shim resolver) lives server-side, where the
694/// `SqlBackends` provider + the shim listener are; the reconcile only calls this
695/// trait. All methods are keyed by `(project, workload, replica)` and **idempotent**,
696/// so the reconcile can call `resolve` for every running replica each tick to keep
697/// the shim registry populated across a restart.
698#[async_trait]
699pub trait ComputeBindingResolver: Send + Sync {
700    /// Resolve `bindings` to `(env_key, env_value)` pairs to inject into the guest,
701    /// registering the shim state for `(project, workload, replica)`.
702    async fn resolve(
703        &self,
704        project: &str,
705        workload: &str,
706        replica: u32,
707        bindings: &[ComputeBinding],
708    ) -> Vec<(String, String)>;
709
710    /// Release the shim state for a torn-down replica.
711    async fn release(
712        &self,
713        project: &str,
714        workload: &str,
715        replica: u32,
716        bindings: &[ComputeBinding],
717    );
718}
719
720/// One reconcile pass: for every workload, refresh replica health, compute the
721/// plan ([`reconcile_plan`]), and execute it against the chosen backends —
722/// launching/stopping replicas and persisting their observed state (which the
723/// gateway reads as the upstream pool). Per-action failures are collected (not
724/// fatal) so one bad workload can't stall the rest; a top-level KV failure
725/// aborts the pass. The caller leader-gates this (cron-style).
726///
727/// For now the chosen backend is invoked locally (the leader also runs it).
728/// Cross-node dispatch via messaging is a later refinement.
729pub async fn reconcile_once(
730    deploy: &DeployStore,
731    backends: &BackendRegistry,
732    nodes: &[Node],
733    policy: &BackendPolicy,
734    activity: &dyn ActivitySource,
735    resolver: Option<&dyn ComputeBindingResolver>,
736) -> Result<ReconcileReport, crate::error::DeployError> {
737    let mut report = ReconcileReport::default();
738    // Per-backend capabilities (the planner gates scale-to-zero on them).
739    let caps: BTreeMap<String, Capabilities> = backends
740        .iter()
741        .map(|(id, b)| (id.clone(), b.capabilities()))
742        .collect();
743    // Fan out over every project's workloads (compute is project-scoped in 0.2.0).
744    // The owning project threads through every replica-state read/write below.
745    for (project_name, workload) in deploy.list_compute_workloads_all().await? {
746        let project = ProjectRef::new(&project_name);
747        let Some(spec) = deploy.get_compute_spec(&workload.active).await? else {
748            report
749                .errors
750                .push(format!("{}: active spec missing", workload.name));
751            continue;
752        };
753
754        // Observed replica state + a health refresh (skipping parked Zero
755        // replicas — they're intentionally down).
756        let mut observed = deploy.list_replica_states(project, &workload.name).await?;
757        for state in &mut observed {
758            if state.phase == ReplicaPhase::Zero {
759                continue;
760            }
761            if let Some(backend) = backends.get(&state.backend) {
762                if let Ok(health) = backend.health(&state.handle).await {
763                    state.healthy = matches!(health, Health::Healthy);
764                }
765            }
766        }
767
768        // Keep the shim registry populated for every running replica (idempotent),
769        // so a workload's bindings keep working across a server restart while the
770        // guest is still up.
771        if let Some(resolver) = resolver {
772            if !spec.bindings.is_empty() {
773                for state in &observed {
774                    if state.phase == ReplicaPhase::Running {
775                        resolver
776                            .resolve(
777                                &project_name,
778                                &workload.name,
779                                state.handle.replica,
780                                &spec.bindings,
781                            )
782                            .await;
783                    }
784                }
785            }
786        }
787
788        let workload_activity = activity.activity(&workload.name).await;
789        for action in reconcile_plan(
790            &workload,
791            &spec,
792            nodes,
793            policy,
794            &observed,
795            workload_activity,
796            &caps,
797        ) {
798            match action {
799                Action::Launch {
800                    workload: wl,
801                    replica,
802                    node,
803                    backend,
804                } => {
805                    let Some(b) = backends.get(&backend) else {
806                        report
807                            .errors
808                            .push(format!("{wl}/{replica}: no backend {backend:?}"));
809                        continue;
810                    };
811                    let node_region = region_of_node(nodes, node);
812                    // Resolve declared bindings → env injected into the guest (registers
813                    // the shim token for this replica).
814                    let binding_env = match resolver {
815                        Some(r) if !spec.bindings.is_empty() => {
816                            r.resolve(&project_name, &wl, replica, &spec.bindings).await
817                        }
818                        _ => Vec::new(),
819                    };
820                    match launch_one(
821                        b.as_ref(),
822                        &wl,
823                        replica,
824                        node,
825                        node_region,
826                        &spec,
827                        &binding_env,
828                    )
829                    .await
830                    {
831                        Ok(state) => match deploy.set_replica_state(project, &state).await {
832                            Ok(()) => report.launched += 1,
833                            Err(e) => report.errors.push(format!("{wl}/{replica}: persist: {e}")),
834                        },
835                        Err(e) => report.errors.push(format!("{wl}/{replica}: launch: {e}")),
836                    }
837                }
838                Action::Stop { handle } => {
839                    if let Some(b) = observed
840                        .iter()
841                        .find(|o| o.handle == handle)
842                        .and_then(|o| backends.get(&o.backend))
843                    {
844                        if let Err(e) = b.stop(&handle).await {
845                            report
846                                .errors
847                                .push(format!("{}/{}: stop: {e}", handle.workload, handle.replica));
848                        }
849                    }
850                    match deploy
851                        .delete_replica_state(project, &handle.workload, handle.replica)
852                        .await
853                    {
854                        Ok(()) => report.stopped += 1,
855                        Err(e) => report.errors.push(format!(
856                            "{}/{}: forget: {e}",
857                            handle.workload, handle.replica
858                        )),
859                    }
860                    // Revoke this replica's shim tokens.
861                    if let Some(resolver) = resolver {
862                        if !spec.bindings.is_empty() {
863                            resolver
864                                .release(
865                                    &project_name,
866                                    &handle.workload,
867                                    handle.replica,
868                                    &spec.bindings,
869                                )
870                                .await;
871                        }
872                    }
873                }
874                Action::Snapshot { handle } => {
875                    let Some(obs) = observed.iter().find(|o| o.handle == handle).cloned() else {
876                        continue; // vanished between plan + execute
877                    };
878                    let Some(b) = backends.get(&obs.backend) else {
879                        report.errors.push(format!(
880                            "{}/{}: no backend {:?}",
881                            handle.workload, handle.replica, obs.backend
882                        ));
883                        continue;
884                    };
885                    match b.snapshot(&handle).await {
886                        // Park it: persist the Zero phase carrying the snapshot
887                        // (the backend's `snapshot` already stopped the replica).
888                        Ok(Some(snapshot)) => {
889                            let parked = ObservedInstance {
890                                healthy: false,
891                                phase: ReplicaPhase::Zero,
892                                snapshot: Some(snapshot),
893                                ..obs
894                            };
895                            match deploy.set_replica_state(project, &parked).await {
896                                Ok(()) => report.slept += 1,
897                                Err(e) => report.errors.push(format!(
898                                    "{}/{}: persist zero: {e}",
899                                    handle.workload, handle.replica
900                                )),
901                            }
902                        }
903                        // Backend declined (e.g. not running) — leave it as is.
904                        Ok(None) => {}
905                        Err(e) => report.errors.push(format!(
906                            "{}/{}: snapshot: {e}",
907                            handle.workload, handle.replica
908                        )),
909                    }
910                }
911                Action::Restore {
912                    snapshot,
913                    node,
914                    backend,
915                } => {
916                    let Some(b) = backends.get(&backend) else {
917                        report.errors.push(format!(
918                            "{}/{}: no backend {backend:?}",
919                            snapshot.workload, snapshot.replica
920                        ));
921                        continue;
922                    };
923                    match b.restore(&snapshot).await {
924                        Ok(instance) => {
925                            let state = ObservedInstance {
926                                handle: instance.handle,
927                                node,
928                                backend: backend.clone(),
929                                endpoint: instance.endpoint,
930                                region: region_of_node(nodes, node),
931                                healthy: true,
932                                phase: ReplicaPhase::Running,
933                                snapshot: None,
934                            };
935                            match deploy.set_replica_state(project, &state).await {
936                                Ok(()) => report.woke += 1,
937                                Err(e) => report.errors.push(format!(
938                                    "{}/{}: persist running: {e}",
939                                    snapshot.workload, snapshot.replica
940                                )),
941                            }
942                        }
943                        Err(e) => report.errors.push(format!(
944                            "{}/{}: restore: {e}",
945                            snapshot.workload, snapshot.replica
946                        )),
947                    }
948                }
949            }
950        }
951    }
952    Ok(report)
953}
954
955/// The region of node `id` in `nodes`, for tagging a replica's endpoint (FA-8).
956fn region_of_node(nodes: &[Node], id: u64) -> Option<String> {
957    nodes
958        .iter()
959        .find(|n| n.id == id)
960        .and_then(|n| n.region.clone())
961}
962
963/// Materialize + launch one replica, returning its observed state.
964async fn launch_one(
965    backend: &dyn ComputeBackend,
966    workload: &str,
967    replica: u32,
968    node: u64,
969    node_region: Option<String>,
970    spec: &ComputeSpec,
971    extra_env: &[(String, String)],
972) -> Result<ObservedInstance, BackendError> {
973    let artifact = backend.materialize(spec).await?;
974    // Fold the resolved binding env into the launched spec. The workload's own env
975    // wins on a collision, so a hand-set value is never clobbered by a binding.
976    let mut spec = spec.clone();
977    for (k, v) in extra_env {
978        spec.env.entry(k.clone()).or_insert_with(|| v.clone());
979    }
980    let instance = backend
981        .launch(&LaunchRequest {
982            workload: workload.to_string(),
983            replica,
984            spec: spec.clone(),
985            artifact,
986        })
987        .await?;
988    Ok(ObservedInstance {
989        handle: instance.handle,
990        node,
991        backend: backend.id().to_string(),
992        endpoint: instance.endpoint,
993        region: node_region,
994        healthy: true,
995        phase: ReplicaPhase::Running,
996        snapshot: None,
997    })
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
1005        ComputeSpec {
1006            version: 1,
1007            root: RootSource::Rootfs("r".repeat(64)),
1008            kernel: "k".repeat(64),
1009            kernel_cmdline: None,
1010            vcpus,
1011            mem_mib,
1012            entrypoint: vec![],
1013            env: BTreeMap::new(),
1014            port: 80,
1015            restart: RestartPolicy::Always,
1016            scale_to_zero: false,
1017            volumes: vec![],
1018            writable_root: false,
1019            isolation: IsolationRequirement::Trusted,
1020            prefer_backend: None,
1021            bindings: vec![],
1022        }
1023    }
1024
1025    fn workload(replicas: u32, placement: PlacementConstraints) -> ComputeWorkload {
1026        ComputeWorkload {
1027            version: 1,
1028            name: "w".into(),
1029            active: "h".into(),
1030            replicas,
1031            placement,
1032        }
1033    }
1034
1035    fn node(
1036        id: u64,
1037        region: &str,
1038        cpus: u32,
1039        mem: u32,
1040        backends: &[(&str, IsolationClass)],
1041    ) -> Node {
1042        Node {
1043            id,
1044            region: Some(region.into()),
1045            labels: BTreeMap::new(),
1046            free_vcpus: cpus,
1047            free_mem_mib: mem,
1048            backends: backends
1049                .iter()
1050                .map(|(id, iso)| BackendKind {
1051                    id: (*id).to_string(),
1052                    isolation: *iso,
1053                    // Fully-capable fixture: the volume / scale-to-zero gates only
1054                    // *refuse* on absent capability, so a capable fixture leaves every
1055                    // existing placement test unaffected; negative cases build their
1056                    // own incapable `BackendKind`.
1057                    persistent_volumes: true,
1058                    scale_to_zero: true,
1059                })
1060                .collect(),
1061        }
1062    }
1063
1064    fn vmm(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1065        node(id, region, cpus, mem, &[("vmm", IsolationClass::VmKvm)])
1066    }
1067
1068    fn container(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1069        node(
1070            id,
1071            region,
1072            cpus,
1073            mem,
1074            &[("container", IsolationClass::Namespace)],
1075        )
1076    }
1077
1078    #[test]
1079    fn isolation_class_strength_and_satisfaction() {
1080        assert!(IsolationClass::VmKvm.is_strong());
1081        assert!(IsolationClass::Platform.is_strong());
1082        assert!(!IsolationClass::Namespace.is_strong());
1083        assert!(!IsolationClass::Container.is_strong());
1084        // Untrusted needs strong; trusted accepts any.
1085        assert!(IsolationClass::Namespace.satisfies(IsolationRequirement::Trusted));
1086        assert!(!IsolationClass::Namespace.satisfies(IsolationRequirement::Untrusted));
1087        assert!(IsolationClass::VmKvm.satisfies(IsolationRequirement::Untrusted));
1088    }
1089
1090    #[test]
1091    fn endpoint_url() {
1092        assert_eq!(
1093            Endpoint {
1094                scheme: Scheme::Http,
1095                host: "10.0.0.5".into(),
1096                port: 8080
1097            }
1098            .url(),
1099            "http://10.0.0.5:8080"
1100        );
1101    }
1102
1103    #[test]
1104    fn policy_permits_force_forbid_allow() {
1105        assert!(BackendPolicy::default().permits("vmm"));
1106        let forbid = BackendPolicy {
1107            forbid: vec!["container".into()],
1108            ..Default::default()
1109        };
1110        assert!(forbid.permits("vmm"));
1111        assert!(!forbid.permits("container"));
1112        let allow = BackendPolicy {
1113            allow: Some(vec!["vmm".into()]),
1114            ..Default::default()
1115        };
1116        assert!(allow.permits("vmm"));
1117        assert!(!allow.permits("docker"));
1118        let force = BackendPolicy {
1119            force: Some("vmm".into()),
1120            forbid: vec!["vmm".into()],
1121            ..Default::default()
1122        };
1123        assert!(force.permits("vmm"), "force overrides forbid");
1124        assert!(!force.permits("container"));
1125    }
1126
1127    #[test]
1128    fn policy_from_shared_kernel_allowed_maps_posture_to_strong_isolation() {
1129        // Strict posture (shared-kernel disallowed) ⇒ require strong isolation.
1130        assert!(BackendPolicy::from_shared_kernel_allowed(false).require_strong_isolation);
1131        // Permissive posture ⇒ the default (no strong-isolation requirement).
1132        let permissive = BackendPolicy::from_shared_kernel_allowed(true);
1133        assert!(!permissive.require_strong_isolation);
1134        assert_eq!(permissive, BackendPolicy::default());
1135    }
1136
1137    #[test]
1138    fn worst_fit_spreads_and_picks_a_backend() {
1139        let nodes = vec![vmm(1, "eu", 4, 4096), vmm(2, "eu", 4, 4096)];
1140        let placed = place_replicas(
1141            2,
1142            &PlacementConstraints::default(),
1143            &spec(1, 256),
1144            &nodes,
1145            &BackendPolicy::default(),
1146        );
1147        assert_eq!(placed.len(), 2);
1148        assert_ne!(placed[0].node, placed[1].node, "worst-fit → one each");
1149        assert!(placed.iter().all(|p| p.backend == "vmm"));
1150    }
1151
1152    #[test]
1153    fn capacity_shortfall_returns_fewer() {
1154        let nodes = vec![vmm(1, "eu", 4, 8192)];
1155        let placed = place_replicas(
1156            5,
1157            &PlacementConstraints::default(),
1158            &spec(2, 256),
1159            &nodes,
1160            &BackendPolicy::default(),
1161        );
1162        assert_eq!(placed.len(), 2, "only two 2-vCPU replicas fit");
1163    }
1164
1165    #[test]
1166    fn untrusted_skips_shared_kernel_nodes() {
1167        // A container-only node can't satisfy an untrusted workload.
1168        let nodes = vec![container(1, "eu", 8, 8192)];
1169        let mut s = spec(1, 128);
1170        s.isolation = IsolationRequirement::Untrusted;
1171        assert!(place_replicas(
1172            2,
1173            &PlacementConstraints::default(),
1174            &s,
1175            &nodes,
1176            &BackendPolicy::default()
1177        )
1178        .is_empty());
1179        // A vmm node satisfies it.
1180        let nodes = vec![vmm(1, "eu", 8, 8192)];
1181        let placed = place_replicas(
1182            2,
1183            &PlacementConstraints::default(),
1184            &s,
1185            &nodes,
1186            &BackendPolicy::default(),
1187        );
1188        assert_eq!(placed.len(), 2);
1189        assert!(placed.iter().all(|p| p.backend == "vmm"));
1190    }
1191
1192    /// A node offering one backend with the given capabilities — for the negative
1193    /// gate cases (the shared fixtures are deliberately fully-capable).
1194    fn node_with_caps(id: &str, iso: IsolationClass, volumes: bool, s2z: bool) -> Node {
1195        Node {
1196            id: 1,
1197            region: Some("eu".into()),
1198            labels: BTreeMap::new(),
1199            free_vcpus: 8,
1200            free_mem_mib: 8192,
1201            backends: vec![BackendKind {
1202                id: id.into(),
1203                isolation: iso,
1204                persistent_volumes: volumes,
1205                scale_to_zero: s2z,
1206            }],
1207        }
1208    }
1209
1210    #[test]
1211    fn volume_spec_needs_a_volume_capable_backend() {
1212        let mut s = spec(1, 128);
1213        s.volumes = vec![VolumeRef {
1214            mount: "/data".into(),
1215            name: "db".into(),
1216            size_mib: 64,
1217        }];
1218        // A backend that can't back volumes ⇒ no placement (fail loud, not
1219        // silently storage-less).
1220        let no_vol = vec![node_with_caps(
1221            "container",
1222            IsolationClass::Namespace,
1223            false,
1224            false,
1225        )];
1226        assert!(
1227            place_replicas(
1228                1,
1229                &PlacementConstraints::default(),
1230                &s,
1231                &no_vol,
1232                &BackendPolicy::default()
1233            )
1234            .is_empty(),
1235            "a volume spec must not place on a volume-incapable backend"
1236        );
1237        // A volume-capable backend places it.
1238        let vol_ok = vec![node_with_caps("vmm", IsolationClass::VmKvm, true, false)];
1239        assert_eq!(
1240            place_replicas(
1241                1,
1242                &PlacementConstraints::default(),
1243                &s,
1244                &vol_ok,
1245                &BackendPolicy::default()
1246            )
1247            .len(),
1248            1
1249        );
1250    }
1251
1252    #[test]
1253    fn scale_to_zero_spec_needs_a_capable_backend() {
1254        let mut s = spec(1, 128);
1255        s.scale_to_zero = true;
1256        // A backend that can't scale to zero ⇒ no placement, rather than silently
1257        // running always-on.
1258        let no_s2z = vec![node_with_caps(
1259            "docker",
1260            IsolationClass::Container,
1261            false,
1262            false,
1263        )];
1264        assert!(
1265            place_replicas(
1266                1,
1267                &PlacementConstraints::default(),
1268                &s,
1269                &no_s2z,
1270                &BackendPolicy::default()
1271            )
1272            .is_empty(),
1273            "a scale-to-zero spec must not place on a scale-to-zero-incapable backend"
1274        );
1275        // A scale-to-zero-capable backend places it.
1276        let s2z_ok = vec![node_with_caps(
1277            "container",
1278            IsolationClass::Namespace,
1279            false,
1280            true,
1281        )];
1282        assert_eq!(
1283            place_replicas(
1284                1,
1285                &PlacementConstraints::default(),
1286                &s,
1287                &s2z_ok,
1288                &BackendPolicy::default()
1289            )
1290            .len(),
1291            1
1292        );
1293    }
1294
1295    #[test]
1296    fn strict_posture_skips_shared_kernel_even_for_trusted_workload() {
1297        // A Trusted (possibly misclassified) workload normally lands
1298        // on a shared-kernel container node...
1299        let nodes = vec![container(1, "eu", 8, 8192)];
1300        let s = spec(1, 128); // default isolation = Trusted
1301        assert_eq!(
1302            place_replicas(
1303                2,
1304                &PlacementConstraints::default(),
1305                &s,
1306                &nodes,
1307                &BackendPolicy::default()
1308            )
1309            .len(),
1310            2,
1311            "a trusted workload uses the shared-kernel node by default"
1312        );
1313        // ...but the strict posture makes shared-kernel ineligible regardless.
1314        let strict = BackendPolicy {
1315            require_strong_isolation: true,
1316            ..Default::default()
1317        };
1318        assert!(
1319            place_replicas(2, &PlacementConstraints::default(), &s, &nodes, &strict).is_empty(),
1320            "strict posture refuses shared-kernel even for a trusted workload"
1321        );
1322        // A vmm (strong) node still satisfies it under the strict posture.
1323        let vnodes = vec![vmm(1, "eu", 8, 8192)];
1324        assert_eq!(
1325            place_replicas(2, &PlacementConstraints::default(), &s, &vnodes, &strict).len(),
1326            2
1327        );
1328    }
1329
1330    #[test]
1331    fn prefer_backend_is_honored_when_eligible() {
1332        let n = node(
1333            1,
1334            "eu",
1335            8,
1336            8192,
1337            &[
1338                ("vmm", IsolationClass::VmKvm),
1339                ("container", IsolationClass::Namespace),
1340            ],
1341        );
1342        let mut s = spec(1, 128);
1343        s.prefer_backend = Some("container".into());
1344        let placed = place_replicas(
1345            1,
1346            &PlacementConstraints::default(),
1347            &s,
1348            &[n],
1349            &BackendPolicy::default(),
1350        );
1351        assert_eq!(placed[0].backend, "container");
1352    }
1353
1354    #[test]
1355    fn policy_force_overrides_preference() {
1356        let n = node(
1357            1,
1358            "eu",
1359            8,
1360            8192,
1361            &[
1362                ("vmm", IsolationClass::VmKvm),
1363                ("container", IsolationClass::Namespace),
1364            ],
1365        );
1366        let mut s = spec(1, 128);
1367        s.prefer_backend = Some("container".into());
1368        let policy = BackendPolicy {
1369            force: Some("vmm".into()),
1370            ..Default::default()
1371        };
1372        let placed = place_replicas(1, &PlacementConstraints::default(), &s, &[n], &policy);
1373        assert_eq!(
1374            placed[0].backend, "vmm",
1375            "policy force beats the spec preference"
1376        );
1377    }
1378
1379    fn observed(workload: &str, replica: u32, node: u64, healthy: bool) -> ObservedInstance {
1380        ObservedInstance {
1381            handle: InstanceHandle {
1382                workload: workload.into(),
1383                replica,
1384                backend_ref: format!("ref-{replica}"),
1385            },
1386            node,
1387            backend: "vmm".into(),
1388            endpoint: Endpoint {
1389                scheme: Scheme::Http,
1390                host: "10.0.0.2".into(),
1391                port: 80,
1392            },
1393            region: None,
1394            healthy,
1395            phase: ReplicaPhase::Running,
1396            snapshot: None,
1397        }
1398    }
1399
1400    /// A scaled-to-zero observed replica (phase `Zero` + a snapshot to wake from).
1401    fn zeroed(workload: &str, replica: u32, node: u64) -> ObservedInstance {
1402        let mut o = observed(workload, replica, node, false);
1403        o.phase = ReplicaPhase::Zero;
1404        o.snapshot = Some(Snapshot {
1405            workload: workload.into(),
1406            replica,
1407            data_ref: format!("snap-{replica}"),
1408        });
1409        o
1410    }
1411
1412    /// Wrapper for the baseline tests: `Active` activity + no scale-to-zero
1413    /// capable backends, so the sleep/wake paths stay inert (behavior unchanged).
1414    fn plan(
1415        wl: &ComputeWorkload,
1416        spec: &ComputeSpec,
1417        nodes: &[Node],
1418        policy: &BackendPolicy,
1419        observed: &[ObservedInstance],
1420    ) -> Vec<Action> {
1421        reconcile_plan(
1422            wl,
1423            spec,
1424            nodes,
1425            policy,
1426            observed,
1427            WorkloadActivity::Active,
1428            &BTreeMap::new(),
1429        )
1430    }
1431
1432    /// A capability map advertising scale-to-zero for the `vmm` backend (the id
1433    /// the `observed`/`zeroed` helpers use).
1434    fn s2z_caps() -> BTreeMap<String, Capabilities> {
1435        let mut m = BTreeMap::new();
1436        m.insert(
1437            "vmm".to_string(),
1438            Capabilities {
1439                isolation: IsolationClass::VmKvm,
1440                scale_to_zero: true,
1441                persistent_volumes: false,
1442                max_vcpus: None,
1443                max_mem_mib: None,
1444            },
1445        );
1446        m
1447    }
1448
1449    /// A spec that opts into scale-to-zero.
1450    fn s2z_spec() -> ComputeSpec {
1451        let mut s = spec(1, 256);
1452        s.scale_to_zero = true;
1453        s
1454    }
1455
1456    #[test]
1457    fn idle_running_replica_is_snapshotted_when_scale_to_zero() {
1458        let nodes = vec![vmm(1, "eu", 8, 8192)];
1459        let obs = vec![observed("w", 0, 1, true)];
1460        let actions = reconcile_plan(
1461            &workload(1, Default::default()),
1462            &s2z_spec(),
1463            &nodes,
1464            &BackendPolicy::default(),
1465            &obs,
1466            WorkloadActivity::Idle,
1467            &s2z_caps(),
1468        );
1469        assert_eq!(actions.len(), 1);
1470        assert!(matches!(&actions[0], Action::Snapshot { handle } if handle.replica == 0));
1471    }
1472
1473    #[test]
1474    fn idle_replica_not_snapshotted_without_opt_in_or_capability() {
1475        let nodes = vec![vmm(1, "eu", 8, 8192)];
1476        let obs = vec![observed("w", 0, 1, true)];
1477        // Opted in, but the backend isn't capable → no snapshot.
1478        let no_cap = reconcile_plan(
1479            &workload(1, Default::default()),
1480            &s2z_spec(),
1481            &nodes,
1482            &BackendPolicy::default(),
1483            &obs,
1484            WorkloadActivity::Idle,
1485            &BTreeMap::new(),
1486        );
1487        assert!(no_cap.is_empty(), "no capable backend: {no_cap:?}");
1488        // Capable backend, but the spec didn't opt in → no snapshot.
1489        let no_opt = reconcile_plan(
1490            &workload(1, Default::default()),
1491            &spec(1, 256),
1492            &nodes,
1493            &BackendPolicy::default(),
1494            &obs,
1495            WorkloadActivity::Idle,
1496            &s2z_caps(),
1497        );
1498        assert!(no_opt.is_empty(), "not opted in: {no_opt:?}");
1499    }
1500
1501    #[test]
1502    fn zeroed_replica_wakes_on_activity() {
1503        let nodes = vec![vmm(1, "eu", 8, 8192)];
1504        let obs = vec![zeroed("w", 0, 1)];
1505        let actions = reconcile_plan(
1506            &workload(1, Default::default()),
1507            &s2z_spec(),
1508            &nodes,
1509            &BackendPolicy::default(),
1510            &obs,
1511            WorkloadActivity::Active,
1512            &s2z_caps(),
1513        );
1514        assert_eq!(actions.len(), 1);
1515        assert!(
1516            matches!(&actions[0], Action::Restore { snapshot, node, .. } if snapshot.replica == 0 && *node == 1)
1517        );
1518    }
1519
1520    #[test]
1521    fn zeroed_replica_stays_parked_when_idle_and_is_not_relaunched() {
1522        let nodes = vec![vmm(1, "eu", 8, 8192)];
1523        let obs = vec![zeroed("w", 0, 1)];
1524        let actions = reconcile_plan(
1525            &workload(1, Default::default()),
1526            &s2z_spec(),
1527            &nodes,
1528            &BackendPolicy::default(),
1529            &obs,
1530            WorkloadActivity::Idle,
1531            &s2z_caps(),
1532        );
1533        // Idle → no restore, and crucially no Launch (the parked ordinal is not
1534        // treated as a missing replica).
1535        assert!(
1536            actions.is_empty(),
1537            "parked replica left untouched: {actions:?}"
1538        );
1539    }
1540
1541    #[test]
1542    fn out_of_range_zeroed_replica_is_stopped_not_restored() {
1543        let nodes = vec![vmm(1, "eu", 8, 8192)];
1544        let obs = vec![zeroed("w", 1, 1)]; // ordinal 1, desired 1 → out of range
1545        let actions = reconcile_plan(
1546            &workload(1, Default::default()),
1547            &s2z_spec(),
1548            &nodes,
1549            &BackendPolicy::default(),
1550            &obs,
1551            WorkloadActivity::Active,
1552            &s2z_caps(),
1553        );
1554        assert!(actions
1555            .iter()
1556            .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1557        assert!(
1558            !actions.iter().any(|a| matches!(a, Action::Restore { .. })),
1559            "out-of-range parked replica is stopped, not restored"
1560        );
1561    }
1562
1563    #[test]
1564    fn reconcile_scales_up_from_nothing() {
1565        let nodes = vec![vmm(1, "eu", 8, 8192), vmm(2, "eu", 8, 8192)];
1566        let actions = plan(
1567            &workload(2, Default::default()),
1568            &spec(1, 256),
1569            &nodes,
1570            &BackendPolicy::default(),
1571            &[],
1572        );
1573        let launches: Vec<u32> = actions
1574            .iter()
1575            .filter_map(|a| match a {
1576                Action::Launch { replica, .. } => Some(*replica),
1577                _ => None,
1578            })
1579            .collect();
1580        assert_eq!(launches, vec![0, 1], "both ordinals launched");
1581        assert!(!actions.iter().any(|a| matches!(a, Action::Stop { .. })));
1582    }
1583
1584    #[test]
1585    fn reconcile_is_noop_when_at_desired() {
1586        let nodes = vec![vmm(1, "eu", 8, 8192)];
1587        let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, true)];
1588        let actions = plan(
1589            &workload(2, Default::default()),
1590            &spec(1, 256),
1591            &nodes,
1592            &BackendPolicy::default(),
1593            &obs,
1594        );
1595        assert!(actions.is_empty(), "already converged");
1596    }
1597
1598    #[test]
1599    fn reconcile_scales_down_stops_out_of_range() {
1600        let nodes = vec![vmm(1, "eu", 8, 8192)];
1601        let obs = vec![
1602            observed("w", 0, 1, true),
1603            observed("w", 1, 1, true),
1604            observed("w", 2, 1, true),
1605        ];
1606        let actions = plan(
1607            &workload(2, Default::default()),
1608            &spec(1, 256),
1609            &nodes,
1610            &BackendPolicy::default(),
1611            &obs,
1612        );
1613        assert_eq!(actions.len(), 1);
1614        assert!(matches!(&actions[0], Action::Stop { handle } if handle.replica == 2));
1615    }
1616
1617    #[test]
1618    fn reconcile_replaces_unhealthy_when_restart_always() {
1619        let nodes = vec![vmm(1, "eu", 8, 8192)];
1620        let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1621        let actions = plan(
1622            &workload(2, Default::default()),
1623            &spec(1, 256),
1624            &nodes,
1625            &BackendPolicy::default(),
1626            &obs,
1627        );
1628        // ordinal 1 is stopped AND relaunched.
1629        assert!(actions
1630            .iter()
1631            .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1632        assert!(actions
1633            .iter()
1634            .any(|a| matches!(a, Action::Launch { replica: 1, .. })));
1635    }
1636
1637    #[test]
1638    fn reconcile_leaves_terminal_replicas_for_restart_never() {
1639        let nodes = vec![vmm(1, "eu", 8, 8192)];
1640        let mut s = spec(1, 256);
1641        s.restart = RestartPolicy::Never;
1642        let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1643        let actions = plan(
1644            &workload(2, Default::default()),
1645            &s,
1646            &nodes,
1647            &BackendPolicy::default(),
1648            &obs,
1649        );
1650        // The exited (unhealthy) Never replica is left alone — no stop, no relaunch.
1651        assert!(
1652            actions.is_empty(),
1653            "run-to-completion replica is terminal: {actions:?}"
1654        );
1655    }
1656
1657    #[test]
1658    fn reconcile_only_touches_its_own_workload() {
1659        let nodes = vec![vmm(1, "eu", 8, 8192)];
1660        let obs = vec![observed("other", 0, 1, true), observed("other", 5, 1, true)];
1661        let actions = plan(
1662            &workload(1, Default::default()),
1663            &spec(1, 256),
1664            &nodes,
1665            &BackendPolicy::default(),
1666            &obs,
1667        );
1668        // Launches ordinal 0 for "w"; ignores "other"'s replicas entirely.
1669        assert_eq!(actions.len(), 1);
1670        assert!(
1671            matches!(&actions[0], Action::Launch { workload, replica: 0, .. } if workload == "w")
1672        );
1673    }
1674
1675    // A trivial in-memory backend, exercising the trait end-to-end.
1676    struct FakeBackend;
1677
1678    #[async_trait]
1679    impl ComputeBackend for FakeBackend {
1680        fn id(&self) -> &'static str {
1681            "fake"
1682        }
1683        fn capabilities(&self) -> Capabilities {
1684            Capabilities {
1685                isolation: IsolationClass::Namespace,
1686                scale_to_zero: false,
1687                persistent_volumes: false,
1688                max_vcpus: None,
1689                max_mem_mib: None,
1690            }
1691        }
1692        async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1693            Ok(Artifact::Image {
1694                reference: "img:latest".into(),
1695            })
1696        }
1697        async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1698            Ok(Instance {
1699                handle: InstanceHandle {
1700                    workload: req.workload.clone(),
1701                    replica: req.replica,
1702                    backend_ref: format!("fake-{}", req.replica),
1703                },
1704                endpoint: Endpoint {
1705                    scheme: Scheme::Http,
1706                    host: "127.0.0.1".into(),
1707                    port: 8080,
1708                },
1709            })
1710        }
1711        async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1712            Ok(())
1713        }
1714        async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1715            Ok(Health::Healthy)
1716        }
1717    }
1718
1719    #[tokio::test]
1720    async fn fake_backend_round_trips_through_the_trait() {
1721        let backend: Box<dyn ComputeBackend> = Box::new(FakeBackend);
1722        assert_eq!(backend.id(), "fake");
1723        let s = spec(1, 128);
1724        let artifact = backend.materialize(&s).await.unwrap();
1725        let inst = backend
1726            .launch(&LaunchRequest {
1727                workload: "w".into(),
1728                replica: 0,
1729                spec: s,
1730                artifact,
1731            })
1732            .await
1733            .unwrap();
1734        assert_eq!(inst.endpoint.url(), "http://127.0.0.1:8080");
1735        assert_eq!(backend.health(&inst.handle).await.unwrap(), Health::Healthy);
1736        backend.stop(&inst.handle).await.unwrap();
1737        // Default snapshot/restore: unsupported.
1738        assert!(backend.snapshot(&inst.handle).await.unwrap().is_none());
1739    }
1740
1741    /// A do-nothing blob backend so the driver test can build a `DeployStore`
1742    /// (the reconcile loop only touches the KV-backed methods).
1743    struct NullStorage;
1744
1745    #[async_trait]
1746    impl crate::Storage for NullStorage {
1747        async fn get(&self, _: &str) -> Result<crate::GetObject, crate::StorageError> {
1748            Err(crate::StorageError::NotFound(String::new()))
1749        }
1750        async fn get_range(
1751            &self,
1752            _: &str,
1753            _: u64,
1754            _: Option<u64>,
1755        ) -> Result<crate::GetObject, crate::StorageError> {
1756            Err(crate::StorageError::NotFound(String::new()))
1757        }
1758        async fn put(
1759            &self,
1760            _: &str,
1761            _: crate::ByteStream,
1762            _: crate::PutMeta,
1763        ) -> Result<crate::ObjectMeta, crate::StorageError> {
1764            Err(crate::StorageError::unsupported("null"))
1765        }
1766        async fn head(&self, _: &str) -> Result<crate::ObjectMeta, crate::StorageError> {
1767            Err(crate::StorageError::NotFound(String::new()))
1768        }
1769        async fn delete(&self, _: &str) -> Result<(), crate::StorageError> {
1770            Ok(())
1771        }
1772        async fn list(&self, _: &str) -> Result<Vec<crate::ObjectMeta>, crate::StorageError> {
1773            Ok(Vec::new())
1774        }
1775    }
1776
1777    fn fake_node() -> Node {
1778        Node {
1779            id: 1,
1780            region: Some("eu".into()),
1781            labels: BTreeMap::new(),
1782            free_vcpus: 8,
1783            free_mem_mib: 8192,
1784            backends: vec![BackendKind {
1785                id: "fake".into(),
1786                isolation: IsolationClass::Namespace,
1787                // Fully-capable so the scale-to-zero reconcile tests (which reuse this
1788                // fixture) still place; negative gate tests build their own node.
1789                persistent_volumes: true,
1790                scale_to_zero: true,
1791            }],
1792        }
1793    }
1794
1795    /// A scale-to-zero-capable backend: `snapshot` always parks (returns a
1796    /// snapshot), `restore` brings it back. Reuses id `"fake"` (the node's
1797    /// backend) so placement still works.
1798    struct S2zBackend;
1799
1800    #[async_trait]
1801    impl ComputeBackend for S2zBackend {
1802        fn id(&self) -> &'static str {
1803            "fake"
1804        }
1805        fn capabilities(&self) -> Capabilities {
1806            Capabilities {
1807                isolation: IsolationClass::Namespace,
1808                scale_to_zero: true,
1809                persistent_volumes: false,
1810                max_vcpus: None,
1811                max_mem_mib: None,
1812            }
1813        }
1814        async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1815            Ok(Artifact::Image {
1816                reference: "img:latest".into(),
1817            })
1818        }
1819        async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1820            Ok(Instance {
1821                handle: InstanceHandle {
1822                    workload: req.workload.clone(),
1823                    replica: req.replica,
1824                    backend_ref: format!("fake-{}", req.replica),
1825                },
1826                endpoint: Endpoint {
1827                    scheme: Scheme::Http,
1828                    host: "127.0.0.1".into(),
1829                    port: 8080,
1830                },
1831            })
1832        }
1833        async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1834            Ok(())
1835        }
1836        async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1837            Ok(Health::Healthy)
1838        }
1839        async fn snapshot(
1840            &self,
1841            handle: &InstanceHandle,
1842        ) -> Result<Option<Snapshot>, BackendError> {
1843            Ok(Some(Snapshot {
1844                workload: handle.workload.clone(),
1845                replica: handle.replica,
1846                data_ref: format!("snap-{}", handle.replica),
1847            }))
1848        }
1849        async fn restore(&self, snapshot: &Snapshot) -> Result<Instance, BackendError> {
1850            Ok(Instance {
1851                handle: InstanceHandle {
1852                    workload: snapshot.workload.clone(),
1853                    replica: snapshot.replica,
1854                    backend_ref: format!("restored-{}", snapshot.replica),
1855                },
1856                endpoint: Endpoint {
1857                    scheme: Scheme::Http,
1858                    host: "127.0.0.1".into(),
1859                    port: 8080,
1860                },
1861            })
1862        }
1863    }
1864
1865    /// An [`ActivitySource`] that reports the same activity for every workload.
1866    struct FixedActivity(WorkloadActivity);
1867
1868    #[async_trait]
1869    impl ActivitySource for FixedActivity {
1870        async fn activity(&self, _workload: &str) -> WorkloadActivity {
1871            self.0
1872        }
1873    }
1874
1875    #[tokio::test]
1876    async fn reconcile_sleeps_idle_replica_then_wakes_it_on_activity() {
1877        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
1878        let mut s = spec(1, 128);
1879        s.scale_to_zero = true;
1880        let hash = deploy.put_compute_spec(&s).await.unwrap();
1881        deploy
1882            .set_compute_workload(
1883                crate::project::ProjectRef::DEFAULT,
1884                &ComputeWorkload {
1885                    version: 1,
1886                    name: "w".into(),
1887                    active: hash,
1888                    replicas: 1,
1889                    placement: Default::default(),
1890                },
1891            )
1892            .await
1893            .unwrap();
1894        let mut backends: BackendRegistry = BTreeMap::new();
1895        backends.insert("fake".into(), Arc::new(S2zBackend));
1896        let nodes = vec![fake_node()];
1897        let policy = BackendPolicy::default();
1898
1899        // Active → launch the replica.
1900        let r = reconcile_once(
1901            &deploy,
1902            &backends,
1903            &nodes,
1904            &policy,
1905            &FixedActivity(WorkloadActivity::Active),
1906            None,
1907        )
1908        .await
1909        .unwrap();
1910        assert_eq!(r.launched, 1, "{:?}", r.errors);
1911
1912        // Idle → sleep it: snapshot + park in Zero.
1913        let r = reconcile_once(
1914            &deploy,
1915            &backends,
1916            &nodes,
1917            &policy,
1918            &FixedActivity(WorkloadActivity::Idle),
1919            None,
1920        )
1921        .await
1922        .unwrap();
1923        assert_eq!(r.slept, 1, "{:?}", r.errors);
1924        let parked = deploy
1925            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
1926            .await
1927            .unwrap();
1928        assert_eq!(parked.len(), 1);
1929        assert_eq!(parked[0].phase, ReplicaPhase::Zero);
1930        assert!(parked[0].snapshot.is_some(), "carries its snapshot");
1931        assert!(!parked[0].healthy);
1932
1933        // Idle again → stays parked (no churn).
1934        let r = reconcile_once(
1935            &deploy,
1936            &backends,
1937            &nodes,
1938            &policy,
1939            &FixedActivity(WorkloadActivity::Idle),
1940            None,
1941        )
1942        .await
1943        .unwrap();
1944        assert_eq!((r.slept, r.woke, r.launched), (0, 0, 0), "{:?}", r.errors);
1945
1946        // Active → wake it: restore → Running.
1947        let r = reconcile_once(
1948            &deploy,
1949            &backends,
1950            &nodes,
1951            &policy,
1952            &FixedActivity(WorkloadActivity::Active),
1953            None,
1954        )
1955        .await
1956        .unwrap();
1957        assert_eq!(r.woke, 1, "{:?}", r.errors);
1958        let woken = deploy
1959            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
1960            .await
1961            .unwrap();
1962        assert_eq!(woken.len(), 1);
1963        assert_eq!(woken[0].phase, ReplicaPhase::Running);
1964        assert!(woken[0].snapshot.is_none());
1965        assert!(woken[0].healthy);
1966    }
1967
1968    #[tokio::test]
1969    async fn reconcile_once_launches_converges_then_stops() {
1970        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
1971        let s = spec(1, 128);
1972        let hash = deploy.put_compute_spec(&s).await.unwrap();
1973        deploy
1974            .set_compute_workload(
1975                crate::project::ProjectRef::DEFAULT,
1976                &ComputeWorkload {
1977                    version: 1,
1978                    name: "w".into(),
1979                    active: hash.clone(),
1980                    replicas: 2,
1981                    placement: Default::default(),
1982                },
1983            )
1984            .await
1985            .unwrap();
1986        let nodes = vec![fake_node()];
1987        let mut backends: BackendRegistry = BTreeMap::new();
1988        backends.insert("fake".into(), Arc::new(FakeBackend));
1989        let policy = BackendPolicy::default();
1990
1991        // Pass 1: launches both replicas + persists their state.
1992        let r = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive, None)
1993            .await
1994            .unwrap();
1995        assert_eq!((r.launched, r.stopped), (2, 0), "{:?}", r.errors);
1996        assert!(r.errors.is_empty(), "{:?}", r.errors);
1997        let states = deploy
1998            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
1999            .await
2000            .unwrap();
2001        assert_eq!(states.len(), 2);
2002        // FA-8: each launched replica inherits its node's region tag.
2003        assert!(
2004            states.iter().all(|s| s.region.as_deref() == Some("eu")),
2005            "replicas carry their node's region"
2006        );
2007
2008        // Pass 2: already converged (FakeBackend reports Healthy) → no-op.
2009        let r2 = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive, None)
2010            .await
2011            .unwrap();
2012        assert_eq!((r2.launched, r2.stopped), (0, 0));
2013
2014        // Scale to zero → both stopped + state cleared.
2015        deploy
2016            .set_compute_workload(
2017                crate::project::ProjectRef::DEFAULT,
2018                &ComputeWorkload {
2019                    version: 1,
2020                    name: "w".into(),
2021                    active: hash,
2022                    replicas: 0,
2023                    placement: Default::default(),
2024                },
2025            )
2026            .await
2027            .unwrap();
2028        let r3 = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive, None)
2029            .await
2030            .unwrap();
2031        assert_eq!(r3.stopped, 2);
2032        assert!(deploy
2033            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2034            .await
2035            .unwrap()
2036            .is_empty());
2037    }
2038}