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;
use crate::project::ProjectRef;
#[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 project: String,
pub workload: String,
pub replica: u32,
pub spec: ComputeSpec,
pub artifact: Artifact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstanceHandle {
#[serde(default)]
pub project: String,
pub workload: String,
pub replica: u32,
pub backend_ref: String,
}
pub fn compute_instance_id(project: &str, workload: &str, replica: u32) -> String {
if project.is_empty() || project == crate::project::DEFAULT_PROJECT {
format!("{workload}-{replica}")
} else {
format!("{project}-{workload}-{replica}")
}
}
#[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 {
#[serde(default)]
pub project: String,
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),
}
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub exit_code: i32,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum ExecError {
#[error("workload {0:?} has no running replica to exec in")]
NoReplica(String),
#[error("the {0} backend does not support exec")]
Unsupported(String),
#[error("exec failed: {0}")]
Other(String),
}
#[async_trait]
pub trait ComputeExec: Send + Sync {
async fn exec(
&self,
project: &str,
workload: &str,
argv: &[String],
stdin: Option<&[u8]>,
) -> Result<ExecOutput, ExecError>;
}
#[derive(Debug, thiserror::Error)]
pub enum ControlError {
#[error("the {0} backend does not support this operation")]
Unsupported(String),
#[error("compute control failed: {0}")]
Other(String),
}
#[async_trait]
pub trait ComputeControl: Send + Sync {
async fn restart(
&self,
project: &str,
workload: &str,
replica: u32,
) -> Result<bool, ControlError>;
}
#[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 reserve_in_use(&self, _replicas: &[(String, String, u32, std::net::Ipv4Addr)]) {}
async fn gc_ip_pool(&self, _parked: &[(String, String, u32)]) {}
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)
}
async fn exec(
&self,
_handle: &InstanceHandle,
_argv: &[String],
_stdin: Option<&[u8]>,
) -> Result<ExecOutput, BackendError> {
Err(BackendError::Unsupported)
}
async fn list_volumes(&self) -> Result<Vec<VolumeInfo>, BackendError> {
Ok(Vec::new())
}
async fn remove_volume(&self, _name: &str) -> Result<bool, BackendError> {
Err(BackendError::Unsupported)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VolumeInfo {
pub name: String,
pub size_bytes: u64,
}
#[derive(Debug, thiserror::Error)]
pub enum VolumeError {
#[error("volume {0:?} is in use by a registered workload")]
InUse(String),
#[error("no volume-capable backend on this node")]
Unsupported,
#[error("volume operation failed: {0}")]
Other(String),
}
#[async_trait]
pub trait ComputeVolumes: Send + Sync {
async fn list(&self) -> Result<Vec<VolumeStatus>, VolumeError>;
async fn remove(&self, name: &str, force: bool) -> Result<bool, VolumeError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VolumeStatus {
#[serde(flatten)]
pub info: VolumeInfo,
pub in_use: bool,
}
#[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,
}
}
pub fn from_shared_kernel_allowed(allow_shared_kernel: bool) -> Self {
Self {
require_strong_isolation: !allow_shared_kernel,
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendKind {
pub id: String,
pub isolation: IsolationClass,
pub persistent_volumes: bool,
pub scale_to_zero: bool,
}
#[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())
&& (spec.volumes.is_empty() || b.persistent_volumes)
&& (!spec.scale_to_zero || b.scale_to_zero)
};
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, skip_serializing_if = "Option::is_none")]
pub started_at: Option<u64>,
#[serde(default)]
pub phase: ReplicaPhase,
#[serde(default)]
pub snapshot: Option<Snapshot>,
}
pub fn replica_state_key(project: &str, workload: &str, replica: u32) -> String {
format!("project/{project}/compute_state/{workload}/{replica}")
}
pub fn replica_state_prefix(project: &str, workload: &str) -> String {
format!("project/{project}/compute_state/{workload}/")
}
pub fn replica_states_project_prefix(project: &str) -> String {
format!("project/{project}/compute_state/")
}
#[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,
}
#[allow(clippy::too_many_arguments)]
pub fn reconcile_plan(
workload: &ComputeWorkload,
spec: &ComputeSpec,
nodes: &[Node],
policy: &BackendPolicy,
observed: &[ObservedInstance],
activity: WorkloadActivity,
caps: &BTreeMap<String, Capabilities>,
now: u64,
) -> 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(); let mut starting: 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 if inst
.started_at
.is_some_and(|t| now.saturating_sub(t) < spec.startup_grace_secs as u64)
{
starting.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)
&& !starting.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>,
}
#[async_trait]
pub trait ComputeBindingResolver: Send + Sync {
async fn resolve(
&self,
project: &str,
workload: &str,
replica: u32,
bindings: &[ComputeBinding],
) -> Vec<(String, String)>;
async fn release(
&self,
project: &str,
workload: &str,
replica: u32,
bindings: &[ComputeBinding],
);
}
#[async_trait]
pub trait ManagedDbEnvResolver: Send + Sync {
async fn managed_db_env(&self, project: &str, workload: &str) -> Vec<(String, String)>;
fn managed_db_privilege(&self, _project: &str, _workload: &str) -> Option<PrivilegeDirective> {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PrivilegeDirective {
Rootless { uid: u32, gid: u32 },
Caps(Vec<String>),
}
impl PrivilegeDirective {
pub fn apply(&self, spec: &mut ComputeSpec) {
match self {
Self::Rootless { uid, gid } if spec.user.is_none() => {
spec.user = Some(format!("{uid}:{gid}"));
}
Self::Caps(caps) if spec.cap_add.is_empty() => {
spec.cap_add = caps.clone();
}
_ => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManagedDbEngine {
Postgres,
Mysql,
}
impl ManagedDbEngine {
pub fn default_image(self) -> &'static str {
match self {
Self::Postgres => "pgvector/pgvector:pg16",
Self::Mysql => "mysql:8.0",
}
}
pub fn port(self) -> u16 {
match self {
Self::Postgres => 5432,
Self::Mysql => 3306,
}
}
pub fn data_dir(self) -> &'static str {
match self {
Self::Postgres => "/var/lib/postgresql/data",
Self::Mysql => "/var/lib/mysql",
}
}
}
pub fn managed_db_spec(
engine: ManagedDbEngine,
image: Option<&str>,
volume_size_mib: u32,
) -> ComputeSpec {
const POSTGRES_STARTUP_GRACE_SECS: u32 = 60;
const MYSQL_STARTUP_GRACE_SECS: u32 = 120;
let startup_grace_secs = match engine {
ManagedDbEngine::Postgres => POSTGRES_STARTUP_GRACE_SECS,
ManagedDbEngine::Mysql => MYSQL_STARTUP_GRACE_SECS,
};
let data_dir = engine.data_dir();
let (entrypoint, env) = match engine {
ManagedDbEngine::Postgres => (
vec![
"/usr/local/bin/docker-entrypoint.sh".to_string(),
"postgres".to_string(),
"-c".to_string(),
"listen_addresses=*".to_string(),
],
BTreeMap::from([
(
"PATH".to_string(),
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:\
/usr/lib/postgresql/16/bin"
.to_string(),
),
("PGDATA".to_string(), data_dir.to_string()),
]),
),
ManagedDbEngine::Mysql => (
vec![
"/usr/local/bin/docker-entrypoint.sh".to_string(),
"mysqld".to_string(),
"--bind-address=*".to_string(),
],
BTreeMap::from([(
"PATH".to_string(),
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_string(),
)]),
),
};
ComputeSpec {
version: 1,
root: RootSource::Image(image.unwrap_or_else(|| engine.default_image()).to_string()),
kernel: String::new(),
kernel_cmdline: None,
vcpus: 1,
mem_mib: 512,
entrypoint,
env,
port: engine.port(),
restart: RestartPolicy::Always,
startup_grace_secs,
scale_to_zero: false,
volumes: vec![VolumeRef {
mount: data_dir.to_string(),
name: "data".to_string(),
size_mib: volume_size_mib,
}],
writable_root: false,
cap_add: Vec::new(),
user: None,
isolation: IsolationRequirement::Trusted,
prefer_backend: None,
bindings: vec![],
}
}
pub async fn reconcile_once(
deploy: &DeployStore,
backends: &BackendRegistry,
nodes: &[Node],
policy: &BackendPolicy,
activity: &dyn ActivitySource,
resolver: Option<&dyn ComputeBindingResolver>,
managed_db: Option<&dyn ManagedDbEnvResolver>,
) -> 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();
let mut parked_keys: Vec<(String, String, u32)> = Vec::new();
for (project_name, workload) in deploy.list_compute_workloads_all().await? {
let project = ProjectRef::new(&project_name);
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(project, &workload.name).await?;
for state in &mut observed {
if state.phase == ReplicaPhase::Zero {
parked_keys.push((
project_name.clone(),
state.handle.workload.clone(),
state.handle.replica,
));
continue;
}
if let Some(backend) = backends.get(&state.backend) {
if let Ok(health) = backend.health(&state.handle).await {
let now_healthy = matches!(health, Health::Healthy);
if now_healthy != state.healthy {
state.healthy = now_healthy;
if let Err(e) = deploy.set_replica_state(project, state).await {
report.errors.push(format!(
"{}/{}: persist health: {e}",
state.handle.workload, state.handle.replica
));
}
}
}
}
}
if let Some(resolver) = resolver {
if !spec.bindings.is_empty() {
for state in &observed {
if state.phase == ReplicaPhase::Running {
resolver
.resolve(
&project_name,
&workload.name,
state.handle.replica,
&spec.bindings,
)
.await;
}
}
}
}
let workload_activity = activity.activity(&workload.name).await;
for action in reconcile_plan(
&workload,
&spec,
nodes,
policy,
&observed,
workload_activity,
&caps,
crate::time::now_unix(),
) {
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);
let mut launch_env = match resolver {
Some(r) if !spec.bindings.is_empty() => {
r.resolve(&project_name, &wl, replica, &spec.bindings).await
}
_ => Vec::new(),
};
if let Some(m) = managed_db {
launch_env.extend(m.managed_db_env(&project_name, &wl).await);
}
let privilege =
managed_db.and_then(|m| m.managed_db_privilege(&project_name, &wl));
match launch_one(
b.as_ref(),
&project_name,
&wl,
replica,
node,
node_region,
&spec,
&launch_env,
privilege.as_ref(),
)
.await
{
Ok(state) => match deploy.set_replica_state(project, &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(project, &handle.workload, handle.replica)
.await
{
Ok(()) => report.stopped += 1,
Err(e) => report.errors.push(format!(
"{}/{}: forget: {e}",
handle.workload, handle.replica
)),
}
if let Some(resolver) = resolver {
if !spec.bindings.is_empty() {
resolver
.release(
&project_name,
&handle.workload,
handle.replica,
&spec.bindings,
)
.await;
}
}
}
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(mut snapshot)) => {
snapshot.project = project_name.clone();
let parked = ObservedInstance {
healthy: false,
phase: ReplicaPhase::Zero,
snapshot: Some(snapshot),
..obs
};
match deploy.set_replica_state(project, &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 mut handle = instance.handle;
handle.project = snapshot.project.clone();
let state = ObservedInstance {
handle,
node,
backend: backend.clone(),
endpoint: instance.endpoint,
region: region_of_node(nodes, node),
healthy: true,
started_at: Some(crate::time::now_unix()),
phase: ReplicaPhase::Running,
snapshot: None,
};
match deploy.set_replica_state(project, &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
)),
}
}
}
}
}
for backend in backends.values() {
backend.gc_ip_pool(&parked_keys).await;
}
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())
}
#[allow(clippy::too_many_arguments)]
async fn launch_one(
backend: &dyn ComputeBackend,
project: &str,
workload: &str,
replica: u32,
node: u64,
node_region: Option<String>,
spec: &ComputeSpec,
extra_env: &[(String, String)],
privilege: Option<&PrivilegeDirective>,
) -> Result<ObservedInstance, BackendError> {
let started_at = crate::time::now_unix();
let artifact = backend.materialize(spec).await?;
let mut spec = spec.clone();
for (k, v) in extra_env {
spec.env.entry(k.clone()).or_insert_with(|| v.clone());
}
if let Some(p) = privilege {
p.apply(&mut spec);
}
let instance = backend
.launch(&LaunchRequest {
project: project.to_string(),
workload: workload.to_string(),
replica,
spec: spec.clone(),
artifact,
})
.await?;
let healthy = matches!(backend.health(&instance.handle).await, Ok(Health::Healthy));
let mut handle = instance.handle;
handle.project = project.to_string();
Ok(ObservedInstance {
handle,
node,
backend: backend.id().to_string(),
endpoint: instance.endpoint,
region: node_region,
healthy,
started_at: Some(started_at),
phase: ReplicaPhase::Running,
snapshot: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_instance_id_qualifies_by_project_but_keeps_default_bare() {
assert_eq!(compute_instance_id("default", "web", 0), "web-0");
assert_eq!(compute_instance_id("", "web", 0), "web-0");
assert_eq!(compute_instance_id("default", "api-v2", 3), "api-v2-3");
assert_eq!(compute_instance_id("acme", "web", 0), "acme-web-0");
assert_eq!(compute_instance_id("beta", "web", 0), "beta-web-0");
assert_ne!(
compute_instance_id("acme", "web", 0),
compute_instance_id("beta", "web", 0),
"same-named workloads in different projects must NOT collide on id"
);
assert_ne!(
compute_instance_id("acme", "web", 0),
compute_instance_id("default", "web", 0)
);
}
#[test]
fn managed_db_spec_is_launchable_and_privilege_deferred() {
let pg = managed_db_spec(ManagedDbEngine::Postgres, None, 2048);
assert_eq!(pg.root, RootSource::Image("pgvector/pgvector:pg16".into()));
assert_eq!(pg.port, 5432);
assert!(pg.user.is_none(), "rootless directive sets user at launch");
assert!(pg.cap_add.is_empty());
assert!(matches!(pg.restart, RestartPolicy::Always));
assert!(!pg.scale_to_zero, "a database must not snapshot when idle");
assert_eq!(pg.volumes.len(), 1);
assert_eq!(pg.volumes[0].mount, "/var/lib/postgresql/data");
assert_eq!(pg.volumes[0].size_mib, 2048);
assert!(pg.entrypoint.iter().any(|a| a == "listen_addresses=*"));
assert_eq!(
pg.env.get("PGDATA").map(String::as_str),
Some("/var/lib/postgresql/data")
);
assert!(!pg.env.contains_key("POSTGRES_PASSWORD"));
let mut launched = pg.clone();
PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut launched);
assert_eq!(launched.user.as_deref(), Some("999:999"));
let my = managed_db_spec(ManagedDbEngine::Mysql, Some("mysql:8.4"), 512);
assert_eq!(my.root, RootSource::Image("mysql:8.4".into()));
assert_eq!(my.port, 3306);
assert_eq!(my.volumes[0].mount, "/var/lib/mysql");
assert_eq!(pg.startup_grace_secs, 60);
assert_eq!(my.startup_grace_secs, 120);
assert_eq!(default_startup_grace_secs(), 30);
assert_eq!(spec(1, 64).startup_grace_secs, 30);
}
#[test]
fn privilege_directive_applies_without_overriding_operator_values() {
let mut s = spec(1, 64);
PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
assert_eq!(s.user.as_deref(), Some("999:999"));
assert!(s.cap_add.is_empty());
let mut s = spec(1, 64);
s.user = Some("1000".into());
PrivilegeDirective::Rootless { uid: 999, gid: 999 }.apply(&mut s);
assert_eq!(s.user.as_deref(), Some("1000"));
let mut s = spec(1, 64);
PrivilegeDirective::Caps(vec!["CHOWN".into(), "SETUID".into()]).apply(&mut s);
assert_eq!(s.cap_add, vec!["CHOWN".to_string(), "SETUID".to_string()]);
let mut s = spec(1, 64);
s.cap_add = vec!["NET_BIND_SERVICE".into()];
PrivilegeDirective::Caps(vec!["CHOWN".into()]).apply(&mut s);
assert_eq!(s.cap_add, vec!["NET_BIND_SERVICE".to_string()]);
}
fn spec(vcpus: u32, mem_mib: u32) -> ComputeSpec {
ComputeSpec {
version: 1,
root: RootSource::Rootfs("r".repeat(64)),
kernel: "k".repeat(64),
kernel_cmdline: None,
vcpus,
mem_mib,
entrypoint: vec![],
env: BTreeMap::new(),
port: 80,
restart: RestartPolicy::Always,
startup_grace_secs: 30,
scale_to_zero: false,
volumes: vec![],
writable_root: false,
cap_add: Vec::new(),
user: None,
isolation: IsolationRequirement::Trusted,
prefer_backend: None,
bindings: vec![],
}
}
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,
persistent_volumes: true,
scale_to_zero: true,
})
.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 policy_from_shared_kernel_allowed_maps_posture_to_strong_isolation() {
assert!(BackendPolicy::from_shared_kernel_allowed(false).require_strong_isolation);
let permissive = BackendPolicy::from_shared_kernel_allowed(true);
assert!(!permissive.require_strong_isolation);
assert_eq!(permissive, BackendPolicy::default());
}
#[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"));
}
fn node_with_caps(id: &str, iso: IsolationClass, volumes: bool, s2z: bool) -> Node {
Node {
id: 1,
region: Some("eu".into()),
labels: BTreeMap::new(),
free_vcpus: 8,
free_mem_mib: 8192,
backends: vec![BackendKind {
id: id.into(),
isolation: iso,
persistent_volumes: volumes,
scale_to_zero: s2z,
}],
}
}
#[test]
fn volume_spec_needs_a_volume_capable_backend() {
let mut s = spec(1, 128);
s.volumes = vec![VolumeRef {
mount: "/data".into(),
name: "db".into(),
size_mib: 64,
}];
let no_vol = vec![node_with_caps(
"container",
IsolationClass::Namespace,
false,
false,
)];
assert!(
place_replicas(
1,
&PlacementConstraints::default(),
&s,
&no_vol,
&BackendPolicy::default()
)
.is_empty(),
"a volume spec must not place on a volume-incapable backend"
);
let vol_ok = vec![node_with_caps("vmm", IsolationClass::VmKvm, true, false)];
assert_eq!(
place_replicas(
1,
&PlacementConstraints::default(),
&s,
&vol_ok,
&BackendPolicy::default()
)
.len(),
1
);
}
#[test]
fn scale_to_zero_spec_needs_a_capable_backend() {
let mut s = spec(1, 128);
s.scale_to_zero = true;
let no_s2z = vec![node_with_caps(
"docker",
IsolationClass::Container,
false,
false,
)];
assert!(
place_replicas(
1,
&PlacementConstraints::default(),
&s,
&no_s2z,
&BackendPolicy::default()
)
.is_empty(),
"a scale-to-zero spec must not place on a scale-to-zero-incapable backend"
);
let s2z_ok = vec![node_with_caps(
"container",
IsolationClass::Namespace,
false,
true,
)];
assert_eq!(
place_replicas(
1,
&PlacementConstraints::default(),
&s,
&s2z_ok,
&BackendPolicy::default()
)
.len(),
1
);
}
#[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 {
project: "default".into(),
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,
started_at: None,
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 {
project: "default".into(),
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(),
u64::MAX,
)
}
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(),
u64::MAX,
);
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(),
u64::MAX,
);
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(),
u64::MAX,
);
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(),
u64::MAX,
);
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(),
u64::MAX,
);
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(),
u64::MAX,
);
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, .. })));
}
fn observed_started(
workload: &str,
replica: u32,
node: u64,
healthy: bool,
started_at: Option<u64>,
) -> ObservedInstance {
ObservedInstance {
started_at,
..observed(workload, replica, node, healthy)
}
}
#[test]
fn reconcile_leaves_a_starting_replica_within_its_startup_grace() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let now = 1_000_000u64;
let mut s = spec(1, 256);
s.startup_grace_secs = 60;
let obs = vec![observed_started("w", 0, 1, false, Some(now - 10))];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s,
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Active,
&BTreeMap::new(),
now,
);
assert!(
actions.is_empty(),
"a replica within its startup grace is left alone: {actions:?}"
);
}
#[test]
fn reconcile_relaunches_a_replica_past_its_startup_grace() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let now = 1_000_000u64;
let mut s = spec(1, 256);
s.startup_grace_secs = 60;
let obs = vec![observed_started("w", 0, 1, false, Some(now - 120))];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s,
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Active,
&BTreeMap::new(),
now,
);
assert!(
actions
.iter()
.any(|a| matches!(a, Action::Stop { handle } if handle.replica == 0)),
"past-grace unhealthy replica is stopped: {actions:?}"
);
assert!(
actions
.iter()
.any(|a| matches!(a, Action::Launch { replica: 0, .. })),
"and its ordinal relaunched: {actions:?}"
);
}
#[test]
fn reconcile_treats_started_at_none_as_past_grace() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let mut s = spec(1, 256);
s.startup_grace_secs = 3600;
let obs = vec![observed_started("w", 0, 1, false, None)];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s,
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Active,
&BTreeMap::new(),
1_000_000,
);
assert!(
actions
.iter()
.any(|a| matches!(a, Action::Stop { handle } if handle.replica == 0)),
"None started_at preserves the prior immediate relaunch: {actions:?}"
);
assert!(actions
.iter()
.any(|a| matches!(a, Action::Launch { replica: 0, .. })));
}
#[test]
fn a_starting_replica_counts_toward_desired_and_is_not_duplicated() {
let nodes = vec![vmm(1, "eu", 8, 8192)];
let now = 1_000_000u64;
let mut s = spec(1, 256);
s.startup_grace_secs = 60;
let obs = vec![observed_started("w", 0, 1, false, Some(now - 5))];
let actions = reconcile_plan(
&workload(1, Default::default()),
&s,
&nodes,
&BackendPolicy::default(),
&obs,
WorkloadActivity::Active,
&BTreeMap::new(),
now,
);
assert!(
!actions.iter().any(|a| matches!(a, Action::Launch { .. })),
"a starting replica fills its ordinal — no duplicate Launch: {actions:?}"
);
}
#[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 {
project: req.project.clone(),
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 {
project: "default".into(),
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 NotReadyBackend;
#[async_trait]
impl ComputeBackend for NotReadyBackend {
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 {
project: req.project.clone(),
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::Unhealthy)
}
}
#[tokio::test]
async fn launch_one_records_probed_readiness_not_a_blind_true() {
let s = spec(1, 128);
let unready = launch_one(&NotReadyBackend, "default", "pg", 0, 1, None, &s, &[], None)
.await
.unwrap();
assert!(
!unready.healthy,
"a launched-but-unready replica is recorded unhealthy so the next tick relaunches it"
);
assert_eq!(unready.phase, ReplicaPhase::Running);
let ready = launch_one(&FakeBackend, "default", "pg", 0, 1, None, &s, &[], None)
.await
.unwrap();
assert!(ready.healthy);
let mut always = s.clone();
always.restart = RestartPolicy::Always;
let wl = ComputeWorkload {
version: 1,
name: "pg".into(),
active: "spec".into(),
replicas: 1,
placement: PlacementConstraints::default(),
};
let caps: BTreeMap<String, Capabilities> =
[("fake".to_string(), FakeBackend.capabilities())]
.into_iter()
.collect();
let past_grace = unready.started_at.unwrap() + always.startup_grace_secs as u64 + 1;
let actions = reconcile_plan(
&wl,
&always,
&[fake_node()],
&BackendPolicy::default(),
&[unready],
WorkloadActivity::Active,
&caps,
past_grace,
);
assert!(
actions
.iter()
.any(|a| matches!(a, Action::Stop { handle } if handle.replica == 0)),
"the unhealthy first launch is stopped: {actions:?}"
);
assert!(
actions
.iter()
.any(|a| matches!(a, Action::Launch { replica: 0, .. })),
"and its ordinal relaunched: {actions:?}"
);
}
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,
persistent_volumes: true,
scale_to_zero: true,
}],
}
}
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 {
project: req.project.clone(),
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 {
project: handle.project.clone(),
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 {
project: snapshot.project.clone(),
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(
crate::project::ProjectRef::DEFAULT,
&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),
None,
None,
)
.await
.unwrap();
assert_eq!(r.launched, 1, "{:?}", r.errors);
let r = reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&FixedActivity(WorkloadActivity::Idle),
None,
None,
)
.await
.unwrap();
assert_eq!(r.slept, 1, "{:?}", r.errors);
let parked = deploy
.list_replica_states(crate::project::ProjectRef::DEFAULT, "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),
None,
None,
)
.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),
None,
None,
)
.await
.unwrap();
assert_eq!(r.woke, 1, "{:?}", r.errors);
let woken = deploy
.list_replica_states(crate::project::ProjectRef::DEFAULT, "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(
crate::project::ProjectRef::DEFAULT,
&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,
None,
None,
)
.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(crate::project::ProjectRef::DEFAULT, "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,
None,
None,
)
.await
.unwrap();
assert_eq!((r2.launched, r2.stopped), (0, 0));
deploy
.set_compute_workload(
crate::project::ProjectRef::DEFAULT,
&ComputeWorkload {
version: 1,
name: "w".into(),
active: hash,
replicas: 0,
placement: Default::default(),
},
)
.await
.unwrap();
let r3 = reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&AlwaysActive,
None,
None,
)
.await
.unwrap();
assert_eq!(r3.stopped, 2);
assert!(deploy
.list_replica_states(crate::project::ProjectRef::DEFAULT, "w")
.await
.unwrap()
.is_empty());
}
struct GcSpyBackend {
gc_parked: std::sync::Mutex<Option<Vec<(String, String, u32)>>>,
}
#[async_trait]
impl ComputeBackend for GcSpyBackend {
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 gc_ip_pool(&self, parked: &[(String, String, u32)]) {
*self.gc_parked.lock().unwrap() = Some(parked.to_vec());
}
async fn launch(&self, req: &LaunchRequest) -> Result<Instance, BackendError> {
Ok(Instance {
handle: InstanceHandle {
project: req.project.clone(),
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 reconcile_runs_ip_gc_with_the_parked_replicas() {
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(
crate::project::ProjectRef::DEFAULT,
&ComputeWorkload {
version: 1,
name: "w".into(),
active: hash,
replicas: 1,
placement: Default::default(),
},
)
.await
.unwrap();
let parked = ObservedInstance {
handle: InstanceHandle {
project: "default".into(),
workload: "w".into(),
replica: 0,
backend_ref: "10.0.0.2:8080".into(),
},
node: 1,
backend: "fake".into(),
endpoint: Endpoint {
scheme: Scheme::Http,
host: "10.0.0.2".into(),
port: 8080,
},
region: None,
healthy: false,
started_at: None,
phase: ReplicaPhase::Zero,
snapshot: Some(Snapshot {
project: "default".into(),
workload: "w".into(),
replica: 0,
data_ref: "snap".into(),
}),
};
deploy
.set_replica_state(crate::project::ProjectRef::DEFAULT, &parked)
.await
.unwrap();
let nodes = vec![fake_node()];
let mut backends: BackendRegistry = BTreeMap::new();
let spy = Arc::new(GcSpyBackend {
gc_parked: std::sync::Mutex::new(None),
});
backends.insert("fake".into(), spy.clone());
let policy = BackendPolicy::default();
reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&FixedActivity(WorkloadActivity::Idle), None,
None,
)
.await
.unwrap();
let got = spy.gc_parked.lock().unwrap().clone();
let got = got.expect("gc_ip_pool was called during the reconcile");
assert!(
got.contains(&("default".to_string(), "w".to_string(), 0)),
"the parked (Zero) replica must be handed to gc_ip_pool so its IP is kept, got {got:?}"
);
}
}