use crate::error::SpecError;
use crate::ids::{BundleId, CustomerId, DeploymentId, PartyId, RevisionId};
use crate::version::SchemaVersion;
use chrono::{DateTime, Utc};
use greentic_types::EnvId;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::path::PathBuf;
const BASIS_POINTS_TOTAL: u32 = 10_000;
pub const MAX_CONFIG_OVERRIDE_PACKS: usize = 32;
pub const MAX_CONFIG_OVERRIDE_KEYS_PER_PACK: usize = 64;
pub const MAX_CONFIG_OVERRIDE_BYTES: usize = 16 * 1024;
pub(crate) fn validate_revenue_share_total(
revenue_share: &[RevenueShareEntry],
) -> Result<(), SpecError> {
let mut sum: u64 = 0;
for entry in revenue_share {
if entry.basis_points > BASIS_POINTS_TOTAL {
return Err(SpecError::BasisPointsEntryTooLarge {
value: entry.basis_points,
});
}
sum += u64::from(entry.basis_points);
}
if sum != u64::from(BASIS_POINTS_TOTAL) {
return Err(SpecError::BasisPointsSum { sum });
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BundleDeploymentStatus {
Active,
Paused,
Archived,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TenantSelector {
pub tenant: String,
pub team: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RouteBinding {
#[serde(default)]
pub hosts: Vec<String>,
#[serde(default)]
pub path_prefixes: Vec<String>,
pub tenant_selector: TenantSelector,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RevenueShareEntry {
pub party_id: PartyId,
pub basis_points: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageMeter {
pub meter_endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleDeployment {
pub schema: SchemaVersion,
pub deployment_id: DeploymentId,
pub env_id: EnvId,
pub bundle_id: BundleId,
pub customer_id: CustomerId,
pub status: BundleDeploymentStatus,
#[serde(default)]
pub current_revisions: Vec<RevisionId>,
pub route_binding: RouteBinding,
pub revenue_share: Vec<RevenueShareEntry>,
pub revenue_policy_ref: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<UsageMeter>,
pub created_at: DateTime<Utc>,
pub authorization_ref: PathBuf,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub config_overrides: BTreeMap<String, BTreeMap<String, Value>>,
}
impl BundleDeployment {
pub fn schema_str() -> &'static str {
SchemaVersion::BUNDLE_DEPLOYMENT_V1
}
pub fn validate(&self) -> Result<(), SpecError> {
if self.schema.as_str() != SchemaVersion::BUNDLE_DEPLOYMENT_V1 {
return Err(SpecError::SchemaMismatch {
expected: SchemaVersion::BUNDLE_DEPLOYMENT_V1,
actual: self.schema.as_str().to_string(),
});
}
validate_revenue_share_total(&self.revenue_share)?;
validate_config_overrides(&self.config_overrides)
}
}
pub(crate) fn validate_config_overrides(
overrides: &BTreeMap<String, BTreeMap<String, Value>>,
) -> Result<(), SpecError> {
if overrides.len() > MAX_CONFIG_OVERRIDE_PACKS {
return Err(SpecError::ConfigOverridesTooManyPacks {
count: overrides.len(),
max: MAX_CONFIG_OVERRIDE_PACKS,
});
}
for (pack_id, fields) in overrides {
if pack_id.is_empty() {
return Err(SpecError::ConfigOverrideEmptyPackId);
}
if fields.len() > MAX_CONFIG_OVERRIDE_KEYS_PER_PACK {
return Err(SpecError::ConfigOverridesTooManyKeysForPack {
pack_id: pack_id.clone(),
count: fields.len(),
max: MAX_CONFIG_OVERRIDE_KEYS_PER_PACK,
});
}
for key in fields.keys() {
if key.is_empty() {
return Err(SpecError::ConfigOverrideEmptyKey {
pack_id: pack_id.clone(),
});
}
}
}
let serialized_len = serde_json::to_vec(overrides)
.map(|bytes| bytes.len())
.unwrap_or(usize::MAX);
if serialized_len > MAX_CONFIG_OVERRIDE_BYTES {
return Err(SpecError::ConfigOverridesTooLarge {
bytes: serialized_len,
max: MAX_CONFIG_OVERRIDE_BYTES,
});
}
Ok(())
}
#[cfg(test)]
mod config_overrides_tests {
use super::*;
use serde_json::json;
fn ok(packs: &[(&str, &[(&str, Value)])]) -> Result<(), SpecError> {
let mut overrides = BTreeMap::new();
for (pack_id, fields) in packs {
let mut field_map = BTreeMap::new();
for (k, v) in *fields {
field_map.insert((*k).to_string(), v.clone());
}
overrides.insert((*pack_id).to_string(), field_map);
}
validate_config_overrides(&overrides)
}
#[test]
fn empty_overrides_pass() {
assert!(ok(&[]).is_ok());
}
#[test]
fn single_pack_single_key_passes() {
assert!(
ok(&[(
"messaging-telegram",
&[("api_base_url", json!("https://staging.example.com"))],
)])
.is_ok()
);
}
#[test]
fn empty_pack_id_rejected() {
let err = ok(&[("", &[("api_base_url", json!("x"))])]).unwrap_err();
assert_eq!(err, SpecError::ConfigOverrideEmptyPackId);
}
#[test]
fn empty_config_key_rejected() {
let err = ok(&[("messaging-telegram", &[("", json!("x"))])]).unwrap_err();
assert_eq!(
err,
SpecError::ConfigOverrideEmptyKey {
pack_id: "messaging-telegram".to_string(),
}
);
}
#[test]
fn too_many_packs_rejected() {
let mut overrides = BTreeMap::new();
for i in 0..=MAX_CONFIG_OVERRIDE_PACKS {
let mut fields = BTreeMap::new();
fields.insert("k".to_string(), json!("v"));
overrides.insert(format!("pack-{i}"), fields);
}
let err = validate_config_overrides(&overrides).unwrap_err();
assert_eq!(
err,
SpecError::ConfigOverridesTooManyPacks {
count: MAX_CONFIG_OVERRIDE_PACKS + 1,
max: MAX_CONFIG_OVERRIDE_PACKS,
}
);
}
#[test]
fn too_many_keys_per_pack_rejected() {
let mut fields = BTreeMap::new();
for i in 0..=MAX_CONFIG_OVERRIDE_KEYS_PER_PACK {
fields.insert(format!("k-{i}"), json!("v"));
}
let mut overrides = BTreeMap::new();
overrides.insert("messaging-telegram".to_string(), fields);
let err = validate_config_overrides(&overrides).unwrap_err();
assert_eq!(
err,
SpecError::ConfigOverridesTooManyKeysForPack {
pack_id: "messaging-telegram".to_string(),
count: MAX_CONFIG_OVERRIDE_KEYS_PER_PACK + 1,
max: MAX_CONFIG_OVERRIDE_KEYS_PER_PACK,
}
);
}
#[test]
fn oversized_total_serialized_rejected() {
let mut fields = BTreeMap::new();
fields.insert(
"blob".to_string(),
json!("x".repeat(MAX_CONFIG_OVERRIDE_BYTES)),
);
let mut overrides = BTreeMap::new();
overrides.insert("p".to_string(), fields);
let err = validate_config_overrides(&overrides).unwrap_err();
match err {
SpecError::ConfigOverridesTooLarge { bytes, max } => {
assert!(bytes > max, "must report bytes={bytes} > max={max}");
assert_eq!(max, MAX_CONFIG_OVERRIDE_BYTES);
}
other => panic!("expected ConfigOverridesTooLarge, got {other:?}"),
}
}
}