1use std::collections::BTreeMap;
48use std::path::PathBuf;
49
50use chrono::{DateTime, Utc};
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53use thiserror::Error;
54
55use crate::bundle_deployment::{
56 BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
57};
58use crate::environment::Environment;
59use crate::ids::{BundleId, CustomerId, DeploymentId, RevisionId};
60use crate::revision::RevisionLifecycle;
61use crate::version::SchemaVersion;
62use greentic_types::EnvId;
63
64#[derive(Debug, Clone, PartialEq, Eq, Error)]
72pub enum BundleError {
73 #[error("bundle `{bundle_id}` for customer `{customer_id}` already deployed in env `{env_id}`")]
76 AlreadyDeployed {
77 bundle_id: BundleId,
78 customer_id: CustomerId,
79 env_id: EnvId,
80 },
81 #[error("deployment `{deployment_id}` not found in env `{env_id}`")]
82 DeploymentNotFound {
83 deployment_id: DeploymentId,
84 env_id: EnvId,
85 },
86 #[error(
89 "deployment `{deployment_id}` is still live: {active_splits} traffic split(s), \
90 {active_revisions} non-archived revision(s). Archive revisions and clear the \
91 split first."
92 )]
93 StillLive {
94 deployment_id: DeploymentId,
95 active_splits: usize,
96 active_revisions: usize,
97 },
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AddBundlePayload {
112 pub bundle_id: BundleId,
113 pub customer_id: CustomerId,
114 pub revenue_share: Vec<RevenueShareEntry>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub route_binding: Option<RouteBinding>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub authorization_ref: Option<String>,
119 #[serde(default)]
120 pub config_overrides: BTreeMap<String, BTreeMap<String, Value>>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct UpdateBundlePayload {
129 pub deployment_id: DeploymentId,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub status: Option<BundleDeploymentStatus>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub route_binding: Option<RouteBinding>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub revenue_share: Option<Vec<RevenueShareEntry>>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RemoveBundleOutcome {
147 pub deployment: BundleDeployment,
148 pub pruned_revision_ids: Vec<RevisionId>,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct BundleUpdateApplied {
158 pub index: usize,
160 pub revenue_share_changed: bool,
166}
167
168pub fn add_bundle(
180 env: &mut Environment,
181 payload: AddBundlePayload,
182 deployment_id: DeploymentId,
183 now: DateTime<Utc>,
184) -> Result<usize, BundleError> {
185 if env
186 .bundles
187 .iter()
188 .any(|b| b.bundle_id == payload.bundle_id && b.customer_id == payload.customer_id)
189 {
190 return Err(BundleError::AlreadyDeployed {
191 bundle_id: payload.bundle_id,
192 customer_id: payload.customer_id,
193 env_id: env.environment_id.clone(),
194 });
195 }
196 let route_binding = payload.route_binding.unwrap_or_else(|| RouteBinding {
197 hosts: Vec::new(),
198 path_prefixes: Vec::new(),
199 tenant_selector: TenantSelector {
200 tenant: "default".to_string(),
201 team: "default".to_string(),
202 },
203 });
204 let authorization_ref = payload
205 .authorization_ref
206 .map(PathBuf::from)
207 .unwrap_or_else(|| PathBuf::from("auth.json"));
208 env.bundles.push(BundleDeployment {
209 schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
210 deployment_id,
211 env_id: env.environment_id.clone(),
212 bundle_id: payload.bundle_id,
213 customer_id: payload.customer_id,
214 status: BundleDeploymentStatus::Active,
215 current_revisions: Vec::new(),
216 route_binding,
217 revenue_share: payload.revenue_share,
218 revenue_policy_ref: PathBuf::new(),
220 usage: None,
221 created_at: now,
222 authorization_ref,
223 config_overrides: payload.config_overrides,
224 });
225 Ok(env.bundles.len() - 1)
226}
227
228pub fn update_bundle(
236 env: &mut Environment,
237 payload: UpdateBundlePayload,
238) -> Result<BundleUpdateApplied, BundleError> {
239 let UpdateBundlePayload {
240 deployment_id,
241 status,
242 route_binding,
243 revenue_share,
244 config_overrides,
245 } = payload;
246 let index = env
247 .bundles
248 .iter()
249 .position(|b| b.deployment_id == deployment_id)
250 .ok_or_else(|| BundleError::DeploymentNotFound {
251 deployment_id,
252 env_id: env.environment_id.clone(),
253 })?;
254 if let Some(s) = status {
255 env.bundles[index].status = s;
256 }
257 if let Some(rb) = route_binding {
258 env.bundles[index].route_binding = rb;
259 }
260 if let Some(overrides) = config_overrides {
261 env.bundles[index].config_overrides = overrides;
262 }
263 let revenue_share_changed = revenue_share.is_some();
264 if let Some(shares) = revenue_share {
265 env.bundles[index].revenue_share = shares;
266 }
267 Ok(BundleUpdateApplied {
268 index,
269 revenue_share_changed,
270 })
271}
272
273pub fn remove_bundle(
283 env: &mut Environment,
284 deployment_id: DeploymentId,
285) -> Result<RemoveBundleOutcome, BundleError> {
286 let index = env
287 .bundles
288 .iter()
289 .position(|b| b.deployment_id == deployment_id)
290 .ok_or_else(|| BundleError::DeploymentNotFound {
291 deployment_id,
292 env_id: env.environment_id.clone(),
293 })?;
294 let active_splits = env
300 .traffic_splits
301 .iter()
302 .filter(|s| s.deployment_id == deployment_id)
303 .count();
304 let mut active_revisions = 0usize;
305 let mut pruned_revision_ids: Vec<RevisionId> = Vec::new();
306 for r in env.revisions.iter() {
307 if r.deployment_id != deployment_id {
308 continue;
309 }
310 if matches!(r.lifecycle, RevisionLifecycle::Archived) {
311 pruned_revision_ids.push(r.revision_id);
312 } else {
313 active_revisions += 1;
314 }
315 }
316 if active_splits > 0 || active_revisions > 0 {
317 return Err(BundleError::StillLive {
318 deployment_id,
319 active_splits,
320 active_revisions,
321 });
322 }
323 let deployment = env.bundles.remove(index);
324 env.revisions.retain(|r| r.deployment_id != deployment_id);
325 Ok(RemoveBundleOutcome {
326 deployment,
327 pruned_revision_ids,
328 })
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::engine::fresh_environment;
335 use crate::environment::EnvironmentHostConfig;
336 use crate::ids::PartyId;
337 use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
338 use crate::revision::Revision;
339 use crate::traffic_split::TrafficSplit;
340
341 fn env_id() -> EnvId {
342 EnvId::try_from("local").unwrap()
343 }
344
345 fn minimal_env() -> Environment {
346 fresh_environment(
347 &env_id(),
348 "Local".to_string(),
349 EnvironmentHostConfig {
350 env_id: env_id(),
351 region: None,
352 tenant_org_id: None,
353 listen_addr: None,
354 public_base_url: None,
355 gui_enabled: None,
356 },
357 RevocationConfig::default(),
358 RetentionPolicy::default(),
359 HealthStatus::default(),
360 )
361 }
362
363 fn fixed_now() -> DateTime<Utc> {
364 "2026-06-12T00:00:00Z".parse().unwrap()
365 }
366
367 fn shares() -> Vec<RevenueShareEntry> {
368 vec![RevenueShareEntry {
369 party_id: PartyId::new("greentic"),
370 basis_points: 10_000,
371 }]
372 }
373
374 fn add_payload(bundle: &str, customer: &str) -> AddBundlePayload {
375 AddBundlePayload {
376 bundle_id: BundleId::new(bundle),
377 customer_id: CustomerId::new(customer),
378 revenue_share: shares(),
379 route_binding: None,
380 authorization_ref: None,
381 config_overrides: BTreeMap::new(),
382 }
383 }
384
385 fn revision_for(deployment_id: DeploymentId, lifecycle: RevisionLifecycle) -> Revision {
386 Revision {
387 schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
388 revision_id: RevisionId::new(),
389 env_id: env_id(),
390 bundle_id: BundleId::new("acme"),
391 deployment_id,
392 sequence: 1,
393 created_at: fixed_now(),
394 bundle_digest: "sha256:deadbeef".to_string(),
395 bundle_source_uri: None,
396 pack_list: Vec::new(),
397 pack_list_lock_ref: PathBuf::from("pack-list.lock"),
398 pack_config_refs: Vec::new(),
399 config_digest: "sha256:cafe".to_string(),
400 signature_sidecar_ref: PathBuf::from("rev.sig"),
401 lifecycle,
402 staged_at: None,
403 warmed_at: None,
404 drain_seconds: 0,
405 abort_metrics: Vec::new(),
406 }
407 }
408
409 fn split_for(deployment_id: DeploymentId) -> TrafficSplit {
410 TrafficSplit {
411 schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
412 env_id: env_id(),
413 deployment_id,
414 bundle_id: BundleId::new("acme"),
415 generation: 1,
416 entries: Vec::new(),
417 updated_at: fixed_now(),
418 updated_by: "test".to_string(),
419 idempotency_key: "k".to_string(),
420 authorization_ref: PathBuf::from("auth.json"),
421 previous_split_ref: None,
422 }
423 }
424
425 #[test]
428 fn add_bundle_appends_with_defaults_and_empty_policy_ref() {
429 let mut env = minimal_env();
430 let did = DeploymentId::new();
431 let idx = add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now())
432 .expect("fresh pair deploys");
433 assert_eq!(idx, 0);
434 let dep = &env.bundles[0];
435 assert_eq!(dep.deployment_id, did);
436 assert_eq!(dep.status, BundleDeploymentStatus::Active);
437 assert_eq!(dep.created_at, fixed_now());
438 assert_eq!(dep.revenue_policy_ref, PathBuf::new(), "backend pins ref");
439 assert_eq!(dep.authorization_ref, PathBuf::from("auth.json"));
440 assert_eq!(dep.route_binding.tenant_selector.tenant, "default");
441 }
442
443 #[test]
444 fn add_bundle_rejects_duplicate_bundle_customer_pair() {
445 let mut env = minimal_env();
446 add_bundle(
447 &mut env,
448 add_payload("acme", "cust-1"),
449 DeploymentId::new(),
450 fixed_now(),
451 )
452 .unwrap();
453 let err = add_bundle(
454 &mut env,
455 add_payload("acme", "cust-1"),
456 DeploymentId::new(),
457 fixed_now(),
458 )
459 .unwrap_err();
460 assert_eq!(
461 err.to_string(),
462 "bundle `acme` for customer `cust-1` already deployed in env `local`"
463 );
464 assert_eq!(env.bundles.len(), 1, "env untouched on Err");
465 }
466
467 #[test]
468 fn add_bundle_allows_same_bundle_for_different_customer() {
469 let mut env = minimal_env();
470 add_bundle(
471 &mut env,
472 add_payload("acme", "cust-1"),
473 DeploymentId::new(),
474 fixed_now(),
475 )
476 .unwrap();
477 add_bundle(
478 &mut env,
479 add_payload("acme", "cust-2"),
480 DeploymentId::new(),
481 fixed_now(),
482 )
483 .expect("different customer is a distinct deployment");
484 assert_eq!(env.bundles.len(), 2);
485 }
486
487 #[test]
490 fn update_bundle_patches_fields_and_flags_revenue_change() {
491 let mut env = minimal_env();
492 let did = DeploymentId::new();
493 add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
494 let applied = update_bundle(
495 &mut env,
496 UpdateBundlePayload {
497 deployment_id: did,
498 status: Some(BundleDeploymentStatus::Paused),
499 route_binding: None,
500 revenue_share: Some(vec![RevenueShareEntry {
501 party_id: PartyId::new("partner"),
502 basis_points: 10_000,
503 }]),
504 config_overrides: None,
505 },
506 )
507 .expect("known deployment patches");
508 assert_eq!(applied.index, 0);
509 assert!(applied.revenue_share_changed);
510 assert_eq!(env.bundles[0].status, BundleDeploymentStatus::Paused);
511 assert_eq!(env.bundles[0].revenue_share[0].party_id.as_str(), "partner");
512 }
513
514 #[test]
515 fn update_bundle_without_revenue_share_does_not_flag() {
516 let mut env = minimal_env();
517 let did = DeploymentId::new();
518 add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
519 let applied = update_bundle(
520 &mut env,
521 UpdateBundlePayload {
522 deployment_id: did,
523 status: Some(BundleDeploymentStatus::Paused),
524 route_binding: None,
525 revenue_share: None,
526 config_overrides: None,
527 },
528 )
529 .unwrap();
530 assert!(!applied.revenue_share_changed);
531 }
532
533 #[test]
534 fn update_bundle_rejects_unknown_deployment() {
535 let mut env = minimal_env();
536 let did = DeploymentId::new();
537 let err = update_bundle(
538 &mut env,
539 UpdateBundlePayload {
540 deployment_id: did,
541 status: None,
542 route_binding: None,
543 revenue_share: None,
544 config_overrides: None,
545 },
546 )
547 .unwrap_err();
548 assert_eq!(
549 err.to_string(),
550 format!("deployment `{did}` not found in env `local`")
551 );
552 }
553
554 #[test]
557 fn remove_bundle_prunes_archived_revisions() {
558 let mut env = minimal_env();
559 let did = DeploymentId::new();
560 add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
561 env.revisions
562 .push(revision_for(did, RevisionLifecycle::Archived));
563 env.revisions
564 .push(revision_for(did, RevisionLifecycle::Archived));
565 let other = DeploymentId::new();
566 env.revisions
567 .push(revision_for(other, RevisionLifecycle::Ready));
568
569 let outcome = remove_bundle(&mut env, did).expect("quiesced deployment removes");
570 assert_eq!(outcome.deployment.deployment_id, did);
571 assert_eq!(outcome.pruned_revision_ids.len(), 2);
572 assert!(env.bundles.is_empty());
573 assert_eq!(env.revisions.len(), 1, "other deployment's revision kept");
574 assert_eq!(env.revisions[0].deployment_id, other);
575 }
576
577 #[test]
578 fn remove_bundle_refuses_live_state() {
579 let mut env = minimal_env();
580 let did = DeploymentId::new();
581 add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
582 env.revisions
583 .push(revision_for(did, RevisionLifecycle::Ready));
584 env.traffic_splits.push(split_for(did));
585 let err = remove_bundle(&mut env, did).unwrap_err();
586 assert_eq!(
587 err.to_string(),
588 format!(
589 "deployment `{did}` is still live: 1 traffic split(s), 1 non-archived \
590 revision(s). Archive revisions and clear the split first."
591 )
592 );
593 assert_eq!(env.bundles.len(), 1, "env untouched on Err");
594 assert_eq!(env.revisions.len(), 1, "no pruning on refusal");
595 }
596
597 #[test]
598 fn remove_bundle_rejects_unknown_deployment() {
599 let mut env = minimal_env();
600 let did = DeploymentId::new();
601 let err = remove_bundle(&mut env, did).unwrap_err();
602 assert!(matches!(err, BundleError::DeploymentNotFound { .. }));
603 }
604
605 #[test]
608 fn add_payload_wire_encoding_is_pinned() {
609 let payload = add_payload("acme", "cust-1");
610 let value = serde_json::to_value(&payload).unwrap();
611 assert_eq!(
612 value,
613 serde_json::json!({
614 "bundle_id": "acme",
615 "customer_id": "cust-1",
616 "revenue_share": [{"party_id": "greentic", "basis_points": 10_000}],
617 "config_overrides": {},
618 })
619 );
620 let decoded: AddBundlePayload = serde_json::from_value(value).unwrap();
621 assert_eq!(decoded, payload);
622 }
623
624 #[test]
625 fn add_payload_decodes_without_optional_fields() {
626 let decoded: AddBundlePayload = serde_json::from_value(serde_json::json!({
627 "bundle_id": "acme",
628 "customer_id": "cust-1",
629 "revenue_share": [],
630 }))
631 .unwrap();
632 assert!(decoded.route_binding.is_none());
633 assert!(decoded.authorization_ref.is_none());
634 assert!(decoded.config_overrides.is_empty());
635 }
636
637 #[test]
638 fn update_payload_wire_encoding_is_pinned() {
639 let did = DeploymentId::new();
640 let payload = UpdateBundlePayload {
641 deployment_id: did,
642 status: Some(BundleDeploymentStatus::Paused),
643 route_binding: None,
644 revenue_share: None,
645 config_overrides: None,
646 };
647 let value = serde_json::to_value(&payload).unwrap();
648 assert_eq!(
649 value,
650 serde_json::json!({
651 "deployment_id": did.to_string(),
652 "status": "paused",
653 })
654 );
655 let decoded: UpdateBundlePayload = serde_json::from_value(value).unwrap();
656 assert_eq!(decoded, payload);
657 }
658
659 #[test]
660 fn remove_outcome_wire_encoding_round_trips() {
661 let mut env = minimal_env();
662 let did = DeploymentId::new();
663 add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
664 env.revisions
665 .push(revision_for(did, RevisionLifecycle::Archived));
666 let outcome = remove_bundle(&mut env, did).unwrap();
667 let value = serde_json::to_value(&outcome).unwrap();
668 assert_eq!(value["deployment"]["deployment_id"], did.to_string());
669 assert_eq!(
670 value["pruned_revision_ids"][0],
671 outcome.pruned_revision_ids[0].to_string()
672 );
673 let decoded: RemoveBundleOutcome = serde_json::from_value(value).unwrap();
674 assert_eq!(decoded, outcome);
675 }
676}