use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
pub use boatramp_types::compute::*;
use crate::deploy::DeployStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsolationClass {
VmKvm,
Namespace,
Container,
Platform,
}
impl IsolationClass {
pub fn is_strong(self) -> bool {
matches!(self, Self::VmKvm | Self::Platform)
}
pub fn satisfies(self, req: IsolationRequirement) -> bool {
match req {
IsolationRequirement::Trusted => true,
IsolationRequirement::Untrusted => self.is_strong(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capabilities {
pub isolation: IsolationClass,
pub scale_to_zero: bool,
pub persistent_volumes: bool,
pub max_vcpus: Option<u32>,
pub max_mem_mib: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Artifact {
VmImages {
rootfs_path: String,
kernel_path: String,
},
Rootfs {
dir: String,
},
Image {
reference: String,
},
}
#[derive(Debug, Clone)]
pub struct LaunchRequest {
pub workload: String,
pub replica: u32,
pub spec: ComputeSpec,
pub artifact: Artifact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstanceHandle {
pub workload: String,
pub replica: u32,
pub backend_ref: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scheme {
Http,
Https,
}
impl Scheme {
pub fn as_str(self) -> &'static str {
match self {
Self::Http => "http",
Self::Https => "https",
}
}
}
impl std::fmt::Display for Scheme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Endpoint {
pub scheme: Scheme,
pub host: String,
pub port: u16,
}
impl Endpoint {
pub fn url(&self) -> String {
format!("{}://{}:{}", self.scheme, self.host, self.port)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Instance {
pub handle: InstanceHandle,
pub endpoint: Endpoint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Health {
Healthy,
Unhealthy,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Snapshot {
pub workload: String,
pub replica: u32,
pub data_ref: String,
}
#[derive(Debug, thiserror::Error)]
pub enum BackendError {
#[error("operation not supported by this backend")]
Unsupported,
#[error("materialize: {0}")]
Materialize(String),
#[error("launch: {0}")]
Launch(String),
#[error("stop: {0}")]
Stop(String),
#[error("{0}")]
Other(String),
}
#[async_trait]
pub trait ComputeBackend: Send + Sync {
fn id(&self) -> &'static str;
fn capabilities(&self) -> Capabilities;
async fn materialize(&self, spec: &ComputeSpec) -> Result<Artifact, BackendError>;
async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError>;
async fn stop(&self, handle: &InstanceHandle) -> Result<(), BackendError>;
async fn health(&self, handle: &InstanceHandle) -> Result<Health, BackendError>;
async fn snapshot(&self, _handle: &InstanceHandle) -> Result<Option<Snapshot>, BackendError> {
Ok(None)
}
async fn restore(&self, _snapshot: &Snapshot) -> Result<Instance, BackendError> {
Err(BackendError::Unsupported)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BackendPolicy {
#[serde(skip_serializing_if = "Option::is_none")]
pub allow: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub forbid: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub force: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub require_strong_isolation: bool,
}
impl BackendPolicy {
pub fn permits(&self, id: &str) -> bool {
if let Some(force) = &self.force {
return id == force;
}
if self.forbid.iter().any(|x| x == id) {
return false;
}
match &self.allow {
Some(allow) => allow.iter().any(|x| x == id),
None => true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendKind {
pub id: String,
pub isolation: IsolationClass,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
pub id: u64,
pub region: Option<String>,
pub labels: BTreeMap<String, String>,
pub free_vcpus: u32,
pub free_mem_mib: u32,
pub backends: Vec<BackendKind>,
}
impl Node {
fn pick_backend(&self, spec: &ComputeSpec, policy: &BackendPolicy) -> Option<String> {
let eligible = |b: &BackendKind| {
policy.permits(&b.id)
&& b.isolation.satisfies(spec.isolation)
&& (!policy.require_strong_isolation || b.isolation.is_strong())
};
if let Some(pref) = &spec.prefer_backend {
if let Some(b) = self.backends.iter().find(|b| &b.id == pref && eligible(b)) {
return Some(b.id.clone());
}
}
self.backends
.iter()
.find(|b| eligible(b))
.map(|b| b.id.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placement {
pub node: u64,
pub backend: String,
}
pub fn place_replicas(
count: u32,
placement: &PlacementConstraints,
spec: &ComputeSpec,
nodes: &[Node],
policy: &BackendPolicy,
) -> Vec<Placement> {
let need_cpu = spec.vcpus.max(1);
let need_mem = spec.mem_mib.max(1);
let mut free: Vec<(u64, u32, u32, &Node)> = nodes
.iter()
.filter(|n| placement.allows(n.region.as_deref(), &n.labels))
.map(|n| (n.id, n.free_vcpus, n.free_mem_mib, n))
.collect();
let mut placements = Vec::new();
for _ in 0..count {
let pick = free
.iter_mut()
.filter(|(_, c, m, n)| {
*c >= need_cpu && *m >= need_mem && n.pick_backend(spec, policy).is_some()
})
.max_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)));
match pick {
Some(slot) => {
let backend = slot
.3
.pick_backend(spec, policy)
.expect("filtered to nodes with an eligible backend");
placements.push(Placement {
node: slot.0,
backend,
});
slot.1 -= need_cpu;
slot.2 -= need_mem;
}
None => break, }
}
placements
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ReplicaPhase {
#[default]
Running,
Zero,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObservedInstance {
pub handle: InstanceHandle,
pub node: u64,
pub backend: String,
pub endpoint: Endpoint,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
pub healthy: bool,
#[serde(default)]
pub phase: ReplicaPhase,
#[serde(default)]
pub snapshot: Option<Snapshot>,
}
pub const REPLICA_STATE_PREFIX: &str = "compute_state/";
pub fn replica_state_key(workload: &str, replica: u32) -> String {
format!("{REPLICA_STATE_PREFIX}{workload}/{replica}")
}
pub fn replica_state_prefix(workload: &str) -> String {
format!("{REPLICA_STATE_PREFIX}{workload}/")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Launch {
workload: String,
replica: u32,
node: u64,
backend: String,
},
Stop {
handle: InstanceHandle,
},
Snapshot {
handle: InstanceHandle,
},
Restore {
snapshot: Snapshot,
node: u64,
backend: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WorkloadActivity {
#[default]
Active,
Idle,
}
pub fn reconcile_plan(
workload: &ComputeWorkload,
spec: &ComputeSpec,
nodes: &[Node],
policy: &BackendPolicy,
observed: &[ObservedInstance],
activity: WorkloadActivity,
caps: &BTreeMap<String, Capabilities>,
) -> Vec<Action> {
let desired = workload.replicas;
let mut actions = Vec::new();
let sleeps =
|backend: &str| spec.scale_to_zero && caps.get(backend).is_some_and(|c| c.scale_to_zero);
let mut healthy: BTreeSet<u32> = BTreeSet::new();
let mut terminal: BTreeSet<u32> = BTreeSet::new(); let mut zeroed: BTreeSet<u32> = BTreeSet::new(); for inst in observed
.iter()
.filter(|i| i.handle.workload == workload.name)
{
let ord = inst.handle.replica;
if ord >= desired {
actions.push(Action::Stop {
handle: inst.handle.clone(),
});
} else if inst.phase == ReplicaPhase::Zero {
zeroed.insert(ord);
if matches!(activity, WorkloadActivity::Active) {
if let Some(snapshot) = inst.snapshot.clone() {
actions.push(Action::Restore {
snapshot,
node: inst.node,
backend: inst.backend.clone(),
});
}
}
} else if inst.healthy {
healthy.insert(ord);
if matches!(activity, WorkloadActivity::Idle) && sleeps(&inst.backend) {
actions.push(Action::Snapshot {
handle: inst.handle.clone(),
});
}
} else if matches!(spec.restart, RestartPolicy::Never) {
terminal.insert(ord); } else {
actions.push(Action::Stop {
handle: inst.handle.clone(),
});
}
}
let need: Vec<u32> = (0..desired)
.filter(|ord| !healthy.contains(ord) && !terminal.contains(ord) && !zeroed.contains(ord))
.collect();
if need.is_empty() {
return actions;
}
let placements = place_replicas(need.len() as u32, &workload.placement, spec, nodes, policy);
for (ord, place) in need.iter().zip(placements) {
actions.push(Action::Launch {
workload: workload.name.clone(),
replica: *ord,
node: place.node,
backend: place.backend,
});
}
actions
}
pub type BackendRegistry = BTreeMap<String, Arc<dyn ComputeBackend>>;
#[async_trait]
pub trait ActivitySource: Send + Sync {
async fn activity(&self, workload: &str) -> WorkloadActivity;
}
pub struct AlwaysActive;
#[async_trait]
impl ActivitySource for AlwaysActive {
async fn activity(&self, _workload: &str) -> WorkloadActivity {
WorkloadActivity::Active
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ReconcileReport {
pub launched: usize,
pub stopped: usize,
pub slept: usize,
pub woke: usize,
pub errors: Vec<String>,
}
pub async fn reconcile_once(
deploy: &DeployStore,
backends: &BackendRegistry,
nodes: &[Node],
policy: &BackendPolicy,
activity: &dyn ActivitySource,
) -> Result<ReconcileReport, crate::error::DeployError> {
let mut report = ReconcileReport::default();
let caps: BTreeMap<String, Capabilities> = backends
.iter()
.map(|(id, b)| (id.clone(), b.capabilities()))
.collect();
for workload in deploy.list_compute_workloads().await? {
let Some(spec) = deploy.get_compute_spec(&workload.active).await? else {
report
.errors
.push(format!("{}: active spec missing", workload.name));
continue;
};
let mut observed = deploy.list_replica_states(&workload.name).await?;
for state in &mut observed {
if state.phase == ReplicaPhase::Zero {
continue;
}
if let Some(backend) = backends.get(&state.backend) {
if let Ok(health) = backend.health(&state.handle).await {
state.healthy = matches!(health, Health::Healthy);
}
}
}
let workload_activity = activity.activity(&workload.name).await;
for action in reconcile_plan(
&workload,
&spec,
nodes,
policy,
&observed,
workload_activity,
&caps,
) {
match action {
Action::Launch {
workload: wl,
replica,
node,
backend,
} => {
let Some(b) = backends.get(&backend) else {
report
.errors
.push(format!("{wl}/{replica}: no backend {backend:?}"));
continue;
};
let node_region = region_of_node(nodes, node);
match launch_one(b.as_ref(), &wl, replica, node, node_region, &spec).await {
Ok(state) => match deploy.set_replica_state(&state).await {
Ok(()) => report.launched += 1,
Err(e) => report.errors.push(format!("{wl}/{replica}: persist: {e}")),
},
Err(e) => report.errors.push(format!("{wl}/{replica}: launch: {e}")),
}
}
Action::Stop { handle } => {
if let Some(b) = observed
.iter()
.find(|o| o.handle == handle)
.and_then(|o| backends.get(&o.backend))
{
if let Err(e) = b.stop(&handle).await {
report
.errors
.push(format!("{}/{}: stop: {e}", handle.workload, handle.replica));
}
}
match deploy
.delete_replica_state(&handle.workload, handle.replica)
.await
{
Ok(()) => report.stopped += 1,
Err(e) => report.errors.push(format!(
"{}/{}: forget: {e}",
handle.workload, handle.replica
)),
}
}
Action::Snapshot { handle } => {
let Some(obs) = observed.iter().find(|o| o.handle == handle).cloned() else {
continue; };
let Some(b) = backends.get(&obs.backend) else {
report.errors.push(format!(
"{}/{}: no backend {:?}",
handle.workload, handle.replica, obs.backend
));
continue;
};
match b.snapshot(&handle).await {
Ok(Some(snapshot)) => {
let parked = ObservedInstance {
healthy: false,
phase: ReplicaPhase::Zero,
snapshot: Some(snapshot),
..obs
};
match deploy.set_replica_state(&parked).await {
Ok(()) => report.slept += 1,
Err(e) => report.errors.push(format!(
"{}/{}: persist zero: {e}",
handle.workload, handle.replica
)),
}
}
Ok(None) => {}
Err(e) => report.errors.push(format!(
"{}/{}: snapshot: {e}",
handle.workload, handle.replica
)),
}
}
Action::Restore {
snapshot,
node,
backend,
} => {
let Some(b) = backends.get(&backend) else {
report.errors.push(format!(
"{}/{}: no backend {backend:?}",
snapshot.workload, snapshot.replica
));
continue;
};
match b.restore(&snapshot).await {
Ok(instance) => {
let state = ObservedInstance {
handle: instance.handle,
node,
backend: backend.clone(),
endpoint: instance.endpoint,
region: region_of_node(nodes, node),
healthy: true,
phase: ReplicaPhase::Running,
snapshot: None,
};
match deploy.set_replica_state(&state).await {
Ok(()) => report.woke += 1,
Err(e) => report.errors.push(format!(
"{}/{}: persist running: {e}",
snapshot.workload, snapshot.replica
)),
}
}
Err(e) => report.errors.push(format!(
"{}/{}: restore: {e}",
snapshot.workload, snapshot.replica
)),
}
}
}
}
}
Ok(report)
}
fn region_of_node(nodes: &[Node], id: u64) -> Option<String> {
nodes
.iter()
.find(|n| n.id == id)
.and_then(|n| n.region.clone())
}
async fn launch_one(
backend: &dyn ComputeBackend,
workload: &str,
replica: u32,
node: u64,
node_region: Option<String>,
spec: &ComputeSpec,
) -> Result<ObservedInstance, BackendError> {
let artifact = backend.materialize(spec).await?;
let instance = backend
.launch(&LaunchRequest {
workload: workload.to_string(),
replica,
spec: spec.clone(),
artifact,
})
.await?;
Ok(ObservedInstance {
handle: instance.handle,
node,
backend: backend.id().to_string(),
endpoint: instance.endpoint,
region: node_region,
healthy: true,
phase: ReplicaPhase::Running,
snapshot: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
ComputeSpec {
version: 1,
rootfs: "r".repeat(64),
kernel: "k".repeat(64),
kernel_cmdline: None,
vcpus,
mem_mib,
entrypoint: vec![],
env: BTreeMap::new(),
port: 80,
restart: RestartPolicy::Always,
scale_to_zero: false,
volumes: vec![],
isolation: IsolationRequirement::Trusted,
prefer_backend: None,
}
}
fn workload(replicas: u32, placement: PlacementConstraints) -> ComputeWorkload {
ComputeWorkload {
version: 1,
name: "w".into(),
active: "h".into(),
replicas,
placement,
}
}
fn node(
id: u64,
region: &str,
cpus: u32,
mem: u32,
backends: &[(&str, IsolationClass)],
) -> Node {
Node {
id,
region: Some(region.into()),
labels: BTreeMap::new(),
free_vcpus: cpus,
free_mem_mib: mem,
backends: backends
.iter()
.map(|(id, iso)| BackendKind {
id: (*id).to_string(),
isolation: *iso,
})
.collect(),
}
}
fn vmm(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
node(id, region, cpus, mem, &[("vmm", IsolationClass::VmKvm)])
}
fn container(id: u64, region: &str, cpus: u32, mem: u32) -> Node {
node(
id,
region,
cpus,
mem,
&[("container", IsolationClass::Namespace)],
)
}
#[test]
fn isolation_class_strength_and_satisfaction() {
assert!(IsolationClass::VmKvm.is_strong());
assert!(IsolationClass::Platform.is_strong());
assert!(!IsolationClass::Namespace.is_strong());
assert!(!IsolationClass::Container.is_strong());
assert!(IsolationClass::Namespace.satisfies(IsolationRequirement::Trusted));
assert!(!IsolationClass::Namespace.satisfies(IsolationRequirement::Untrusted));
assert!(IsolationClass::VmKvm.satisfies(IsolationRequirement::Untrusted));
}
#[test]
fn endpoint_url() {
assert_eq!(
Endpoint {
scheme: Scheme::Http,
host: "10.0.0.5".into(),
port: 8080
}
.url(),
"http://10.0.0.5:8080"
);
}
#[test]
fn policy_permits_force_forbid_allow() {
assert!(BackendPolicy::default().permits("vmm"));
let forbid = BackendPolicy {
forbid: vec!["container".into()],
..Default::default()
};
assert!(forbid.permits("vmm"));
assert!(!forbid.permits("container"));
let allow = BackendPolicy {
allow: Some(vec!["vmm".into()]),
..Default::default()
};
assert!(allow.permits("vmm"));
assert!(!allow.permits("docker"));
let force = BackendPolicy {
force: Some("vmm".into()),
forbid: vec!["vmm".into()],
..Default::default()
};
assert!(force.permits("vmm"), "force overrides forbid");
assert!(!force.permits("container"));
}
#[test]
fn worst_fit_spreads_and_picks_a_backend() {
let nodes = vec![vmm(1, "eu", 4, 4096), vmm(2, "eu", 4, 4096)];
let placed = place_replicas(
2,
&PlacementConstraints::default(),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
);
assert_eq!(placed.len(), 2);
assert_ne!(placed[0].node, placed[1].node, "worst-fit → one each");
assert!(placed.iter().all(|p| p.backend == "vmm"));
}
#[test]
fn capacity_shortfall_returns_fewer() {
let nodes = vec![vmm(1, "eu", 4, 8192)];
let placed = place_replicas(
5,
&PlacementConstraints::default(),
&spec(2, 256),
&nodes,
&BackendPolicy::default(),
);
assert_eq!(placed.len(), 2, "only two 2-vCPU replicas fit");
}
#[test]
fn untrusted_skips_shared_kernel_nodes() {
let nodes = vec![container(1, "eu", 8, 8192)];
let mut s = spec(1, 128);
s.isolation = IsolationRequirement::Untrusted;
assert!(place_replicas(
2,
&PlacementConstraints::default(),
&s,
&nodes,
&BackendPolicy::default()
)
.is_empty());
let nodes = vec![vmm(1, "eu", 8, 8192)];
let placed = place_replicas(
2,
&PlacementConstraints::default(),
&s,
&nodes,
&BackendPolicy::default(),
);
assert_eq!(placed.len(), 2);
assert!(placed.iter().all(|p| p.backend == "vmm"));
}
#[test]
fn strict_posture_skips_shared_kernel_even_for_trusted_workload() {
let nodes = vec![container(1, "eu", 8, 8192)];
let s = spec(1, 128); assert_eq!(
place_replicas(
2,
&PlacementConstraints::default(),
&s,
&nodes,
&BackendPolicy::default()
)
.len(),
2,
"a trusted workload uses the shared-kernel node by default"
);
let strict = BackendPolicy {
require_strong_isolation: true,
..Default::default()
};
assert!(
place_replicas(2, &PlacementConstraints::default(), &s, &nodes, &strict).is_empty(),
"strict posture refuses shared-kernel even for a trusted workload"
);
let vnodes = vec![vmm(1, "eu", 8, 8192)];
assert_eq!(
place_replicas(2, &PlacementConstraints::default(), &s, &vnodes, &strict).len(),
2
);
}
#[test]
fn prefer_backend_is_honored_when_eligible() {
let n = node(
1,
"eu",
8,
8192,
&[
("vmm", IsolationClass::VmKvm),
("container", IsolationClass::Namespace),
],
);
let mut s = spec(1, 128);
s.prefer_backend = Some("container".into());
let placed = place_replicas(
1,
&PlacementConstraints::default(),
&s,
&[n],
&BackendPolicy::default(),
);
assert_eq!(placed[0].backend, "container");
}
#[test]
fn policy_force_overrides_preference() {
let n = node(
1,
"eu",
8,
8192,
&[
("vmm", IsolationClass::VmKvm),
("container", IsolationClass::Namespace),
],
);
let mut s = spec(1, 128);
s.prefer_backend = Some("container".into());
let policy = BackendPolicy {
force: Some("vmm".into()),
..Default::default()
};
let placed = place_replicas(1, &PlacementConstraints::default(), &s, &[n], &policy);
assert_eq!(
placed[0].backend, "vmm",
"policy force beats the spec preference"
);
}
fn observed(workload: &str, replica: u32, node: u64, healthy: bool) -> ObservedInstance {
ObservedInstance {
handle: InstanceHandle {
workload: workload.into(),
replica,
backend_ref: format!("ref-{replica}"),
},
node,
backend: "vmm".into(),
endpoint: Endpoint {
scheme: Scheme::Http,
host: "10.0.0.2".into(),
port: 80,
},
region: None,
healthy,
phase: ReplicaPhase::Running,
snapshot: None,
}
}
fn zeroed(workload: &str, replica: u32, node: u64) -> ObservedInstance {
let mut o = observed(workload, replica, node, false);
o.phase = ReplicaPhase::Zero;
o.snapshot = Some(Snapshot {
workload: workload.into(),
replica,
data_ref: format!("snap-{replica}"),
});
o
}
fn plan(
wl: &ComputeWorkload,
spec: &ComputeSpec,
nodes: &[Node],
policy: &BackendPolicy,
observed: &[ObservedInstance],
) -> Vec<Action> {
reconcile_plan(
wl,
spec,
nodes,
policy,
observed,
WorkloadActivity::Active,
&BTreeMap::new(),
)
}
fn s2z_caps() -> BTreeMap<String, Capabilities> {
let mut m = BTreeMap::new();
m.insert(
"vmm".to_string(),
Capabilities {
isolation: IsolationClass::VmKvm,
scale_to_zero: true,
persistent_volumes: false,
max_vcpus: None,
max_mem_mib: None,
},
);
m
}
fn s2z_spec() -> ComputeSpec {
let mut s = spec(1, 256);
s.scale_to_zero = true;
s
}
#[test]
fn idle_running_replica_is_snapshotted_when_scale_to_zero() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![observed("w", 0, 1, true)];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s2z_spec(),
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Idle,
&s2z_caps(),
);
assert_eq!(actions.len(), 1);
assert!(matches!(&actions[0], Action::Snapshot { handle } if handle.replica == 0));
}
#[test]
fn idle_replica_not_snapshotted_without_opt_in_or_capability() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![observed("w", 0, 1, true)];
let no_cap = reconcile_plan(
&workload(1, Default::default()),
&s2z_spec(),
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Idle,
&BTreeMap::new(),
);
assert!(no_cap.is_empty(), "no capable backend: {no_cap:?}");
let no_opt = reconcile_plan(
&workload(1, Default::default()),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Idle,
&s2z_caps(),
);
assert!(no_opt.is_empty(), "not opted in: {no_opt:?}");
}
#[test]
fn zeroed_replica_wakes_on_activity() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![zeroed("w", 0, 1)];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s2z_spec(),
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Active,
&s2z_caps(),
);
assert_eq!(actions.len(), 1);
assert!(
matches!(&actions[0], Action::Restore { snapshot, node, .. } if snapshot.replica == 0 && *node == 1)
);
}
#[test]
fn zeroed_replica_stays_parked_when_idle_and_is_not_relaunched() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![zeroed("w", 0, 1)];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s2z_spec(),
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Idle,
&s2z_caps(),
);
assert!(
actions.is_empty(),
"parked replica left untouched: {actions:?}"
);
}
#[test]
fn out_of_range_zeroed_replica_is_stopped_not_restored() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![zeroed("w", 1, 1)]; let actions = reconcile_plan(
&workload(1, Default::default()),
&s2z_spec(),
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Active,
&s2z_caps(),
);
assert!(actions
.iter()
.any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
assert!(
!actions.iter().any(|a| matches!(a, Action::Restore { .. })),
"out-of-range parked replica is stopped, not restored"
);
}
#[test]
fn reconcile_scales_up_from_nothing() {
let nodes = vec![vmm(1, "eu", 8, 8192), vmm(2, "eu", 8, 8192)];
let actions = plan(
&workload(2, Default::default()),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
&[],
);
let launches: Vec<u32> = actions
.iter()
.filter_map(|a| match a {
Action::Launch { replica, .. } => Some(*replica),
_ => None,
})
.collect();
assert_eq!(launches, vec![0, 1], "both ordinals launched");
assert!(!actions.iter().any(|a| matches!(a, Action::Stop { .. })));
}
#[test]
fn reconcile_is_noop_when_at_desired() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, true)];
let actions = plan(
&workload(2, Default::default()),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
&obs,
);
assert!(actions.is_empty(), "already converged");
}
#[test]
fn reconcile_scales_down_stops_out_of_range() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![
observed("w", 0, 1, true),
observed("w", 1, 1, true),
observed("w", 2, 1, true),
];
let actions = plan(
&workload(2, Default::default()),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
&obs,
);
assert_eq!(actions.len(), 1);
assert!(matches!(&actions[0], Action::Stop { handle } if handle.replica == 2));
}
#[test]
fn reconcile_replaces_unhealthy_when_restart_always() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
let actions = plan(
&workload(2, Default::default()),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
&obs,
);
assert!(actions
.iter()
.any(|a| matches!(a, Action::Stop { handle } if handle.replica == 1)));
assert!(actions
.iter()
.any(|a| matches!(a, Action::Launch { replica: 1, .. })));
}
#[test]
fn reconcile_leaves_terminal_replicas_for_restart_never() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let mut s = spec(1, 256);
s.restart = RestartPolicy::Never;
let obs = vec![observed("w", 0, 1, true), observed("w", 1, 1, false)];
let actions = plan(
&workload(2, Default::default()),
&s,
&nodes,
&BackendPolicy::default(),
&obs,
);
assert!(
actions.is_empty(),
"run-to-completion replica is terminal: {actions:?}"
);
}
#[test]
fn reconcile_only_touches_its_own_workload() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let obs = vec![observed("other", 0, 1, true), observed("other", 5, 1, true)];
let actions = plan(
&workload(1, Default::default()),
&spec(1, 256),
&nodes,
&BackendPolicy::default(),
&obs,
);
assert_eq!(actions.len(), 1);
assert!(
matches!(&actions[0], Action::Launch { workload, replica: 0, .. } if workload == "w")
);
}
struct FakeBackend;
#[async_trait]
impl ComputeBackend for FakeBackend {
fn id(&self) -> &'static str {
"fake"
}
fn capabilities(&self) -> Capabilities {
Capabilities {
isolation: IsolationClass::Namespace,
scale_to_zero: false,
persistent_volumes: false,
max_vcpus: None,
max_mem_mib: None,
}
}
async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
Ok(Artifact::Image {
reference: "img:latest".into(),
})
}
async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
Ok(Instance {
handle: InstanceHandle {
workload: req.workload.clone(),
replica: req.replica,
backend_ref: format!("fake-{}", req.replica),
},
endpoint: Endpoint {
scheme: Scheme::Http,
host: "127.0.0.1".into(),
port: 8080,
},
})
}
async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
Ok(())
}
async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
Ok(Health::Healthy)
}
}
#[tokio::test]
async fn fake_backend_round_trips_through_the_trait() {
let backend: Box<dyn ComputeBackend> = Box::new(FakeBackend);
assert_eq!(backend.id(), "fake");
let s = spec(1, 128);
let artifact = backend.materialize(&s).await.unwrap();
let inst = backend
.launch(&LaunchRequest {
workload: "w".into(),
replica: 0,
spec: s,
artifact,
})
.await
.unwrap();
assert_eq!(inst.endpoint.url(), "http://127.0.0.1:8080");
assert_eq!(backend.health(&inst.handle).await.unwrap(), Health::Healthy);
backend.stop(&inst.handle).await.unwrap();
assert!(backend.snapshot(&inst.handle).await.unwrap().is_none());
}
struct NullStorage;
#[async_trait]
impl crate::Storage for NullStorage {
async fn get(&self, _: &str) -> Result<crate::GetObject, crate::StorageError> {
Err(crate::StorageError::NotFound(String::new()))
}
async fn get_range(
&self,
_: &str,
_: u64,
_: Option<u64>,
) -> Result<crate::GetObject, crate::StorageError> {
Err(crate::StorageError::NotFound(String::new()))
}
async fn put(
&self,
_: &str,
_: crate::ByteStream,
_: crate::PutMeta,
) -> Result<crate::ObjectMeta, crate::StorageError> {
Err(crate::StorageError::unsupported("null"))
}
async fn head(&self, _: &str) -> Result<crate::ObjectMeta, crate::StorageError> {
Err(crate::StorageError::NotFound(String::new()))
}
async fn delete(&self, _: &str) -> Result<(), crate::StorageError> {
Ok(())
}
async fn list(&self, _: &str) -> Result<Vec<crate::ObjectMeta>, crate::StorageError> {
Ok(Vec::new())
}
}
fn fake_node() -> Node {
Node {
id: 1,
region: Some("eu".into()),
labels: BTreeMap::new(),
free_vcpus: 8,
free_mem_mib: 8192,
backends: vec![BackendKind {
id: "fake".into(),
isolation: IsolationClass::Namespace,
}],
}
}
struct S2zBackend;
#[async_trait]
impl ComputeBackend for S2zBackend {
fn id(&self) -> &'static str {
"fake"
}
fn capabilities(&self) -> Capabilities {
Capabilities {
isolation: IsolationClass::Namespace,
scale_to_zero: true,
persistent_volumes: false,
max_vcpus: None,
max_mem_mib: None,
}
}
async fn materialize(&self, _spec: &ComputeSpec) -> Result<Artifact, BackendError> {
Ok(Artifact::Image {
reference: "img:latest".into(),
})
}
async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
Ok(Instance {
handle: InstanceHandle {
workload: req.workload.clone(),
replica: req.replica,
backend_ref: format!("fake-{}", req.replica),
},
endpoint: Endpoint {
scheme: Scheme::Http,
host: "127.0.0.1".into(),
port: 8080,
},
})
}
async fn stop(&self, _handle: &InstanceHandle) -> Result<(), BackendError> {
Ok(())
}
async fn health(&self, _handle: &InstanceHandle) -> Result<Health, BackendError> {
Ok(Health::Healthy)
}
async fn snapshot(
&self,
handle: &InstanceHandle,
) -> Result<Option<Snapshot>, BackendError> {
Ok(Some(Snapshot {
workload: handle.workload.clone(),
replica: handle.replica,
data_ref: format!("snap-{}", handle.replica),
}))
}
async fn restore(&self, snapshot: &Snapshot) -> Result<Instance, BackendError> {
Ok(Instance {
handle: InstanceHandle {
workload: snapshot.workload.clone(),
replica: snapshot.replica,
backend_ref: format!("restored-{}", snapshot.replica),
},
endpoint: Endpoint {
scheme: Scheme::Http,
host: "127.0.0.1".into(),
port: 8080,
},
})
}
}
struct FixedActivity(WorkloadActivity);
#[async_trait]
impl ActivitySource for FixedActivity {
async fn activity(&self, _workload: &str) -> WorkloadActivity {
self.0
}
}
#[tokio::test]
async fn reconcile_sleeps_idle_replica_then_wakes_it_on_activity() {
let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
let mut s = spec(1, 128);
s.scale_to_zero = true;
let hash = deploy.put_compute_spec(&s).await.unwrap();
deploy
.set_compute_workload(&ComputeWorkload {
version: 1,
name: "w".into(),
active: hash,
replicas: 1,
placement: Default::default(),
})
.await
.unwrap();
let mut backends: BackendRegistry = BTreeMap::new();
backends.insert("fake".into(), Arc::new(S2zBackend));
let nodes = vec![fake_node()];
let policy = BackendPolicy::default();
let r = reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&FixedActivity(WorkloadActivity::Active),
)
.await
.unwrap();
assert_eq!(r.launched, 1, "{:?}", r.errors);
let r = reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&FixedActivity(WorkloadActivity::Idle),
)
.await
.unwrap();
assert_eq!(r.slept, 1, "{:?}", r.errors);
let parked = deploy.list_replica_states("w").await.unwrap();
assert_eq!(parked.len(), 1);
assert_eq!(parked[0].phase, ReplicaPhase::Zero);
assert!(parked[0].snapshot.is_some(), "carries its snapshot");
assert!(!parked[0].healthy);
let r = reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&FixedActivity(WorkloadActivity::Idle),
)
.await
.unwrap();
assert_eq!((r.slept, r.woke, r.launched), (0, 0, 0), "{:?}", r.errors);
let r = reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&FixedActivity(WorkloadActivity::Active),
)
.await
.unwrap();
assert_eq!(r.woke, 1, "{:?}", r.errors);
let woken = deploy.list_replica_states("w").await.unwrap();
assert_eq!(woken.len(), 1);
assert_eq!(woken[0].phase, ReplicaPhase::Running);
assert!(woken[0].snapshot.is_none());
assert!(woken[0].healthy);
}
#[tokio::test]
async fn reconcile_once_launches_converges_then_stops() {
let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(crate::kv::MemoryKv::new()));
let s = spec(1, 128);
let hash = deploy.put_compute_spec(&s).await.unwrap();
deploy
.set_compute_workload(&ComputeWorkload {
version: 1,
name: "w".into(),
active: hash.clone(),
replicas: 2,
placement: Default::default(),
})
.await
.unwrap();
let nodes = vec![fake_node()];
let mut backends: BackendRegistry = BTreeMap::new();
backends.insert("fake".into(), Arc::new(FakeBackend));
let policy = BackendPolicy::default();
let r = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive)
.await
.unwrap();
assert_eq!((r.launched, r.stopped), (2, 0), "{:?}", r.errors);
assert!(r.errors.is_empty(), "{:?}", r.errors);
let states = deploy.list_replica_states("w").await.unwrap();
assert_eq!(states.len(), 2);
assert!(
states.iter().all(|s| s.region.as_deref() == Some("eu")),
"replicas carry their node's region"
);
let r2 = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive)
.await
.unwrap();
assert_eq!((r2.launched, r2.stopped), (0, 0));
deploy
.set_compute_workload(&ComputeWorkload {
version: 1,
name: "w".into(),
active: hash,
replicas: 0,
placement: Default::default(),
})
.await
.unwrap();
let r3 = reconcile_once(&deploy, &backends, &nodes, &policy, &AlwaysActive)
.await
.unwrap();
assert_eq!(r3.stopped, 2);
assert!(deploy.list_replica_states("w").await.unwrap().is_empty());
}
}