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/// Injects **server-initialization env** (`POSTGRES_*` / `MYSQL_*`) into a compute
721/// workload that a handler `sql` binding manages (PLAN-managed-compute-sql, Phase
722/// 2). The reverse of [`ComputeBindingResolver`]: that wires a *guest* to reach
723/// boatramp's shims; this wires boatramp's managed **credential** into a *database
724/// server* the guest then connects to. Given `(project, workload)` it returns the
725/// env the DB image reads on first boot to create boatramp's user/password/database
726/// — empty when `workload` is not a managed database. **Idempotent**: the credential
727/// is generated once + sealed, then stable, so it is safe to call on every launch
728/// (the DB, initialized with it, keeps accepting the same password across restarts).
729/// The concrete impl lives in `boatramp-node`, where the handler sql config + the
730/// sealed-credential store are; the reconcile only calls this trait.
731#[async_trait]
732pub trait ManagedDbEnvResolver: Send + Sync {
733    /// Server-init env for `workload` if it is a managed database, else empty.
734    async fn managed_db_env(&self, project: &str, workload: &str) -> Vec<(String, String)>;
735
736    /// The privilege strategy that lets `workload`'s stock DB image initialize on a
737    /// shared-kernel backend, or `None` if `workload` is not a managed database.
738    /// Sync + defaulted so a non-DB resolver needs no change. The reconcile applies it
739    /// to the **launch** spec only (never the stored one), and only when the operator
740    /// has not already set `user`/`cap_add`.
741    fn managed_db_privilege(&self, _project: &str, _workload: &str) -> Option<PrivilegeDirective> {
742        None
743    }
744}
745
746/// How a managed database is made able to initialize its stock image on a shared-kernel
747/// backend despite the dropped-`ALL` default. Applied to the launch spec by the
748/// reconcile (see [`ManagedDbEnvResolver::managed_db_privilege`]).
749#[derive(Debug, Clone, PartialEq, Eq)]
750pub enum PrivilegeDirective {
751    /// Run rootless as `uid:gid` (the image's DB user) against its pre-owned volume —
752    /// needs no capabilities and works under any posture. The preferred default.
753    Rootless { uid: u32, gid: u32 },
754    /// Grant these capabilities back (short names, no `CAP_` prefix). Single-tenant
755    /// only — the backend's posture gate drops them under the multi-tenant guard.
756    Caps(Vec<String>),
757}
758
759impl PrivilegeDirective {
760    /// Apply this directive to a **launch** `spec`, without overriding a value the
761    /// operator set explicitly (an operator `user`/`cap_add` always wins).
762    pub fn apply(&self, spec: &mut ComputeSpec) {
763        match self {
764            Self::Rootless { uid, gid } if spec.user.is_none() => {
765                spec.user = Some(format!("{uid}:{gid}"));
766            }
767            Self::Caps(caps) if spec.cap_add.is_empty() => {
768                spec.cap_add = caps.clone();
769            }
770            _ => {}
771        }
772    }
773}
774
775/// One reconcile pass: for every workload, refresh replica health, compute the
776/// plan ([`reconcile_plan`]), and execute it against the chosen backends —
777/// launching/stopping replicas and persisting their observed state (which the
778/// gateway reads as the upstream pool). Per-action failures are collected (not
779/// fatal) so one bad workload can't stall the rest; a top-level KV failure
780/// aborts the pass. The caller leader-gates this (cron-style).
781///
782/// For now the chosen backend is invoked locally (the leader also runs it).
783/// Cross-node dispatch via messaging is a later refinement.
784pub async fn reconcile_once(
785    deploy: &DeployStore,
786    backends: &BackendRegistry,
787    nodes: &[Node],
788    policy: &BackendPolicy,
789    activity: &dyn ActivitySource,
790    resolver: Option<&dyn ComputeBindingResolver>,
791    managed_db: Option<&dyn ManagedDbEnvResolver>,
792) -> Result<ReconcileReport, crate::error::DeployError> {
793    let mut report = ReconcileReport::default();
794    // Per-backend capabilities (the planner gates scale-to-zero on them).
795    let caps: BTreeMap<String, Capabilities> = backends
796        .iter()
797        .map(|(id, b)| (id.clone(), b.capabilities()))
798        .collect();
799    // Fan out over every project's workloads (compute is project-scoped in 0.2.0).
800    // The owning project threads through every replica-state read/write below.
801    for (project_name, workload) in deploy.list_compute_workloads_all().await? {
802        let project = ProjectRef::new(&project_name);
803        let Some(spec) = deploy.get_compute_spec(&workload.active).await? else {
804            report
805                .errors
806                .push(format!("{}: active spec missing", workload.name));
807            continue;
808        };
809
810        // Observed replica state + a health refresh (skipping parked Zero
811        // replicas — they're intentionally down).
812        let mut observed = deploy.list_replica_states(project, &workload.name).await?;
813        for state in &mut observed {
814            if state.phase == ReplicaPhase::Zero {
815                continue;
816            }
817            if let Some(backend) = backends.get(&state.backend) {
818                if let Ok(health) = backend.health(&state.handle).await {
819                    state.healthy = matches!(health, Health::Healthy);
820                }
821            }
822        }
823
824        // Keep the shim registry populated for every running replica (idempotent),
825        // so a workload's bindings keep working across a server restart while the
826        // guest is still up.
827        if let Some(resolver) = resolver {
828            if !spec.bindings.is_empty() {
829                for state in &observed {
830                    if state.phase == ReplicaPhase::Running {
831                        resolver
832                            .resolve(
833                                &project_name,
834                                &workload.name,
835                                state.handle.replica,
836                                &spec.bindings,
837                            )
838                            .await;
839                    }
840                }
841            }
842        }
843
844        let workload_activity = activity.activity(&workload.name).await;
845        for action in reconcile_plan(
846            &workload,
847            &spec,
848            nodes,
849            policy,
850            &observed,
851            workload_activity,
852            &caps,
853        ) {
854            match action {
855                Action::Launch {
856                    workload: wl,
857                    replica,
858                    node,
859                    backend,
860                } => {
861                    let Some(b) = backends.get(&backend) else {
862                        report
863                            .errors
864                            .push(format!("{wl}/{replica}: no backend {backend:?}"));
865                        continue;
866                    };
867                    let node_region = region_of_node(nodes, node);
868                    // Resolve declared bindings → env injected into the guest (registers
869                    // the shim token for this replica).
870                    let mut launch_env = match resolver {
871                        Some(r) if !spec.bindings.is_empty() => {
872                            r.resolve(&project_name, &wl, replica, &spec.bindings).await
873                        }
874                        _ => Vec::new(),
875                    };
876                    // If this workload is a managed database, inject its server-init
877                    // env (POSTGRES_*/MYSQL_*) from the sealed managed credential, so it
878                    // initializes on first boot with the user/password the handler will
879                    // connect as. Empty for a non-managed workload (idempotent).
880                    if let Some(m) = managed_db {
881                        launch_env.extend(m.managed_db_env(&project_name, &wl).await);
882                    }
883                    // A managed DB also gets a privilege strategy (rootless user or a
884                    // cap allowlist) so its stock image can init on a shared-kernel
885                    // backend; applied to the launch spec only, and only where the
886                    // operator has not set `user`/`cap_add` already.
887                    let privilege =
888                        managed_db.and_then(|m| m.managed_db_privilege(&project_name, &wl));
889                    match launch_one(
890                        b.as_ref(),
891                        &wl,
892                        replica,
893                        node,
894                        node_region,
895                        &spec,
896                        &launch_env,
897                        privilege.as_ref(),
898                    )
899                    .await
900                    {
901                        Ok(state) => match deploy.set_replica_state(project, &state).await {
902                            Ok(()) => report.launched += 1,
903                            Err(e) => report.errors.push(format!("{wl}/{replica}: persist: {e}")),
904                        },
905                        Err(e) => report.errors.push(format!("{wl}/{replica}: launch: {e}")),
906                    }
907                }
908                Action::Stop { handle } => {
909                    if let Some(b) = observed
910                        .iter()
911                        .find(|o| o.handle == handle)
912                        .and_then(|o| backends.get(&o.backend))
913                    {
914                        if let Err(e) = b.stop(&handle).await {
915                            report
916                                .errors
917                                .push(format!("{}/{}: stop: {e}", handle.workload, handle.replica));
918                        }
919                    }
920                    match deploy
921                        .delete_replica_state(project, &handle.workload, handle.replica)
922                        .await
923                    {
924                        Ok(()) => report.stopped += 1,
925                        Err(e) => report.errors.push(format!(
926                            "{}/{}: forget: {e}",
927                            handle.workload, handle.replica
928                        )),
929                    }
930                    // Revoke this replica's shim tokens.
931                    if let Some(resolver) = resolver {
932                        if !spec.bindings.is_empty() {
933                            resolver
934                                .release(
935                                    &project_name,
936                                    &handle.workload,
937                                    handle.replica,
938                                    &spec.bindings,
939                                )
940                                .await;
941                        }
942                    }
943                }
944                Action::Snapshot { handle } => {
945                    let Some(obs) = observed.iter().find(|o| o.handle == handle).cloned() else {
946                        continue; // vanished between plan + execute
947                    };
948                    let Some(b) = backends.get(&obs.backend) else {
949                        report.errors.push(format!(
950                            "{}/{}: no backend {:?}",
951                            handle.workload, handle.replica, obs.backend
952                        ));
953                        continue;
954                    };
955                    match b.snapshot(&handle).await {
956                        // Park it: persist the Zero phase carrying the snapshot
957                        // (the backend's `snapshot` already stopped the replica).
958                        Ok(Some(snapshot)) => {
959                            let parked = ObservedInstance {
960                                healthy: false,
961                                phase: ReplicaPhase::Zero,
962                                snapshot: Some(snapshot),
963                                ..obs
964                            };
965                            match deploy.set_replica_state(project, &parked).await {
966                                Ok(()) => report.slept += 1,
967                                Err(e) => report.errors.push(format!(
968                                    "{}/{}: persist zero: {e}",
969                                    handle.workload, handle.replica
970                                )),
971                            }
972                        }
973                        // Backend declined (e.g. not running) — leave it as is.
974                        Ok(None) => {}
975                        Err(e) => report.errors.push(format!(
976                            "{}/{}: snapshot: {e}",
977                            handle.workload, handle.replica
978                        )),
979                    }
980                }
981                Action::Restore {
982                    snapshot,
983                    node,
984                    backend,
985                } => {
986                    let Some(b) = backends.get(&backend) else {
987                        report.errors.push(format!(
988                            "{}/{}: no backend {backend:?}",
989                            snapshot.workload, snapshot.replica
990                        ));
991                        continue;
992                    };
993                    match b.restore(&snapshot).await {
994                        Ok(instance) => {
995                            let state = ObservedInstance {
996                                handle: instance.handle,
997                                node,
998                                backend: backend.clone(),
999                                endpoint: instance.endpoint,
1000                                region: region_of_node(nodes, node),
1001                                healthy: true,
1002                                phase: ReplicaPhase::Running,
1003                                snapshot: None,
1004                            };
1005                            match deploy.set_replica_state(project, &state).await {
1006                                Ok(()) => report.woke += 1,
1007                                Err(e) => report.errors.push(format!(
1008                                    "{}/{}: persist running: {e}",
1009                                    snapshot.workload, snapshot.replica
1010                                )),
1011                            }
1012                        }
1013                        Err(e) => report.errors.push(format!(
1014                            "{}/{}: restore: {e}",
1015                            snapshot.workload, snapshot.replica
1016                        )),
1017                    }
1018                }
1019            }
1020        }
1021    }
1022    Ok(report)
1023}
1024
1025/// The region of node `id` in `nodes`, for tagging a replica's endpoint (FA-8).
1026fn region_of_node(nodes: &[Node], id: u64) -> Option<String> {
1027    nodes
1028        .iter()
1029        .find(|n| n.id == id)
1030        .and_then(|n| n.region.clone())
1031}
1032
1033/// Materialize + launch one replica, returning its observed state.
1034#[allow(clippy::too_many_arguments)]
1035async fn launch_one(
1036    backend: &dyn ComputeBackend,
1037    workload: &str,
1038    replica: u32,
1039    node: u64,
1040    node_region: Option<String>,
1041    spec: &ComputeSpec,
1042    extra_env: &[(String, String)],
1043    privilege: Option<&PrivilegeDirective>,
1044) -> Result<ObservedInstance, BackendError> {
1045    let artifact = backend.materialize(spec).await?;
1046    // Fold the resolved binding env into the launched spec. The workload's own env
1047    // wins on a collision, so a hand-set value is never clobbered by a binding.
1048    let mut spec = spec.clone();
1049    for (k, v) in extra_env {
1050        spec.env.entry(k.clone()).or_insert_with(|| v.clone());
1051    }
1052    // A managed-DB privilege strategy (rootless user / cap allowlist) — launch spec
1053    // only; never overrides an operator-set `user`/`cap_add`.
1054    if let Some(p) = privilege {
1055        p.apply(&mut spec);
1056    }
1057    let instance = backend
1058        .launch(&LaunchRequest {
1059            workload: workload.to_string(),
1060            replica,
1061            spec: spec.clone(),
1062            artifact,
1063        })
1064        .await?;
1065    Ok(ObservedInstance {
1066        handle: instance.handle,
1067        node,
1068        backend: backend.id().to_string(),
1069        endpoint: instance.endpoint,
1070        region: node_region,
1071        healthy: true,
1072        phase: ReplicaPhase::Running,
1073        snapshot: None,
1074    })
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080
1081    #[test]
1082    fn privilege_directive_applies_without_overriding_operator_values() {
1083        // Rootless sets `user` when unset.
1084        let mut s = spec(1, 64);
1085        PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
1086        assert_eq!(s.user.as_deref(), Some("999:999"));
1087        assert!(s.cap_add.is_empty());
1088
1089        // An operator-set `user` is never overridden.
1090        let mut s = spec(1, 64);
1091        s.user = Some("1000".into());
1092        PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
1093        assert_eq!(s.user.as_deref(), Some("1000"));
1094
1095        // Caps fills `cap_add` when empty…
1096        let mut s = spec(1, 64);
1097        PrivilegeDirective::Caps(vec!["CHOWN".into(), "SETUID".into()]).apply(&mut s);
1098        assert_eq!(s.cap_add, vec!["CHOWN".to_string(), "SETUID".to_string()]);
1099
1100        // …but not over an operator-set allowlist.
1101        let mut s = spec(1, 64);
1102        s.cap_add = vec!["NET_BIND_SERVICE".into()];
1103        PrivilegeDirective::Caps(vec!["CHOWN".into()]).apply(&mut s);
1104        assert_eq!(s.cap_add, vec!["NET_BIND_SERVICE".to_string()]);
1105    }
1106
1107    fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
1108        ComputeSpec {
1109            version: 1,
1110            root: RootSource::Rootfs("r".repeat(64)),
1111            kernel: "k".repeat(64),
1112            kernel_cmdline: None,
1113            vcpus,
1114            mem_mib,
1115            entrypoint: vec![],
1116            env: BTreeMap::new(),
1117            port: 80,
1118            restart: RestartPolicy::Always,
1119            scale_to_zero: false,
1120            volumes: vec![],
1121            writable_root: false,
1122            cap_add: Vec::new(),
1123            user: None,
1124            isolation: IsolationRequirement::Trusted,
1125            prefer_backend: None,
1126            bindings: vec![],
1127        }
1128    }
1129
1130    fn workload(replicas: u32, placement: PlacementConstraints) -> ComputeWorkload {
1131        ComputeWorkload {
1132            version: 1,
1133            name: "w".into(),
1134            active: "h".into(),
1135            replicas,
1136            placement,
1137        }
1138    }
1139
1140    fn node(
1141        id: u64,
1142        region: &str,
1143        cpus: u32,
1144        mem: u32,
1145        backends: &[(&str, IsolationClass)],
1146    ) -> Node {
1147        Node {
1148            id,
1149            region: Some(region.into()),
1150            labels: BTreeMap::new(),
1151            free_vcpus: cpus,
1152            free_mem_mib: mem,
1153            backends: backends
1154                .iter()
1155                .map(|(id, iso)| BackendKind {
1156                    id: (*id).to_string(),
1157                    isolation: *iso,
1158                    // Fully-capable fixture: the volume / scale-to-zero gates only
1159                    // *refuse* on absent capability, so a capable fixture leaves every
1160                    // existing placement test unaffected; negative cases build their
1161                    // own incapable `BackendKind`.
1162                    persistent_volumes: true,
1163                    scale_to_zero: true,
1164                })
1165                .collect(),
1166        }
1167    }
1168
1169    fn vmm(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1170        node(id, region, cpus, mem, &[("vmm", IsolationClass::VmKvm)])
1171    }
1172
1173    fn container(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
1174        node(
1175            id,
1176            region,
1177            cpus,
1178            mem,
1179            &[("container", IsolationClass::Namespace)],
1180        )
1181    }
1182
1183    #[test]
1184    fn isolation_class_strength_and_satisfaction() {
1185        assert!(IsolationClass::VmKvm.is_strong());
1186        assert!(IsolationClass::Platform.is_strong());
1187        assert!(!IsolationClass::Namespace.is_strong());
1188        assert!(!IsolationClass::Container.is_strong());
1189        // Untrusted needs strong; trusted accepts any.
1190        assert!(IsolationClass::Namespace.satisfies(IsolationRequirement::Trusted));
1191        assert!(!IsolationClass::Namespace.satisfies(IsolationRequirement::Untrusted));
1192        assert!(IsolationClass::VmKvm.satisfies(IsolationRequirement::Untrusted));
1193    }
1194
1195    #[test]
1196    fn endpoint_url() {
1197        assert_eq!(
1198            Endpoint {
1199                scheme: Scheme::Http,
1200                host: "10.0.0.5".into(),
1201                port: 8080
1202            }
1203            .url(),
1204            "http://10.0.0.5:8080"
1205        );
1206    }
1207
1208    #[test]
1209    fn policy_permits_force_forbid_allow() {
1210        assert!(BackendPolicy::default().permits("vmm"));
1211        let forbid = BackendPolicy {
1212            forbid: vec!["container".into()],
1213            ..Default::default()
1214        };
1215        assert!(forbid.permits("vmm"));
1216        assert!(!forbid.permits("container"));
1217        let allow = BackendPolicy {
1218            allow: Some(vec!["vmm".into()]),
1219            ..Default::default()
1220        };
1221        assert!(allow.permits("vmm"));
1222        assert!(!allow.permits("docker"));
1223        let force = BackendPolicy {
1224            force: Some("vmm".into()),
1225            forbid: vec!["vmm".into()],
1226            ..Default::default()
1227        };
1228        assert!(force.permits("vmm"), "force overrides forbid");
1229        assert!(!force.permits("container"));
1230    }
1231
1232    #[test]
1233    fn policy_from_shared_kernel_allowed_maps_posture_to_strong_isolation() {
1234        // Strict posture (shared-kernel disallowed) ⇒ require strong isolation.
1235        assert!(BackendPolicy::from_shared_kernel_allowed(false).require_strong_isolation);
1236        // Permissive posture ⇒ the default (no strong-isolation requirement).
1237        let permissive = BackendPolicy::from_shared_kernel_allowed(true);
1238        assert!(!permissive.require_strong_isolation);
1239        assert_eq!(permissive, BackendPolicy::default());
1240    }
1241
1242    #[test]
1243    fn worst_fit_spreads_and_picks_a_backend() {
1244        let nodes = vec![vmm(1, "eu", 4, 4096), vmm(2, "eu", 4, 4096)];
1245        let placed = place_replicas(
1246            2,
1247            &PlacementConstraints::default(),
1248            &spec(1, 256),
1249            &nodes,
1250            &BackendPolicy::default(),
1251        );
1252        assert_eq!(placed.len(), 2);
1253        assert_ne!(placed[0].node, placed[1].node, "worst-fit → one each");
1254        assert!(placed.iter().all(|p| p.backend == "vmm"));
1255    }
1256
1257    #[test]
1258    fn capacity_shortfall_returns_fewer() {
1259        let nodes = vec![vmm(1, "eu", 4, 8192)];
1260        let placed = place_replicas(
1261            5,
1262            &PlacementConstraints::default(),
1263            &spec(2, 256),
1264            &nodes,
1265            &BackendPolicy::default(),
1266        );
1267        assert_eq!(placed.len(), 2, "only two 2-vCPU replicas fit");
1268    }
1269
1270    #[test]
1271    fn untrusted_skips_shared_kernel_nodes() {
1272        // A container-only node can't satisfy an untrusted workload.
1273        let nodes = vec![container(1, "eu", 8, 8192)];
1274        let mut s = spec(1, 128);
1275        s.isolation = IsolationRequirement::Untrusted;
1276        assert!(place_replicas(
1277            2,
1278            &PlacementConstraints::default(),
1279            &s,
1280            &nodes,
1281            &BackendPolicy::default()
1282        )
1283        .is_empty());
1284        // A vmm node satisfies it.
1285        let nodes = vec![vmm(1, "eu", 8, 8192)];
1286        let placed = place_replicas(
1287            2,
1288            &PlacementConstraints::default(),
1289            &s,
1290            &nodes,
1291            &BackendPolicy::default(),
1292        );
1293        assert_eq!(placed.len(), 2);
1294        assert!(placed.iter().all(|p| p.backend == "vmm"));
1295    }
1296
1297    /// A node offering one backend with the given capabilities — for the negative
1298    /// gate cases (the shared fixtures are deliberately fully-capable).
1299    fn node_with_caps(id: &str, iso: IsolationClass, volumes: bool, s2z: bool) -> Node {
1300        Node {
1301            id: 1,
1302            region: Some("eu".into()),
1303            labels: BTreeMap::new(),
1304            free_vcpus: 8,
1305            free_mem_mib: 8192,
1306            backends: vec![BackendKind {
1307                id: id.into(),
1308                isolation: iso,
1309                persistent_volumes: volumes,
1310                scale_to_zero: s2z,
1311            }],
1312        }
1313    }
1314
1315    #[test]
1316    fn volume_spec_needs_a_volume_capable_backend() {
1317        let mut s = spec(1, 128);
1318        s.volumes = vec![VolumeRef {
1319            mount: "/data".into(),
1320            name: "db".into(),
1321            size_mib: 64,
1322        }];
1323        // A backend that can't back volumes ⇒ no placement (fail loud, not
1324        // silently storage-less).
1325        let no_vol = vec![node_with_caps(
1326            "container",
1327            IsolationClass::Namespace,
1328            false,
1329            false,
1330        )];
1331        assert!(
1332            place_replicas(
1333                1,
1334                &PlacementConstraints::default(),
1335                &s,
1336                &no_vol,
1337                &BackendPolicy::default()
1338            )
1339            .is_empty(),
1340            "a volume spec must not place on a volume-incapable backend"
1341        );
1342        // A volume-capable backend places it.
1343        let vol_ok = vec![node_with_caps("vmm", IsolationClass::VmKvm, true, false)];
1344        assert_eq!(
1345            place_replicas(
1346                1,
1347                &PlacementConstraints::default(),
1348                &s,
1349                &vol_ok,
1350                &BackendPolicy::default()
1351            )
1352            .len(),
1353            1
1354        );
1355    }
1356
1357    #[test]
1358    fn scale_to_zero_spec_needs_a_capable_backend() {
1359        let mut s = spec(1, 128);
1360        s.scale_to_zero = true;
1361        // A backend that can't scale to zero ⇒ no placement, rather than silently
1362        // running always-on.
1363        let no_s2z = vec![node_with_caps(
1364            "docker",
1365            IsolationClass::Container,
1366            false,
1367            false,
1368        )];
1369        assert!(
1370            place_replicas(
1371                1,
1372                &PlacementConstraints::default(),
1373                &s,
1374                &no_s2z,
1375                &BackendPolicy::default()
1376            )
1377            .is_empty(),
1378            "a scale-to-zero spec must not place on a scale-to-zero-incapable backend"
1379        );
1380        // A scale-to-zero-capable backend places it.
1381        let s2z_ok = vec![node_with_caps(
1382            "container",
1383            IsolationClass::Namespace,
1384            false,
1385            true,
1386        )];
1387        assert_eq!(
1388            place_replicas(
1389                1,
1390                &PlacementConstraints::default(),
1391                &s,
1392                &s2z_ok,
1393                &BackendPolicy::default()
1394            )
1395            .len(),
1396            1
1397        );
1398    }
1399
1400    #[test]
1401    fn strict_posture_skips_shared_kernel_even_for_trusted_workload() {
1402        // A Trusted (possibly misclassified) workload normally lands
1403        // on a shared-kernel container node...
1404        let nodes = vec![container(1, "eu", 8, 8192)];
1405        let s = spec(1, 128); // default isolation = Trusted
1406        assert_eq!(
1407            place_replicas(
1408                2,
1409                &PlacementConstraints::default(),
1410                &s,
1411                &nodes,
1412                &BackendPolicy::default()
1413            )
1414            .len(),
1415            2,
1416            "a trusted workload uses the shared-kernel node by default"
1417        );
1418        // ...but the strict posture makes shared-kernel ineligible regardless.
1419        let strict = BackendPolicy {
1420            require_strong_isolation: true,
1421            ..Default::default()
1422        };
1423        assert!(
1424            place_replicas(2, &PlacementConstraints::default(), &s, &nodes, &strict).is_empty(),
1425            "strict posture refuses shared-kernel even for a trusted workload"
1426        );
1427        // A vmm (strong) node still satisfies it under the strict posture.
1428        let vnodes = vec![vmm(1, "eu", 8, 8192)];
1429        assert_eq!(
1430            place_replicas(2, &PlacementConstraints::default(), &s, &vnodes, &strict).len(),
1431            2
1432        );
1433    }
1434
1435    #[test]
1436    fn prefer_backend_is_honored_when_eligible() {
1437        let n = node(
1438            1,
1439            "eu",
1440            8,
1441            8192,
1442            &[
1443                ("vmm", IsolationClass::VmKvm),
1444                ("container", IsolationClass::Namespace),
1445            ],
1446        );
1447        let mut s = spec(1, 128);
1448        s.prefer_backend = Some("container".into());
1449        let placed = place_replicas(
1450            1,
1451            &PlacementConstraints::default(),
1452            &s,
1453            &[n],
1454            &BackendPolicy::default(),
1455        );
1456        assert_eq!(placed[0].backend, "container");
1457    }
1458
1459    #[test]
1460    fn policy_force_overrides_preference() {
1461        let n = node(
1462            1,
1463            "eu",
1464            8,
1465            8192,
1466            &[
1467                ("vmm", IsolationClass::VmKvm),
1468                ("container", IsolationClass::Namespace),
1469            ],
1470        );
1471        let mut s = spec(1, 128);
1472        s.prefer_backend = Some("container".into());
1473        let policy = BackendPolicy {
1474            force: Some("vmm".into()),
1475            ..Default::default()
1476        };
1477        let placed = place_replicas(1, &PlacementConstraints::default(), &s, &[n], &policy);
1478        assert_eq!(
1479            placed[0].backend, "vmm",
1480            "policy force beats the spec preference"
1481        );
1482    }
1483
1484    fn observed(workload: &str, replica: u32, node: u64, healthy: bool) -> ObservedInstance {
1485        ObservedInstance {
1486            handle: InstanceHandle {
1487                workload: workload.into(),
1488                replica,
1489                backend_ref: format!("ref-{replica}"),
1490            },
1491            node,
1492            backend: "vmm".into(),
1493            endpoint: Endpoint {
1494                scheme: Scheme::Http,
1495                host: "10.0.0.2".into(),
1496                port: 80,
1497            },
1498            region: None,
1499            healthy,
1500            phase: ReplicaPhase::Running,
1501            snapshot: None,
1502        }
1503    }
1504
1505    /// A scaled-to-zero observed replica (phase `Zero` + a snapshot to wake from).
1506    fn zeroed(workload: &str, replica: u32, node: u64) -> ObservedInstance {
1507        let mut o = observed(workload, replica, node, false);
1508        o.phase = ReplicaPhase::Zero;
1509        o.snapshot = Some(Snapshot {
1510            workload: workload.into(),
1511            replica,
1512            data_ref: format!("snap-{replica}"),
1513        });
1514        o
1515    }
1516
1517    /// Wrapper for the baseline tests: `Active` activity + no scale-to-zero
1518    /// capable backends, so the sleep/wake paths stay inert (behavior unchanged).
1519    fn plan(
1520        wl: &ComputeWorkload,
1521        spec: &ComputeSpec,
1522        nodes: &[Node],
1523        policy: &BackendPolicy,
1524        observed: &[ObservedInstance],
1525    ) -> Vec<Action> {
1526        reconcile_plan(
1527            wl,
1528            spec,
1529            nodes,
1530            policy,
1531            observed,
1532            WorkloadActivity::Active,
1533            &BTreeMap::new(),
1534        )
1535    }
1536
1537    /// A capability map advertising scale-to-zero for the `vmm` backend (the id
1538    /// the `observed`/`zeroed` helpers use).
1539    fn s2z_caps() -> BTreeMap<String, Capabilities> {
1540        let mut m = BTreeMap::new();
1541        m.insert(
1542            "vmm".to_string(),
1543            Capabilities {
1544                isolation: IsolationClass::VmKvm,
1545                scale_to_zero: true,
1546                persistent_volumes: false,
1547                max_vcpus: None,
1548                max_mem_mib: None,
1549            },
1550        );
1551        m
1552    }
1553
1554    /// A spec that opts into scale-to-zero.
1555    fn s2z_spec() -> ComputeSpec {
1556        let mut s = spec(1, 256);
1557        s.scale_to_zero = true;
1558        s
1559    }
1560
1561    #[test]
1562    fn idle_running_replica_is_snapshotted_when_scale_to_zero() {
1563        let nodes = vec![vmm(1, "eu", 8, 8192)];
1564        let obs = vec![observed("w", 0, 1, true)];
1565        let actions = reconcile_plan(
1566            &workload(1, Default::default()),
1567            &s2z_spec(),
1568            &nodes,
1569            &BackendPolicy::default(),
1570            &obs,
1571            WorkloadActivity::Idle,
1572            &s2z_caps(),
1573        );
1574        assert_eq!(actions.len(), 1);
1575        assert!(matches!(&actions[0], Action::Snapshot { handle } if handle.replica == 0));
1576    }
1577
1578    #[test]
1579    fn idle_replica_not_snapshotted_without_opt_in_or_capability() {
1580        let nodes = vec![vmm(1, "eu", 8, 8192)];
1581        let obs = vec![observed("w", 0, 1, true)];
1582        // Opted in, but the backend isn't capable → no snapshot.
1583        let no_cap = reconcile_plan(
1584            &workload(1, Default::default()),
1585            &s2z_spec(),
1586            &nodes,
1587            &BackendPolicy::default(),
1588            &obs,
1589            WorkloadActivity::Idle,
1590            &BTreeMap::new(),
1591        );
1592        assert!(no_cap.is_empty(), "no capable backend: {no_cap:?}");
1593        // Capable backend, but the spec didn't opt in → no snapshot.
1594        let no_opt = reconcile_plan(
1595            &workload(1, Default::default()),
1596            &spec(1, 256),
1597            &nodes,
1598            &BackendPolicy::default(),
1599            &obs,
1600            WorkloadActivity::Idle,
1601            &s2z_caps(),
1602        );
1603        assert!(no_opt.is_empty(), "not opted in: {no_opt:?}");
1604    }
1605
1606    #[test]
1607    fn zeroed_replica_wakes_on_activity() {
1608        let nodes = vec![vmm(1, "eu", 8, 8192)];
1609        let obs = vec![zeroed("w", 0, 1)];
1610        let actions = reconcile_plan(
1611            &workload(1, Default::default()),
1612            &s2z_spec(),
1613            &nodes,
1614            &BackendPolicy::default(),
1615            &obs,
1616            WorkloadActivity::Active,
1617            &s2z_caps(),
1618        );
1619        assert_eq!(actions.len(), 1);
1620        assert!(
1621            matches!(&actions[0], Action::Restore { snapshot, node, .. } if snapshot.replica == 0 && *node == 1)
1622        );
1623    }
1624
1625    #[test]
1626    fn zeroed_replica_stays_parked_when_idle_and_is_not_relaunched() {
1627        let nodes = vec![vmm(1, "eu", 8, 8192)];
1628        let obs = vec![zeroed("w", 0, 1)];
1629        let actions = reconcile_plan(
1630            &workload(1, Default::default()),
1631            &s2z_spec(),
1632            &nodes,
1633            &BackendPolicy::default(),
1634            &obs,
1635            WorkloadActivity::Idle,
1636            &s2z_caps(),
1637        );
1638        // Idle → no restore, and crucially no Launch (the parked ordinal is not
1639        // treated as a missing replica).
1640        assert!(
1641            actions.is_empty(),
1642            "parked replica left untouched: {actions:?}"
1643        );
1644    }
1645
1646    #[test]
1647    fn out_of_range_zeroed_replica_is_stopped_not_restored() {
1648        let nodes = vec![vmm(1, "eu", 8, 8192)];
1649        let obs = vec![zeroed("w", 1, 1)]; // ordinal 1, desired 1 → out of range
1650        let actions = reconcile_plan(
1651            &workload(1, Default::default()),
1652            &s2z_spec(),
1653            &nodes,
1654            &BackendPolicy::default(),
1655            &obs,
1656            WorkloadActivity::Active,
1657            &s2z_caps(),
1658        );
1659        assert!(actions
1660            .iter()
1661            .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1662        assert!(
1663            !actions.iter().any(|a| matches!(a, Action::Restore { .. })),
1664            "out-of-range parked replica is stopped, not restored"
1665        );
1666    }
1667
1668    #[test]
1669    fn reconcile_scales_up_from_nothing() {
1670        let nodes = vec![vmm(1, "eu", 8, 8192), vmm(2, "eu", 8, 8192)];
1671        let actions = plan(
1672            &workload(2, Default::default()),
1673            &spec(1, 256),
1674            &nodes,
1675            &BackendPolicy::default(),
1676            &[],
1677        );
1678        let launches: Vec<u32> = actions
1679            .iter()
1680            .filter_map(|a| match a {
1681                Action::Launch { replica, .. } => Some(*replica),
1682                _ => None,
1683            })
1684            .collect();
1685        assert_eq!(launches, vec![0, 1], "both ordinals launched");
1686        assert!(!actions.iter().any(|a| matches!(a, Action::Stop { .. })));
1687    }
1688
1689    #[test]
1690    fn reconcile_is_noop_when_at_desired() {
1691        let nodes = vec![vmm(1, "eu", 8, 8192)];
1692        let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, true)];
1693        let actions = plan(
1694            &workload(2, Default::default()),
1695            &spec(1, 256),
1696            &nodes,
1697            &BackendPolicy::default(),
1698            &obs,
1699        );
1700        assert!(actions.is_empty(), "already converged");
1701    }
1702
1703    #[test]
1704    fn reconcile_scales_down_stops_out_of_range() {
1705        let nodes = vec![vmm(1, "eu", 8, 8192)];
1706        let obs = vec![
1707            observed("w", 0, 1, true),
1708            observed("w", 1, 1, true),
1709            observed("w", 2, 1, true),
1710        ];
1711        let actions = plan(
1712            &workload(2, Default::default()),
1713            &spec(1, 256),
1714            &nodes,
1715            &BackendPolicy::default(),
1716            &obs,
1717        );
1718        assert_eq!(actions.len(), 1);
1719        assert!(matches!(&actions[0], Action::Stop { handle } if handle.replica == 2));
1720    }
1721
1722    #[test]
1723    fn reconcile_replaces_unhealthy_when_restart_always() {
1724        let nodes = vec![vmm(1, "eu", 8, 8192)];
1725        let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1726        let actions = plan(
1727            &workload(2, Default::default()),
1728            &spec(1, 256),
1729            &nodes,
1730            &BackendPolicy::default(),
1731            &obs,
1732        );
1733        // ordinal 1 is stopped AND relaunched.
1734        assert!(actions
1735            .iter()
1736            .any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
1737        assert!(actions
1738            .iter()
1739            .any(|a| matches!(a, Action::Launch { replica: 1, .. })));
1740    }
1741
1742    #[test]
1743    fn reconcile_leaves_terminal_replicas_for_restart_never() {
1744        let nodes = vec![vmm(1, "eu", 8, 8192)];
1745        let mut s = spec(1, 256);
1746        s.restart = RestartPolicy::Never;
1747        let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
1748        let actions = plan(
1749            &workload(2, Default::default()),
1750            &s,
1751            &nodes,
1752            &BackendPolicy::default(),
1753            &obs,
1754        );
1755        // The exited (unhealthy) Never replica is left alone — no stop, no relaunch.
1756        assert!(
1757            actions.is_empty(),
1758            "run-to-completion replica is terminal: {actions:?}"
1759        );
1760    }
1761
1762    #[test]
1763    fn reconcile_only_touches_its_own_workload() {
1764        let nodes = vec![vmm(1, "eu", 8, 8192)];
1765        let obs = vec![observed("other", 0, 1, true), observed("other", 5, 1, true)];
1766        let actions = plan(
1767            &workload(1, Default::default()),
1768            &spec(1, 256),
1769            &nodes,
1770            &BackendPolicy::default(),
1771            &obs,
1772        );
1773        // Launches ordinal 0 for "w"; ignores "other"'s replicas entirely.
1774        assert_eq!(actions.len(), 1);
1775        assert!(
1776            matches!(&actions[0], Action::Launch { workload, replica: 0, .. } if workload == "w")
1777        );
1778    }
1779
1780    // A trivial in-memory backend, exercising the trait end-to-end.
1781    struct FakeBackend;
1782
1783    #[async_trait]
1784    impl ComputeBackend for FakeBackend {
1785        fn id(&self) -> &'static str {
1786            "fake"
1787        }
1788        fn capabilities(&self) -> Capabilities {
1789            Capabilities {
1790                isolation: IsolationClass::Namespace,
1791                scale_to_zero: false,
1792                persistent_volumes: false,
1793                max_vcpus: None,
1794                max_mem_mib: None,
1795            }
1796        }
1797        async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1798            Ok(Artifact::Image {
1799                reference: "img:latest".into(),
1800            })
1801        }
1802        async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1803            Ok(Instance {
1804                handle: InstanceHandle {
1805                    workload: req.workload.clone(),
1806                    replica: req.replica,
1807                    backend_ref: format!("fake-{}", req.replica),
1808                },
1809                endpoint: Endpoint {
1810                    scheme: Scheme::Http,
1811                    host: "127.0.0.1".into(),
1812                    port: 8080,
1813                },
1814            })
1815        }
1816        async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1817            Ok(())
1818        }
1819        async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1820            Ok(Health::Healthy)
1821        }
1822    }
1823
1824    #[tokio::test]
1825    async fn fake_backend_round_trips_through_the_trait() {
1826        let backend: Box<dyn ComputeBackend> = Box::new(FakeBackend);
1827        assert_eq!(backend.id(), "fake");
1828        let s = spec(1, 128);
1829        let artifact = backend.materialize(&s).await.unwrap();
1830        let inst = backend
1831            .launch(&LaunchRequest {
1832                workload: "w".into(),
1833                replica: 0,
1834                spec: s,
1835                artifact,
1836            })
1837            .await
1838            .unwrap();
1839        assert_eq!(inst.endpoint.url(), "http://127.0.0.1:8080");
1840        assert_eq!(backend.health(&inst.handle).await.unwrap(), Health::Healthy);
1841        backend.stop(&inst.handle).await.unwrap();
1842        // Default snapshot/restore: unsupported.
1843        assert!(backend.snapshot(&inst.handle).await.unwrap().is_none());
1844    }
1845
1846    /// A do-nothing blob backend so the driver test can build a `DeployStore`
1847    /// (the reconcile loop only touches the KV-backed methods).
1848    struct NullStorage;
1849
1850    #[async_trait]
1851    impl crate::Storage for NullStorage {
1852        async fn get(&self, _: &str) -> Result<crate::GetObject, crate::StorageError> {
1853            Err(crate::StorageError::NotFound(String::new()))
1854        }
1855        async fn get_range(
1856            &self,
1857            _: &str,
1858            _: u64,
1859            _: Option<u64>,
1860        ) -> Result<crate::GetObject, crate::StorageError> {
1861            Err(crate::StorageError::NotFound(String::new()))
1862        }
1863        async fn put(
1864            &self,
1865            _: &str,
1866            _: crate::ByteStream,
1867            _: crate::PutMeta,
1868        ) -> Result<crate::ObjectMeta, crate::StorageError> {
1869            Err(crate::StorageError::unsupported("null"))
1870        }
1871        async fn head(&self, _: &str) -> Result<crate::ObjectMeta, crate::StorageError> {
1872            Err(crate::StorageError::NotFound(String::new()))
1873        }
1874        async fn delete(&self, _: &str) -> Result<(), crate::StorageError> {
1875            Ok(())
1876        }
1877        async fn list(&self, _: &str) -> Result<Vec<crate::ObjectMeta>, crate::StorageError> {
1878            Ok(Vec::new())
1879        }
1880    }
1881
1882    fn fake_node() -> Node {
1883        Node {
1884            id: 1,
1885            region: Some("eu".into()),
1886            labels: BTreeMap::new(),
1887            free_vcpus: 8,
1888            free_mem_mib: 8192,
1889            backends: vec![BackendKind {
1890                id: "fake".into(),
1891                isolation: IsolationClass::Namespace,
1892                // Fully-capable so the scale-to-zero reconcile tests (which reuse this
1893                // fixture) still place; negative gate tests build their own node.
1894                persistent_volumes: true,
1895                scale_to_zero: true,
1896            }],
1897        }
1898    }
1899
1900    /// A scale-to-zero-capable backend: `snapshot` always parks (returns a
1901    /// snapshot), `restore` brings it back. Reuses id `"fake"` (the node's
1902    /// backend) so placement still works.
1903    struct S2zBackend;
1904
1905    #[async_trait]
1906    impl ComputeBackend for S2zBackend {
1907        fn id(&self) -> &'static str {
1908            "fake"
1909        }
1910        fn capabilities(&self) -> Capabilities {
1911            Capabilities {
1912                isolation: IsolationClass::Namespace,
1913                scale_to_zero: true,
1914                persistent_volumes: false,
1915                max_vcpus: None,
1916                max_mem_mib: None,
1917            }
1918        }
1919        async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
1920            Ok(Artifact::Image {
1921                reference: "img:latest".into(),
1922            })
1923        }
1924        async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
1925            Ok(Instance {
1926                handle: InstanceHandle {
1927                    workload: req.workload.clone(),
1928                    replica: req.replica,
1929                    backend_ref: format!("fake-{}", req.replica),
1930                },
1931                endpoint: Endpoint {
1932                    scheme: Scheme::Http,
1933                    host: "127.0.0.1".into(),
1934                    port: 8080,
1935                },
1936            })
1937        }
1938        async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
1939            Ok(())
1940        }
1941        async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
1942            Ok(Health::Healthy)
1943        }
1944        async fn snapshot(
1945            &self,
1946            handle: &InstanceHandle,
1947        ) -> Result<Option<Snapshot>, BackendError> {
1948            Ok(Some(Snapshot {
1949                workload: handle.workload.clone(),
1950                replica: handle.replica,
1951                data_ref: format!("snap-{}", handle.replica),
1952            }))
1953        }
1954        async fn restore(&self, snapshot: &Snapshot) -> Result<Instance, BackendError> {
1955            Ok(Instance {
1956                handle: InstanceHandle {
1957                    workload: snapshot.workload.clone(),
1958                    replica: snapshot.replica,
1959                    backend_ref: format!("restored-{}", snapshot.replica),
1960                },
1961                endpoint: Endpoint {
1962                    scheme: Scheme::Http,
1963                    host: "127.0.0.1".into(),
1964                    port: 8080,
1965                },
1966            })
1967        }
1968    }
1969
1970    /// An [`ActivitySource`] that reports the same activity for every workload.
1971    struct FixedActivity(WorkloadActivity);
1972
1973    #[async_trait]
1974    impl ActivitySource for FixedActivity {
1975        async fn activity(&self, _workload: &str) -> WorkloadActivity {
1976            self.0
1977        }
1978    }
1979
1980    #[tokio::test]
1981    async fn reconcile_sleeps_idle_replica_then_wakes_it_on_activity() {
1982        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
1983        let mut s = spec(1, 128);
1984        s.scale_to_zero = true;
1985        let hash = deploy.put_compute_spec(&s).await.unwrap();
1986        deploy
1987            .set_compute_workload(
1988                crate::project::ProjectRef::DEFAULT,
1989                &ComputeWorkload {
1990                    version: 1,
1991                    name: "w".into(),
1992                    active: hash,
1993                    replicas: 1,
1994                    placement: Default::default(),
1995                },
1996            )
1997            .await
1998            .unwrap();
1999        let mut backends: BackendRegistry = BTreeMap::new();
2000        backends.insert("fake".into(), Arc::new(S2zBackend));
2001        let nodes = vec![fake_node()];
2002        let policy = BackendPolicy::default();
2003
2004        // Active → launch the replica.
2005        let r = reconcile_once(
2006            &deploy,
2007            &backends,
2008            &nodes,
2009            &policy,
2010            &FixedActivity(WorkloadActivity::Active),
2011            None,
2012            None,
2013        )
2014        .await
2015        .unwrap();
2016        assert_eq!(r.launched, 1, "{:?}", r.errors);
2017
2018        // Idle → sleep it: snapshot + park in Zero.
2019        let r = reconcile_once(
2020            &deploy,
2021            &backends,
2022            &nodes,
2023            &policy,
2024            &FixedActivity(WorkloadActivity::Idle),
2025            None,
2026            None,
2027        )
2028        .await
2029        .unwrap();
2030        assert_eq!(r.slept, 1, "{:?}", r.errors);
2031        let parked = deploy
2032            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2033            .await
2034            .unwrap();
2035        assert_eq!(parked.len(), 1);
2036        assert_eq!(parked[0].phase, ReplicaPhase::Zero);
2037        assert!(parked[0].snapshot.is_some(), "carries its snapshot");
2038        assert!(!parked[0].healthy);
2039
2040        // Idle again → stays parked (no churn).
2041        let r = reconcile_once(
2042            &deploy,
2043            &backends,
2044            &nodes,
2045            &policy,
2046            &FixedActivity(WorkloadActivity::Idle),
2047            None,
2048            None,
2049        )
2050        .await
2051        .unwrap();
2052        assert_eq!((r.slept, r.woke, r.launched), (0, 0, 0), "{:?}", r.errors);
2053
2054        // Active → wake it: restore → Running.
2055        let r = reconcile_once(
2056            &deploy,
2057            &backends,
2058            &nodes,
2059            &policy,
2060            &FixedActivity(WorkloadActivity::Active),
2061            None,
2062            None,
2063        )
2064        .await
2065        .unwrap();
2066        assert_eq!(r.woke, 1, "{:?}", r.errors);
2067        let woken = deploy
2068            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2069            .await
2070            .unwrap();
2071        assert_eq!(woken.len(), 1);
2072        assert_eq!(woken[0].phase, ReplicaPhase::Running);
2073        assert!(woken[0].snapshot.is_none());
2074        assert!(woken[0].healthy);
2075    }
2076
2077    #[tokio::test]
2078    async fn reconcile_once_launches_converges_then_stops() {
2079        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
2080        let s = spec(1, 128);
2081        let hash = deploy.put_compute_spec(&s).await.unwrap();
2082        deploy
2083            .set_compute_workload(
2084                crate::project::ProjectRef::DEFAULT,
2085                &ComputeWorkload {
2086                    version: 1,
2087                    name: "w".into(),
2088                    active: hash.clone(),
2089                    replicas: 2,
2090                    placement: Default::default(),
2091                },
2092            )
2093            .await
2094            .unwrap();
2095        let nodes = vec![fake_node()];
2096        let mut backends: BackendRegistry = BTreeMap::new();
2097        backends.insert("fake".into(), Arc::new(FakeBackend));
2098        let policy = BackendPolicy::default();
2099
2100        // Pass 1: launches both replicas + persists their state.
2101        let r = reconcile_once(
2102            &deploy,
2103            &backends,
2104            &nodes,
2105            &policy,
2106            &AlwaysActive,
2107            None,
2108            None,
2109        )
2110        .await
2111        .unwrap();
2112        assert_eq!((r.launched, r.stopped), (2, 0), "{:?}", r.errors);
2113        assert!(r.errors.is_empty(), "{:?}", r.errors);
2114        let states = deploy
2115            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2116            .await
2117            .unwrap();
2118        assert_eq!(states.len(), 2);
2119        // FA-8: each launched replica inherits its node's region tag.
2120        assert!(
2121            states.iter().all(|s| s.region.as_deref() == Some("eu")),
2122            "replicas carry their node's region"
2123        );
2124
2125        // Pass 2: already converged (FakeBackend reports Healthy) → no-op.
2126        let r2 = reconcile_once(
2127            &deploy,
2128            &backends,
2129            &nodes,
2130            &policy,
2131            &AlwaysActive,
2132            None,
2133            None,
2134        )
2135        .await
2136        .unwrap();
2137        assert_eq!((r2.launched, r2.stopped), (0, 0));
2138
2139        // Scale to zero → both stopped + state cleared.
2140        deploy
2141            .set_compute_workload(
2142                crate::project::ProjectRef::DEFAULT,
2143                &ComputeWorkload {
2144                    version: 1,
2145                    name: "w".into(),
2146                    active: hash,
2147                    replicas: 0,
2148                    placement: Default::default(),
2149                },
2150            )
2151            .await
2152            .unwrap();
2153        let r3 = reconcile_once(
2154            &deploy,
2155            &backends,
2156            &nodes,
2157            &policy,
2158            &AlwaysActive,
2159            None,
2160            None,
2161        )
2162        .await
2163        .unwrap();
2164        assert_eq!(r3.stopped, 2);
2165        assert!(deploy
2166            .list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
2167            .await
2168            .unwrap()
2169            .is_empty());
2170    }
2171}