Skip to main content

greentic_deploy_spec/
bundle_deployment.rs

1//! `greentic.bundle-deployment.v1` (`§5.4`).
2//!
3//! The usage-level anchor (P6). One per `(env_id, bundle_id, customer_id)`.
4
5use crate::error::SpecError;
6use crate::ids::{BundleId, CustomerId, DeploymentId, PartyId, RevisionId};
7use crate::version::SchemaVersion;
8use chrono::{DateTime, Utc};
9use greentic_types::EnvId;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15const BASIS_POINTS_TOTAL: u32 = 10_000;
16
17/// Caps on [`BundleDeployment::config_overrides`] — applied in [`BundleDeployment::validate`].
18///
19/// `environment.json` is loaded into memory at every operator verb (warm,
20/// traffic set, etc.) and serialized in audit events; an unbounded
21/// per-deployment config payload would amplify both. Single-pack/single-key
22/// overrides like `{"messaging-telegram": {"api_base_url": "..."}}` are
23/// hundreds of bytes; the caps below give ~3 orders of magnitude of
24/// headroom without admitting a "store the whole pack config here" misuse
25/// that belongs in Phase C's `pack-config.v1.non_secret` channel.
26pub const MAX_CONFIG_OVERRIDE_PACKS: usize = 32;
27pub const MAX_CONFIG_OVERRIDE_KEYS_PER_PACK: usize = 64;
28pub const MAX_CONFIG_OVERRIDE_BYTES: usize = 16 * 1024;
29
30/// Shared `§5.4` revenue-share invariant: every entry's basis points must be
31/// `<= 10,000` and the sum across entries must equal exactly `10,000`.
32///
33/// The sum widens into `u64` and rejects any per-entry value above 10,000 so a
34/// crafted document like `[u32::MAX, 10001]` cannot wrap to exactly 10,000 in
35/// release builds. Shared by [`BundleDeployment::validate`] and the versioned
36/// [`RevenuePolicyDocument`](crate::revenue_policy::RevenuePolicyDocument).
37pub(crate) fn validate_revenue_share_total(
38    revenue_share: &[RevenueShareEntry],
39) -> Result<(), SpecError> {
40    let mut sum: u64 = 0;
41    for entry in revenue_share {
42        if entry.basis_points > BASIS_POINTS_TOTAL {
43            return Err(SpecError::BasisPointsEntryTooLarge {
44                value: entry.basis_points,
45            });
46        }
47        sum += u64::from(entry.basis_points);
48    }
49    if sum != u64::from(BASIS_POINTS_TOTAL) {
50        return Err(SpecError::BasisPointsSum { sum });
51    }
52    Ok(())
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum BundleDeploymentStatus {
58    Active,
59    Paused,
60    Archived,
61}
62
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct TenantSelector {
65    pub tenant: String,
66    pub team: String,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct RouteBinding {
71    #[serde(default)]
72    pub hosts: Vec<String>,
73    #[serde(default)]
74    pub path_prefixes: Vec<String>,
75    pub tenant_selector: TenantSelector,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
79pub struct RevenueShareEntry {
80    pub party_id: PartyId,
81    pub basis_points: u32,
82}
83
84#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct UsageMeter {
86    pub meter_endpoint: String,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub last_seen_at: Option<DateTime<Utc>>,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct BundleDeployment {
93    pub schema: SchemaVersion,
94    pub deployment_id: DeploymentId,
95    pub env_id: EnvId,
96    pub bundle_id: BundleId,
97    pub customer_id: CustomerId,
98    pub status: BundleDeploymentStatus,
99    /// Subset of `Environment.revisions` for this deployment.
100    #[serde(default)]
101    pub current_revisions: Vec<RevisionId>,
102    pub route_binding: RouteBinding,
103    pub revenue_share: Vec<RevenueShareEntry>,
104    /// Path to the signed, versioned policy document.
105    pub revenue_policy_ref: PathBuf,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub usage: Option<UsageMeter>,
108    pub created_at: DateTime<Utc>,
109    pub authorization_ref: PathBuf,
110    /// Per-pack non-secret runtime config overrides applied at the egress
111    /// boundary (D.4). Outer key is the pack id (matches the on-disk
112    /// `<bundle>/packs/<pack_id>.gtpack` slug and the `pack_id` carried on
113    /// synthesized HTTP routes); inner key is the provider config field
114    /// (`api_base_url`, `default_chat_id`, …). Values flow through
115    /// `messaging_egress::build_send_payload` → `SendPayloadInV1.config`
116    /// → the WASM provider's `load_config(input.get("config"))` path.
117    ///
118    /// Secrets MUST NOT land here — they go through `SecretsManager` via
119    /// the `secrets://<env>/<tenant>/<team>/<pack>/<key>` URI scheme
120    /// resolved by the `secrets-store` host import (B12a). The non-secret/
121    /// secret split is the producer's responsibility (deployer CLI rejects
122    /// secret-marked keys); validation here is the structural cap only.
123    ///
124    /// Caps: see [`MAX_CONFIG_OVERRIDE_PACKS`],
125    /// [`MAX_CONFIG_OVERRIDE_KEYS_PER_PACK`],
126    /// [`MAX_CONFIG_OVERRIDE_BYTES`].
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub config_overrides: BTreeMap<String, BTreeMap<String, Value>>,
129}
130
131impl BundleDeployment {
132    pub fn schema_str() -> &'static str {
133        SchemaVersion::BUNDLE_DEPLOYMENT_V1
134    }
135
136    /// `§5.4`: schema discriminator equals `greentic.bundle-deployment.v1`
137    /// and the sum of revenue-share basis points MUST equal 10,000.
138    ///
139    /// Sum widens into `u64` and rejects any per-entry value above 10,000 so a
140    /// crafted document like `[u32::MAX, 10001]` cannot wrap to exactly 10,000
141    /// in release builds.
142    pub fn validate(&self) -> Result<(), SpecError> {
143        if self.schema.as_str() != SchemaVersion::BUNDLE_DEPLOYMENT_V1 {
144            return Err(SpecError::SchemaMismatch {
145                expected: SchemaVersion::BUNDLE_DEPLOYMENT_V1,
146                actual: self.schema.as_str().to_string(),
147            });
148        }
149        validate_revenue_share_total(&self.revenue_share)?;
150        validate_config_overrides(&self.config_overrides)
151    }
152}
153
154/// Structural validation for `config_overrides`. Caps the pack count, the
155/// per-pack key count, and the total serialized size — and rejects empty
156/// pack ids / empty config keys (both would break downstream lookup keyed
157/// on those strings).
158///
159/// The byte cap is computed on a canonical JSON serialization so
160/// pack/key/value reshuffling doesn't bypass it.
161pub(crate) fn validate_config_overrides(
162    overrides: &BTreeMap<String, BTreeMap<String, Value>>,
163) -> Result<(), SpecError> {
164    if overrides.len() > MAX_CONFIG_OVERRIDE_PACKS {
165        return Err(SpecError::ConfigOverridesTooManyPacks {
166            count: overrides.len(),
167            max: MAX_CONFIG_OVERRIDE_PACKS,
168        });
169    }
170    for (pack_id, fields) in overrides {
171        if pack_id.is_empty() {
172            return Err(SpecError::ConfigOverrideEmptyPackId);
173        }
174        if fields.len() > MAX_CONFIG_OVERRIDE_KEYS_PER_PACK {
175            return Err(SpecError::ConfigOverridesTooManyKeysForPack {
176                pack_id: pack_id.clone(),
177                count: fields.len(),
178                max: MAX_CONFIG_OVERRIDE_KEYS_PER_PACK,
179            });
180        }
181        for key in fields.keys() {
182            if key.is_empty() {
183                return Err(SpecError::ConfigOverrideEmptyKey {
184                    pack_id: pack_id.clone(),
185                });
186            }
187        }
188    }
189    let serialized_len = serde_json::to_vec(overrides)
190        .map(|bytes| bytes.len())
191        .unwrap_or(usize::MAX);
192    if serialized_len > MAX_CONFIG_OVERRIDE_BYTES {
193        return Err(SpecError::ConfigOverridesTooLarge {
194            bytes: serialized_len,
195            max: MAX_CONFIG_OVERRIDE_BYTES,
196        });
197    }
198    Ok(())
199}
200
201#[cfg(test)]
202mod config_overrides_tests {
203    use super::*;
204    use serde_json::json;
205
206    fn ok(packs: &[(&str, &[(&str, Value)])]) -> Result<(), SpecError> {
207        let mut overrides = BTreeMap::new();
208        for (pack_id, fields) in packs {
209            let mut field_map = BTreeMap::new();
210            for (k, v) in *fields {
211                field_map.insert((*k).to_string(), v.clone());
212            }
213            overrides.insert((*pack_id).to_string(), field_map);
214        }
215        validate_config_overrides(&overrides)
216    }
217
218    #[test]
219    fn empty_overrides_pass() {
220        assert!(ok(&[]).is_ok());
221    }
222
223    #[test]
224    fn single_pack_single_key_passes() {
225        assert!(
226            ok(&[(
227                "messaging-telegram",
228                &[("api_base_url", json!("https://staging.example.com"))],
229            )])
230            .is_ok()
231        );
232    }
233
234    #[test]
235    fn empty_pack_id_rejected() {
236        let err = ok(&[("", &[("api_base_url", json!("x"))])]).unwrap_err();
237        assert_eq!(err, SpecError::ConfigOverrideEmptyPackId);
238    }
239
240    #[test]
241    fn empty_config_key_rejected() {
242        let err = ok(&[("messaging-telegram", &[("", json!("x"))])]).unwrap_err();
243        assert_eq!(
244            err,
245            SpecError::ConfigOverrideEmptyKey {
246                pack_id: "messaging-telegram".to_string(),
247            }
248        );
249    }
250
251    #[test]
252    fn too_many_packs_rejected() {
253        let mut overrides = BTreeMap::new();
254        for i in 0..=MAX_CONFIG_OVERRIDE_PACKS {
255            let mut fields = BTreeMap::new();
256            fields.insert("k".to_string(), json!("v"));
257            overrides.insert(format!("pack-{i}"), fields);
258        }
259        let err = validate_config_overrides(&overrides).unwrap_err();
260        assert_eq!(
261            err,
262            SpecError::ConfigOverridesTooManyPacks {
263                count: MAX_CONFIG_OVERRIDE_PACKS + 1,
264                max: MAX_CONFIG_OVERRIDE_PACKS,
265            }
266        );
267    }
268
269    #[test]
270    fn too_many_keys_per_pack_rejected() {
271        let mut fields = BTreeMap::new();
272        for i in 0..=MAX_CONFIG_OVERRIDE_KEYS_PER_PACK {
273            fields.insert(format!("k-{i}"), json!("v"));
274        }
275        let mut overrides = BTreeMap::new();
276        overrides.insert("messaging-telegram".to_string(), fields);
277        let err = validate_config_overrides(&overrides).unwrap_err();
278        assert_eq!(
279            err,
280            SpecError::ConfigOverridesTooManyKeysForPack {
281                pack_id: "messaging-telegram".to_string(),
282                count: MAX_CONFIG_OVERRIDE_KEYS_PER_PACK + 1,
283                max: MAX_CONFIG_OVERRIDE_KEYS_PER_PACK,
284            }
285        );
286    }
287
288    /// A value crafted to push the serialized representation past
289    /// `MAX_CONFIG_OVERRIDE_BYTES` even though the pack + key counts
290    /// pass. Catches the "fewer big values bypass the cap" attack.
291    #[test]
292    fn oversized_total_serialized_rejected() {
293        let mut fields = BTreeMap::new();
294        fields.insert(
295            "blob".to_string(),
296            json!("x".repeat(MAX_CONFIG_OVERRIDE_BYTES)),
297        );
298        let mut overrides = BTreeMap::new();
299        overrides.insert("p".to_string(), fields);
300        let err = validate_config_overrides(&overrides).unwrap_err();
301        match err {
302            SpecError::ConfigOverridesTooLarge { bytes, max } => {
303                assert!(bytes > max, "must report bytes={bytes} > max={max}");
304                assert_eq!(max, MAX_CONFIG_OVERRIDE_BYTES);
305            }
306            other => panic!("expected ConfigOverridesTooLarge, got {other:?}"),
307        }
308    }
309}