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,
}
pub fn default_startup_grace_secs() -> u32 {
30
}
#[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 = "default_startup_grace_secs")]
pub startup_grace_secs: u32,
#[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 stable_tag(self) -> u8 {
match self {
Self::Sql => 1,
Self::Kv => 2,
Self::Blob => 3,
Self::Messaging => 4,
}
}
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,
}
pub fn default_replicas() -> u32 {
1
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PutComputeRequest {
pub spec: ComputeSpec,
#[serde(default = "default_replicas")]
pub replicas: u32,
#[serde(default)]
pub placement: PlacementConstraints,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyDatabaseKind {
Postgres,
Mysql,
}
impl ApplyDatabaseKind {
pub fn as_config_kind(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::Mysql => "mysql",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyDatabaseTenant {
#[default]
Single,
Shared,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyDatabaseScope {
#[default]
Project,
Site,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApplyDatabaseSize {
#[default]
Small,
Medium,
Large,
}
impl ApplyDatabaseSize {
pub fn resources(self) -> (u32, u32, u32) {
match self {
Self::Small => (1, 512, 10 * 1024),
Self::Medium => (2, 2 * 1024, 50 * 1024),
Self::Large => (4, 8 * 1024, 200 * 1024),
}
}
}
pub mod apply_db_caps {
pub const MAX_POOL: u32 = 64;
pub const MAX_CONNECT_TIMEOUT_SECS: u64 = 60;
pub const MAX_STARTUP_GRACE_SECS: u32 = 600;
pub const DEFAULT_MAX_DECLARED_DATABASES_PER_PROJECT: usize = 16;
pub const DEFAULT_MAX_DECLARED_VOLUME_MIB: u64 = 512 * 1024;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApplyDatabase {
pub name: String,
pub kind: ApplyDatabaseKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
#[serde(default)]
pub size: ApplyDatabaseSize,
#[serde(default)]
pub tenant: ApplyDatabaseTenant,
#[serde(default)]
pub tenant_scope: ApplyDatabaseScope,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub read_only: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub rls_session: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_guc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_guc: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_all_marker: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool_max: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connect_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_grace_secs: Option<u32>,
}
impl ApplyDatabase {
pub fn identity_change(&self, prior: &Self) -> Option<&'static str> {
if self.kind != prior.kind {
return Some("kind");
}
if self.tenant != prior.tenant {
return Some("tenant");
}
if self.tenant_scope != prior.tenant_scope {
return Some("tenant_scope");
}
None
}
}
#[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,
startup_grace_secs: 30,
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 old_spec_without_startup_grace_deserializes_with_the_default() {
let old = r#"{
"version": 1,
"root": { "rootfs": "aaaa" },
"vcpus": 1,
"mem_mib": 256,
"port": 8080
}"#;
let spec: ComputeSpec = serde_json::from_str(old).expect("old spec deserializes");
assert_eq!(spec.version, 1, "schema stays v1");
assert_eq!(spec.startup_grace_secs, default_startup_grace_secs());
assert_eq!(spec.startup_grace_secs, 30);
}
#[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()));
}
}