use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::manifest::sha256_hex;
pub const WORKLOAD_PREFIX: &str = "compute/";
pub const SPEC_PREFIX: &str = "computever/";
pub fn workload_key(name: &str) -> String {
format!("{WORKLOAD_PREFIX}{name}")
}
pub fn spec_key(hash: &str) -> String {
format!("{SPEC_PREFIX}{hash}")
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RestartPolicy {
Never,
OnFailure,
#[default]
Always,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IsolationRequirement {
#[default]
Trusted,
Untrusted,
}
impl IsolationRequirement {
pub fn is_trusted(&self) -> bool {
matches!(self, Self::Trusted)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VolumeRef {
pub mount: String,
pub name: String,
pub size_mib: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ComputeSpec {
#[serde(default = "crate::schema_version")]
pub version: u32,
pub rootfs: String,
pub kernel: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kernel_cmdline: Option<String>,
pub vcpus: u32,
pub mem_mib: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entrypoint: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
pub port: u16,
#[serde(default)]
pub restart: RestartPolicy,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub scale_to_zero: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volumes: Vec<VolumeRef>,
#[serde(default, skip_serializing_if = "IsolationRequirement::is_trusted")]
pub isolation: IsolationRequirement,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefer_backend: Option<String>,
}
impl ComputeSpec {
pub fn id(&self) -> String {
let canonical = serde_json::to_vec(self).expect("ComputeSpec serializes");
sha256_hex(&canonical)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PlacementConstraints {
pub regions: Vec<String>,
pub labels: BTreeMap<String, String>,
}
impl PlacementConstraints {
pub fn allows(
&self,
node_region: Option<&str>,
node_labels: &BTreeMap<String, String>,
) -> bool {
if !self.regions.is_empty() {
match node_region {
Some(r) if self.regions.iter().any(|want| want == r) => {}
_ => return false,
}
}
self.labels
.iter()
.all(|(k, v)| node_labels.get(k).is_some_and(|nv| nv == v))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ComputeWorkload {
#[serde(default = "crate::schema_version")]
pub version: u32,
pub name: String,
pub active: String,
pub replicas: u32,
#[serde(default)]
pub placement: PlacementConstraints,
}
#[cfg(test)]
mod tests {
use super::*;
fn spec() -> ComputeSpec {
ComputeSpec {
version: crate::SCHEMA_VERSION,
rootfs: "a".repeat(64),
kernel: "b".repeat(64),
kernel_cmdline: None,
vcpus: 2,
mem_mib: 512,
entrypoint: vec!["/app".into(), "--serve".into()],
env: BTreeMap::from([("PORT".to_string(), "8080".to_string())]),
port: 8080,
restart: RestartPolicy::Always,
scale_to_zero: true,
volumes: vec![],
isolation: IsolationRequirement::Trusted,
prefer_backend: None,
}
}
#[test]
fn spec_id_is_stable_and_content_addressed() {
let a = spec();
let mut b = spec();
assert_eq!(a.id(), b.id(), "identical specs share an id");
b.vcpus = 4;
assert_ne!(a.id(), b.id(), "a changed field changes the id");
assert_eq!(a.id().len(), 64);
}
#[test]
fn default_isolation_does_not_change_the_spec_hash() {
let mut a = spec();
a.isolation = IsolationRequirement::Trusted;
let json = serde_json::to_string(&a).unwrap();
assert!(!json.contains("isolation"), "default isolation is omitted");
let mut b = spec();
b.isolation = IsolationRequirement::Untrusted;
assert_ne!(a.id(), b.id());
assert!(serde_json::to_string(&b).unwrap().contains("untrusted"));
}
#[test]
fn spec_round_trips_through_json() {
let a = spec();
let json = serde_json::to_string(&a).unwrap();
assert_eq!(serde_json::from_str::<ComputeSpec>(&json).unwrap(), a);
}
#[test]
fn keyspace_helpers() {
assert_eq!(workload_key("api"), "compute/api");
assert_eq!(spec_key("deadbeef"), "computever/deadbeef");
}
#[test]
fn placement_matches_region_and_labels() {
let c = PlacementConstraints {
regions: vec!["eu".into()],
labels: BTreeMap::from([("gpu".to_string(), "yes".to_string())]),
};
let labels = BTreeMap::from([("gpu".to_string(), "yes".to_string())]);
assert!(c.allows(Some("eu"), &labels));
assert!(!c.allows(Some("us"), &labels), "wrong region");
assert!(!c.allows(Some("eu"), &BTreeMap::new()), "missing label");
assert!(PlacementConstraints::default().allows(None, &BTreeMap::new()));
}
}