use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::manifest::sha256_hex;
pub const SPEC_PREFIX: &str = "computever/";
pub fn workload_key(project: &str, name: &str) -> String {
format!("project/{project}/compute/{name}")
}
pub fn workloads_prefix(project: &str) -> String {
format!("project/{project}/compute/")
}
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(rename_all = "snake_case")]
pub enum RootSource {
Image(String),
Tar(String),
Rootfs(String),
}
impl RootSource {
pub fn as_str(&self) -> &str {
match self {
Self::Image(s) | Self::Tar(s) | Self::Rootfs(s) => s,
}
}
}
#[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 root: RootSource,
#[serde(default, skip_serializing_if = "String::is_empty")]
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 = "std::ops::Not::not")]
pub writable_root: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cap_add: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default, skip_serializing_if = "IsolationRequirement::is_trusted")]
pub isolation: IsolationRequirement,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefer_backend: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bindings: Vec<ComputeBinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ComputeBinding {
pub kind: BindingKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_env: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BindingKind {
Sql,
Kv,
Blob,
Messaging,
}
impl ComputeBinding {
pub fn url_env(&self) -> String {
self.url_env
.clone()
.unwrap_or_else(|| self.kind.default_url_env().to_string())
}
}
impl BindingKind {
pub fn default_url_env(self) -> &'static str {
match self {
Self::Sql => "BOATRAMP_SQL_URL",
Self::Kv => "BOATRAMP_KV_URL",
Self::Blob => "BOATRAMP_BLOB_URL",
Self::Messaging => "BOATRAMP_MESSAGING_URL",
}
}
}
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,
root: RootSource::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![],
writable_root: false,
cap_add: Vec::new(),
user: None,
isolation: IsolationRequirement::Trusted,
prefer_backend: None,
bindings: vec![],
}
}
#[test]
fn empty_bindings_do_not_change_the_spec_hash() {
let a = spec();
let json = serde_json::to_string(&a).unwrap();
assert!(!json.contains("bindings"), "empty bindings are omitted");
let mut b = spec();
b.bindings = vec![ComputeBinding {
kind: BindingKind::Sql,
name: String::new(),
url_env: None,
}];
assert_ne!(a.id(), b.id(), "a declared binding changes the id");
assert!(serde_json::to_string(&b).unwrap().contains("bindings"));
}
#[test]
fn binding_kind_parses_lowercase_and_url_env_defaults_per_kind() {
assert_eq!(
serde_json::from_str::<BindingKind>("\"sql\"").unwrap(),
BindingKind::Sql
);
let sql = ComputeBinding {
kind: BindingKind::Sql,
name: String::new(),
url_env: None,
};
assert_eq!(sql.url_env(), "BOATRAMP_SQL_URL");
let custom = ComputeBinding {
kind: BindingKind::Sql,
name: "analytics".into(),
url_env: Some("ANALYTICS_URL".into()),
};
assert_eq!(custom.url_env(), "ANALYTICS_URL");
}
#[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("default", "api"),
"project/default/compute/api"
);
assert_eq!(workload_key("acme", "api"), "project/acme/compute/api");
assert_eq!(workloads_prefix("default"), "project/default/compute/");
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()));
}
}