1use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13use greentic_deploy_spec::{
14 BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
15 RevenueShareEntry, RouteBinding, TenantSelector,
16};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use crate::environment::mutations::UpdateBundlePayload as StoreUpdateBundlePayload;
21use crate::environment::{
22 AddBundlePayload as StoreAddBundlePayload, EnvironmentReads, LocalFsStore, RemoveBundleOutcome,
23};
24
25use super::{
26 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
27 resolve_idempotency_key,
28};
29
30const NOUN: &str = "bundles";
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BundleAddPayload {
34 pub environment_id: String,
35 pub bundle_id: String,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub customer_id: Option<String>,
40 pub route_binding: RouteBindingPayload,
41 #[serde(default = "default_revenue_share")]
42 pub revenue_share: Vec<RevenueShareEntryPayload>,
43 #[serde(default = "default_authorization_ref")]
44 pub authorization_ref: PathBuf,
45 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
51 pub config_overrides: BTreeMap<String, BTreeMap<String, Value>>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub idempotency_key: Option<String>,
58}
59
60const LOCAL_DEV_CUSTOMER_ID: &str = "local-dev";
63
64pub(crate) fn default_revenue_share() -> Vec<RevenueShareEntryPayload> {
65 vec![RevenueShareEntryPayload {
66 party_id: "greentic".to_string(),
67 basis_points: 10_000,
68 }]
69}
70pub(crate) fn default_authorization_ref() -> PathBuf {
71 PathBuf::from("auth.json")
72}
73
74pub(crate) fn convert_revenue_share(
78 entries: &[RevenueShareEntryPayload],
79) -> Vec<RevenueShareEntry> {
80 entries
81 .iter()
82 .cloned()
83 .map(|e| RevenueShareEntry {
84 party_id: greentic_deploy_spec::PartyId::new(e.party_id),
85 basis_points: e.basis_points,
86 })
87 .collect()
88}
89
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct RouteBindingPayload {
92 #[serde(default)]
93 pub hosts: Vec<String>,
94 #[serde(default)]
95 pub path_prefixes: Vec<String>,
96 #[serde(default)]
97 pub tenant_selector: Option<TenantSelectorPayload>,
98}
99
100impl RouteBindingPayload {
101 pub fn validate(&self) -> Result<(), OpError> {
106 if self.tenant_selector.is_some() && self.hosts.is_empty() && self.path_prefixes.is_empty()
107 {
108 return Err(OpError::InvalidArgument(
109 "route_binding: tenant_selector requires at least one host or path_prefix \
110 (a binding with no matchers would be unreachable)"
111 .to_string(),
112 ));
113 }
114 Ok(())
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct TenantSelectorPayload {
120 pub tenant: String,
121 pub team: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RevenueShareEntryPayload {
126 pub party_id: String,
127 pub basis_points: u32,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct BundleSummary {
132 pub environment_id: String,
133 pub bundle_id: String,
134 pub deployment_id: String,
135 pub customer_id: String,
136 pub status: BundleDeploymentStatus,
137 pub current_revision_count: usize,
138 pub hosts: Vec<String>,
139}
140
141impl BundleSummary {
142 pub(crate) fn from(env_id: &EnvId, b: &BundleDeployment) -> Self {
143 Self {
144 environment_id: env_id.as_str().to_string(),
145 bundle_id: b.bundle_id.as_str().to_string(),
146 deployment_id: b.deployment_id.to_string(),
147 customer_id: b.customer_id.as_str().to_string(),
148 status: b.status,
149 current_revision_count: b.current_revisions.len(),
150 hosts: b.route_binding.hosts.clone(),
151 }
152 }
153}
154
155pub fn add(
156 store: &LocalFsStore,
157 flags: &OpFlags,
158 payload: Option<BundleAddPayload>,
159) -> Result<OpOutcome, OpError> {
160 if flags.schema_only {
161 return Ok(OpOutcome::new(NOUN, "add", add_schema()));
162 }
163 let payload = resolve_payload(flags, payload)?;
164 let env_id = parse_env_id(&payload.environment_id)?;
165 if payload.bundle_id.trim().is_empty() {
166 return Err(OpError::InvalidArgument(
167 "bundle_id must not be empty".to_string(),
168 ));
169 }
170 let bundle_id = BundleId::new(payload.bundle_id);
171 let customer_id = resolve_customer_id(&env_id, payload.customer_id.clone())?;
175 let revenue_share: Vec<RevenueShareEntry> = payload
176 .revenue_share
177 .iter()
178 .cloned()
179 .map(|e| RevenueShareEntry {
180 party_id: greentic_deploy_spec::PartyId::new(e.party_id),
181 basis_points: e.basis_points,
182 })
183 .collect();
184 let config_overrides = payload.config_overrides.clone();
185 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
186 let route_binding_payload = payload.route_binding.clone();
187 let ctx = AuditCtx {
188 env_id: env_id.clone(),
189 noun: NOUN,
190 verb: "add",
191 target: json!({
192 "bundle_id": bundle_id.as_str(),
193 "customer_id": customer_id.as_str(),
194 "revenue_share": revenue_share_json(&revenue_share),
195 "config_overrides": config_overrides_audit_shape(&config_overrides),
196 }),
197 idempotency_key: Some(idempotency_key.as_str().to_string()),
198 };
199 audit_and_record(store, ctx, |_committed| {
200 let deployment = store
201 .add_bundle(
202 &env_id,
203 StoreAddBundlePayload {
204 bundle_id,
205 customer_id,
206 revenue_share,
207 route_binding: Some(into_route_binding(route_binding_payload)),
208 authorization_ref: Some(
209 payload.authorization_ref.to_string_lossy().into_owned(),
210 ),
211 config_overrides,
212 },
213 idempotency_key,
214 )
215 .map_err(map_store_err_preserving_noun)?;
216 let summary = BundleSummary::from(&env_id, &deployment);
217 let outcome = OpOutcome::new(
218 NOUN,
219 "add",
220 serde_json::to_value(summary).expect("BundleSummary is json-safe"),
221 );
222 Ok((outcome, super::AuditGens::NONE))
223 })
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct BundleUpdatePayload {
228 pub environment_id: String,
229 pub deployment_id: String,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub status: Option<BundleDeploymentStatus>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub route_binding: Option<RouteBindingPayload>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub revenue_share: Option<Vec<RevenueShareEntryPayload>>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub idempotency_key: Option<String>,
248}
249
250pub fn update(
251 store: &LocalFsStore,
252 flags: &OpFlags,
253 payload: Option<BundleUpdatePayload>,
254) -> Result<OpOutcome, OpError> {
255 if flags.schema_only {
256 return Ok(OpOutcome::new(NOUN, "update", update_schema()));
257 }
258 let payload = resolve_payload::<BundleUpdatePayload>(flags, payload)?;
259 let env_id = parse_env_id(&payload.environment_id)?;
260 let deployment_id = parse_deployment_id(&payload.deployment_id)?;
261 let new_revenue_share: Option<Vec<RevenueShareEntry>> =
264 payload.revenue_share.as_ref().map(|s| {
265 s.iter()
266 .cloned()
267 .map(|e| RevenueShareEntry {
268 party_id: greentic_deploy_spec::PartyId::new(e.party_id),
269 basis_points: e.basis_points,
270 })
271 .collect()
272 });
273 let new_route_binding = payload.route_binding.clone().map(into_route_binding);
274 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
275 let mut target = json!({"deployment_id": deployment_id.to_string()});
276 if let Some(shares) = &new_revenue_share {
277 target["revenue_share"] = revenue_share_json(shares);
278 }
279 if let Some(overrides) = &payload.config_overrides {
280 target["config_overrides"] = config_overrides_audit_shape(overrides);
281 }
282 let ctx = AuditCtx {
283 env_id: env_id.clone(),
284 noun: NOUN,
285 verb: "update",
286 target,
287 idempotency_key: Some(idempotency_key.as_str().to_string()),
288 };
289 audit_and_record(store, ctx, |_committed| {
290 let deployment = store
291 .update_bundle(
292 &env_id,
293 StoreUpdateBundlePayload {
294 deployment_id,
295 status: payload.status,
296 route_binding: new_route_binding,
297 revenue_share: new_revenue_share,
298 config_overrides: payload.config_overrides,
299 },
300 idempotency_key,
301 )
302 .map_err(map_store_err_preserving_noun)?;
303 let summary = BundleSummary::from(&env_id, &deployment);
304 let outcome = OpOutcome::new(
305 NOUN,
306 "update",
307 serde_json::to_value(summary).expect("BundleSummary is json-safe"),
308 );
309 Ok((outcome, super::AuditGens::NONE))
310 })
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct BundleRemovePayload {
315 pub environment_id: String,
316 pub deployment_id: String,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub idempotency_key: Option<String>,
323}
324
325pub fn remove(
326 store: &LocalFsStore,
327 flags: &OpFlags,
328 payload: Option<BundleRemovePayload>,
329) -> Result<OpOutcome, OpError> {
330 if flags.schema_only {
331 return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
332 }
333 let payload = resolve_payload::<BundleRemovePayload>(flags, payload)?;
334 let env_id = parse_env_id(&payload.environment_id)?;
335 let deployment_id = parse_deployment_id(&payload.deployment_id)?;
336 let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
337 let ctx = AuditCtx {
338 env_id: env_id.clone(),
339 noun: NOUN,
340 verb: "remove",
341 target: json!({"deployment_id": deployment_id.to_string()}),
345 idempotency_key: Some(idempotency_key.as_str().to_string()),
346 };
347 audit_and_record(store, ctx, |_committed| {
348 let RemoveBundleOutcome {
349 deployment,
350 pruned_revision_ids,
351 } = store
352 .remove_bundle(&env_id, deployment_id, idempotency_key)
353 .map_err(map_store_err_preserving_noun)?;
354 let summary = BundleSummary::from(&env_id, &deployment);
355 let mut result = serde_json::to_value(summary).expect("BundleSummary is json-safe");
356 result["pruned_revision_ids"] = json!(
360 pruned_revision_ids
361 .iter()
362 .map(|r| r.to_string())
363 .collect::<Vec<_>>()
364 );
365 let outcome = OpOutcome::new(NOUN, "remove", result);
366 Ok((outcome, super::AuditGens::NONE))
367 })
368}
369
370pub fn list(
371 store: &dyn EnvironmentReads,
372 flags: &OpFlags,
373 env_id: &str,
374) -> Result<OpOutcome, OpError> {
375 if flags.schema_only {
376 return Ok(OpOutcome::new(
377 NOUN,
378 "list",
379 json!({"input_schema": "env_id positional"}),
380 ));
381 }
382 let env_id = parse_env_id(env_id)?;
383 if !store.env_exists(&env_id)? {
384 return Err(OpError::NotFound(format!("environment `{env_id}`")));
385 }
386 let env = store.load_env(&env_id)?;
387 let deployments: Vec<BundleSummary> = env
388 .bundles
389 .iter()
390 .map(|b| BundleSummary::from(&env_id, b))
391 .collect();
392 Ok(OpOutcome::new(
393 NOUN,
394 "list",
395 json!({"environment_id": env_id.as_str(), "deployments": deployments}),
396 ))
397}
398
399fn resolve_payload<T: serde::de::DeserializeOwned>(
402 flags: &OpFlags,
403 payload: Option<T>,
404) -> Result<T, OpError> {
405 if let Some(p) = payload {
406 return Ok(p);
407 }
408 if let Some(path) = &flags.answers {
409 return super::load_answers::<T>(path);
410 }
411 Err(OpError::InvalidArgument(
412 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
413 ))
414}
415
416fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
417 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
418}
419
420pub(crate) fn resolve_customer_id(
423 env_id: &EnvId,
424 supplied: Option<String>,
425) -> Result<CustomerId, OpError> {
426 match supplied {
427 Some(c) if c.trim().is_empty() => Err(OpError::InvalidArgument(
428 "customer_id must not be empty".to_string(),
429 )),
430 Some(c) => Ok(CustomerId::new(c)),
431 None if env_id.as_str() == crate::defaults::LOCAL_ENV_ID => {
432 Ok(CustomerId::new(LOCAL_DEV_CUSTOMER_ID))
433 }
434 None => Err(OpError::InvalidArgument(format!(
435 "customer_id is required for non-local env `{env_id}` (the billing principal; P6)"
436 ))),
437 }
438}
439
440pub(super) fn config_overrides_audit_shape(
450 overrides: &BTreeMap<String, BTreeMap<String, Value>>,
451) -> Value {
452 let mut shape = serde_json::Map::with_capacity(overrides.len());
453 for (pack_id, keys) in overrides {
454 let keys: Vec<Value> = keys.keys().map(|k| Value::String(k.clone())).collect();
455 shape.insert(pack_id.clone(), Value::Array(keys));
456 }
457 Value::Object(shape)
458}
459
460fn revenue_share_json(shares: &[RevenueShareEntry]) -> Value {
462 Value::Array(
463 shares
464 .iter()
465 .map(|e| json!({"party_id": e.party_id.as_str(), "basis_points": e.basis_points}))
466 .collect(),
467 )
468}
469
470fn parse_deployment_id(raw: &str) -> Result<DeploymentId, OpError> {
471 use std::str::FromStr;
472 let ulid = ulid::Ulid::from_str(raw)
473 .map_err(|e| OpError::InvalidArgument(format!("deployment_id: {e}")))?;
474 Ok(DeploymentId(ulid))
475}
476
477pub(crate) fn into_route_binding(payload: RouteBindingPayload) -> RouteBinding {
478 let tenant_selector = payload
479 .tenant_selector
480 .map(|t| TenantSelector {
481 tenant: t.tenant,
482 team: t.team,
483 })
484 .unwrap_or_else(|| TenantSelector {
485 tenant: "default".to_string(),
486 team: "default".to_string(),
487 });
488 RouteBinding {
489 hosts: payload.hosts,
490 path_prefixes: payload.path_prefixes,
491 tenant_selector,
492 }
493}
494
495fn add_schema() -> Value {
496 json!({
497 "$schema": "https://json-schema.org/draft/2020-12/schema",
498 "title": "BundleAddPayload",
499 "type": "object",
500 "required": ["environment_id", "bundle_id", "route_binding"],
501 "additionalProperties": false,
502 "properties": {
503 "environment_id": {"type": "string"},
504 "bundle_id": {"type": "string"},
505 "customer_id": {"type": "string", "description": "billing principal; required for non-local envs, defaults to `local-dev` on `local`"},
506 "route_binding": {"type": "object"},
507 "revenue_share": {"type": "array"},
508 "authorization_ref": {"type": "string"},
509 "config_overrides": {
510 "type": "object",
511 "description": "D.4: per-pack provider config overrides — object keyed by pack_id, values are objects of {key: json-value}",
512 "additionalProperties": {"type": "object"}
513 },
514 "idempotency_key": {
515 "type": "string",
516 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
517 }
518 }
519 })
520}
521
522fn update_schema() -> Value {
523 json!({
524 "$schema": "https://json-schema.org/draft/2020-12/schema",
525 "title": "BundleUpdatePayload",
526 "type": "object",
527 "required": ["environment_id", "deployment_id"],
528 "additionalProperties": false,
529 "properties": {
530 "environment_id": {"type": "string"},
531 "deployment_id": {"type": "string", "description": "ULID"},
532 "status": {"type": "string", "enum": ["active", "paused", "archived"]},
533 "route_binding": {"type": "object"},
534 "revenue_share": {"type": "array"},
535 "config_overrides": {
536 "type": "object",
537 "description": "D.4: replace the deployment's config_overrides map wholesale (omit to leave untouched; pass `{}` to clear)",
538 "additionalProperties": {"type": "object"}
539 },
540 "idempotency_key": {
541 "type": "string",
542 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
543 }
544 }
545 })
546}
547
548fn remove_schema() -> Value {
549 json!({
550 "$schema": "https://json-schema.org/draft/2020-12/schema",
551 "title": "BundleRemovePayload",
552 "type": "object",
553 "required": ["environment_id", "deployment_id"],
554 "additionalProperties": false,
555 "properties": {
556 "environment_id": {"type": "string"},
557 "deployment_id": {"type": "string", "description": "ULID"},
558 "idempotency_key": {
559 "type": "string",
560 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
561 }
562 }
563 })
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::cli::tests_common::make_env;
570 use crate::environment::EnvironmentStore;
571 use tempfile::tempdir;
572
573 #[test]
579 fn remove_schema_lists_idempotency_key() {
580 let schema = remove_schema();
581 assert!(
582 schema.pointer("/properties/idempotency_key").is_some(),
583 "remove_schema must list `idempotency_key` so --schema-driven \
584 callers can supply the A8 retry key (schema: {schema:#})"
585 );
586 }
587
588 #[test]
592 fn add_schema_lists_idempotency_key() {
593 let schema = add_schema();
594 assert!(
595 schema.pointer("/properties/idempotency_key").is_some(),
596 "add_schema must list `idempotency_key` so --schema-driven \
597 callers can supply the A8 retry key (schema: {schema:#})"
598 );
599 }
600
601 #[test]
605 fn update_schema_lists_idempotency_key() {
606 let schema = update_schema();
607 assert!(
608 schema.pointer("/properties/idempotency_key").is_some(),
609 "update_schema must list `idempotency_key` so --schema-driven \
610 callers can supply the A8 retry key (schema: {schema:#})"
611 );
612 }
613
614 #[test]
619 fn remove_response_surfaces_pruned_revision_ids() {
620 use greentic_deploy_spec::RevisionLifecycle;
621 let dir = tempdir().unwrap();
622 let store = LocalFsStore::new(dir.path());
623 let env_id = EnvId::try_from("local").unwrap();
624 store.save(&make_env("local")).unwrap();
625 let env_dir = store.env_dir(&env_id).unwrap();
626 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
627
628 let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
629 let did_str = added
630 .result
631 .get("deployment_id")
632 .and_then(|v| v.as_str())
633 .unwrap()
634 .to_string();
635 let deployment_id = parse_deployment_id(&did_str).unwrap();
636
637 let mut env = store.load(&env_id).unwrap();
640 for _ in 0..2 {
641 env.revisions.push(crate::cli::tests_common::make_revision(
642 "local",
643 "fast2flow",
644 &deployment_id,
645 1,
646 RevisionLifecycle::Archived,
647 ));
648 }
649 store.save(&env).unwrap();
650
651 let removed = remove(
652 &store,
653 &OpFlags::default(),
654 Some(BundleRemovePayload {
655 environment_id: "local".to_string(),
656 deployment_id: did_str,
657 idempotency_key: None,
658 }),
659 )
660 .unwrap();
661
662 let pruned = removed
663 .result
664 .get("pruned_revision_ids")
665 .and_then(|v| v.as_array())
666 .expect("pruned_revision_ids on response");
667 assert_eq!(
668 pruned.len(),
669 2,
670 "both archived revisions surface in the response: {removed:#?}"
671 );
672 }
673
674 fn payload(bundle_id: &str) -> BundleAddPayload {
675 BundleAddPayload {
676 environment_id: "local".to_string(),
677 bundle_id: bundle_id.to_string(),
678 customer_id: Some("local-dev".to_string()),
679 route_binding: RouteBindingPayload {
680 hosts: vec![format!("{bundle_id}.local")],
681 path_prefixes: Vec::new(),
682 tenant_selector: None,
683 },
684 revenue_share: default_revenue_share(),
685 authorization_ref: default_authorization_ref(),
686 config_overrides: BTreeMap::new(),
687 idempotency_key: None,
688 }
689 }
690
691 #[test]
692 fn add_then_list_returns_deployment() {
693 let dir = tempdir().unwrap();
694 let store = LocalFsStore::new(dir.path());
695 store.save(&make_env("local")).unwrap();
696 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
697 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
698 let outcome = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
699 let did = outcome
700 .result
701 .get("deployment_id")
702 .and_then(|v| v.as_str())
703 .unwrap();
704 assert!(!did.is_empty());
705 let listed = list(&store, &OpFlags::default(), "local").unwrap();
706 let deployments = listed
707 .result
708 .get("deployments")
709 .and_then(|v| v.as_array())
710 .expect("deployments array");
711 assert_eq!(deployments.len(), 1);
712 }
713
714 #[test]
715 fn add_without_bootstrap_surfaces_operator_key_not_trusted() {
716 let dir = tempdir().unwrap();
722 let store = LocalFsStore::new(dir.path());
723 store.save(&make_env("local")).unwrap();
724 let err = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap_err();
729 assert!(
733 matches!(
734 err,
735 OpError::OperatorKey(_)
736 | OpError::RevenuePolicy(
737 crate::environment::BundleDeploymentError::OperatorKeyNotTrusted { .. }
738 )
739 ),
740 "expected OperatorKey or OperatorKeyNotTrusted, got {err:?}"
741 );
742 }
743
744 #[test]
745 fn add_rejects_duplicate_bundle_customer() {
746 let dir = tempdir().unwrap();
747 let store = LocalFsStore::new(dir.path());
748 store.save(&make_env("local")).unwrap();
749 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
750 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
751 add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
752 let err = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap_err();
753 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
754 }
755
756 #[test]
757 fn add_allows_same_bundle_for_different_customer() {
758 let dir = tempdir().unwrap();
759 let store = LocalFsStore::new(dir.path());
760 store.save(&make_env("local")).unwrap();
761 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
762 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
763 add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
764 let mut p2 = payload("fast2flow");
765 p2.customer_id = Some("other".to_string());
766 let outcome = add(&store, &OpFlags::default(), Some(p2)).unwrap();
767 assert_eq!(outcome.op, "add");
768 }
769
770 #[test]
771 fn update_changes_status_and_route_binding() {
772 let dir = tempdir().unwrap();
773 let store = LocalFsStore::new(dir.path());
774 store.save(&make_env("local")).unwrap();
775 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
776 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
777 let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
778 let did = added
779 .result
780 .get("deployment_id")
781 .and_then(|v| v.as_str())
782 .unwrap()
783 .to_string();
784 let outcome = update(
785 &store,
786 &OpFlags::default(),
787 Some(BundleUpdatePayload {
788 environment_id: "local".to_string(),
789 deployment_id: did.clone(),
790 status: Some(BundleDeploymentStatus::Paused),
791 route_binding: Some(RouteBindingPayload {
792 hosts: vec!["new.example.com".to_string()],
793 path_prefixes: vec!["/v1".to_string()],
794 tenant_selector: None,
795 }),
796 revenue_share: None,
797 config_overrides: None,
798 idempotency_key: None,
799 }),
800 )
801 .unwrap();
802 assert_eq!(
803 outcome.result.get("status").and_then(|v| v.as_str()),
804 Some("paused")
805 );
806 let hosts = outcome
807 .result
808 .get("hosts")
809 .and_then(|v| v.as_array())
810 .unwrap();
811 assert_eq!(hosts.len(), 1);
812 assert_eq!(hosts[0].as_str(), Some("new.example.com"));
813 }
814
815 #[test]
816 fn remove_rejects_deployment_with_revisions() {
817 let dir = tempdir().unwrap();
818 let store = LocalFsStore::new(dir.path());
819 let mut env = make_env("local");
820 let mut bundle = crate::cli::tests_common::make_bundle_deployment("local", "fast2flow");
821 let did = bundle.deployment_id;
822 let revision = crate::cli::tests_common::make_revision(
825 "local",
826 "fast2flow",
827 &did,
828 1,
829 greentic_deploy_spec::RevisionLifecycle::Ready,
830 );
831 bundle.current_revisions.push(revision.revision_id);
832 env.bundles.push(bundle);
833 env.revisions.push(revision);
834 store.save(&env).unwrap();
835 let err = remove(
836 &store,
837 &OpFlags::default(),
838 Some(BundleRemovePayload {
839 environment_id: "local".to_string(),
840 deployment_id: did.to_string(),
841 idempotency_key: None,
842 }),
843 )
844 .unwrap_err();
845 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
846 }
847
848 #[test]
849 fn remove_with_no_revisions_succeeds() {
850 let dir = tempdir().unwrap();
851 let store = LocalFsStore::new(dir.path());
852 store.save(&make_env("local")).unwrap();
853 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
854 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
855 let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
856 let did = added
857 .result
858 .get("deployment_id")
859 .and_then(|v| v.as_str())
860 .unwrap()
861 .to_string();
862 remove(
863 &store,
864 &OpFlags::default(),
865 Some(BundleRemovePayload {
866 environment_id: "local".to_string(),
867 deployment_id: did,
868 idempotency_key: None,
869 }),
870 )
871 .unwrap();
872 let listed = list(&store, &OpFlags::default(), "local").unwrap();
873 let deployments = listed.result.get("deployments").and_then(|v| v.as_array());
874 assert!(deployments.map(|v| v.is_empty()).unwrap_or(false));
875 }
876
877 #[test]
878 fn remove_rejects_when_traffic_split_references_deployment() {
879 let dir = tempdir().unwrap();
884 let store = LocalFsStore::new(dir.path());
885 let mut env = make_env("local");
886 let bundle = crate::cli::tests_common::make_bundle_deployment("local", "fast2flow");
887 let did = bundle.deployment_id;
888 let revision = crate::cli::tests_common::make_revision(
891 "local",
892 "fast2flow",
893 &did,
894 1,
895 greentic_deploy_spec::RevisionLifecycle::Ready,
896 );
897 let split = crate::cli::tests_common::make_traffic_split(
898 "local",
899 "fast2flow",
900 &did,
901 &revision.revision_id,
902 "k1",
903 );
904 env.bundles.push(bundle);
905 env.revisions.push(revision);
906 env.traffic_splits.push(split);
907 store.save(&env).unwrap();
908 let err = remove(
909 &store,
910 &OpFlags::default(),
911 Some(BundleRemovePayload {
912 environment_id: "local".to_string(),
913 deployment_id: did.to_string(),
914 idempotency_key: None,
915 }),
916 )
917 .unwrap_err();
918 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
919 }
920
921 #[test]
922 fn remove_rejects_when_non_archived_revision_exists() {
923 let dir = tempdir().unwrap();
927 let store = LocalFsStore::new(dir.path());
928 let mut env = make_env("local");
929 let bundle = crate::cli::tests_common::make_bundle_deployment("local", "fast2flow");
930 let did = bundle.deployment_id;
931 let revision = crate::cli::tests_common::make_revision(
932 "local",
933 "fast2flow",
934 &did,
935 1,
936 greentic_deploy_spec::RevisionLifecycle::Staged,
937 );
938 env.bundles.push(bundle);
939 env.revisions.push(revision);
940 store.save(&env).unwrap();
941 let err = remove(
942 &store,
943 &OpFlags::default(),
944 Some(BundleRemovePayload {
945 environment_id: "local".to_string(),
946 deployment_id: did.to_string(),
947 idempotency_key: None,
948 }),
949 )
950 .unwrap_err();
951 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
952 }
953
954 #[test]
957 fn add_writes_v1_revenue_policy_and_pins_ref() {
958 let dir = tempdir().unwrap();
959 let store = LocalFsStore::new(dir.path());
960 store.save(&make_env("local")).unwrap();
961 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
962 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
963 add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
964
965 let env = store.load(&parse_env_id("local").unwrap()).unwrap();
966 let dep = &env.bundles[0];
967 assert_eq!(
968 dep.revenue_policy_ref,
969 PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig")
970 );
971 let env_dir = dir.path().join("local");
972 assert!(env_dir.join(&dep.revenue_policy_ref).is_file());
973 assert!(
974 env_dir
975 .join("billing-policies/fast2flow/local-dev/v1.json")
976 .is_file()
977 );
978 }
979
980 #[test]
981 fn add_overwrites_orphan_v1_from_failed_prior_attempt() {
982 let dir = tempdir().unwrap();
987 let store = LocalFsStore::new(dir.path());
988 store.save(&make_env("local")).unwrap();
989 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
990 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
991 let orphan_dir = dir
993 .path()
994 .join("local/billing-policies/fast2flow/local-dev");
995 std::fs::create_dir_all(&orphan_dir).unwrap();
996 std::fs::write(orphan_dir.join("v1.json"), b"{\"stale\":true}").unwrap();
997
998 add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
999
1000 let env = store.load(&parse_env_id("local").unwrap()).unwrap();
1001 assert_eq!(
1002 env.bundles[0].revenue_policy_ref,
1003 PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig"),
1004 "retry must reuse v1, not advance past the orphan"
1005 );
1006 assert!(!orphan_dir.join("v2.json").exists());
1007 let doc: greentic_deploy_spec::RevenuePolicyDocument =
1009 serde_json::from_slice(&std::fs::read(orphan_dir.join("v1.json")).unwrap()).unwrap();
1010 assert_eq!(doc.version, 1);
1011 assert!(doc.validate().is_ok());
1012 }
1013
1014 #[test]
1015 fn add_rejects_empty_bundle_id_early() {
1016 let dir = tempdir().unwrap();
1017 let store = LocalFsStore::new(dir.path());
1018 store.save(&make_env("local")).unwrap();
1019 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1020 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1021 let mut p = payload("fast2flow");
1022 p.bundle_id = "".to_string();
1023 let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
1024 assert!(
1025 matches!(&err, OpError::InvalidArgument(m) if m.contains("bundle_id")),
1026 "got {err:?}"
1027 );
1028 assert!(!dir.path().join("local/billing-policies").exists());
1030 }
1031
1032 #[test]
1033 fn add_rejects_empty_customer_id_early() {
1034 let dir = tempdir().unwrap();
1035 let store = LocalFsStore::new(dir.path());
1036 store.save(&make_env("local")).unwrap();
1037 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1038 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1039 let mut p = payload("fast2flow");
1040 p.customer_id = Some("".to_string());
1041 let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
1042 assert!(
1043 matches!(&err, OpError::InvalidArgument(m) if m.contains("customer_id")),
1044 "got {err:?}"
1045 );
1046 assert!(!dir.path().join("local/billing-policies").exists());
1047 }
1048
1049 #[test]
1050 fn add_local_defaults_customer_id_when_omitted() {
1051 let dir = tempdir().unwrap();
1052 let store = LocalFsStore::new(dir.path());
1053 store.save(&make_env("local")).unwrap();
1054 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1055 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1056 let mut p = payload("fast2flow");
1057 p.customer_id = None;
1058 let outcome = add(&store, &OpFlags::default(), Some(p)).unwrap();
1059 assert_eq!(
1060 outcome.result.get("customer_id").and_then(|v| v.as_str()),
1061 Some("local-dev")
1062 );
1063 }
1064
1065 #[test]
1066 fn add_non_local_without_customer_id_is_invalid_argument() {
1067 let dir = tempdir().unwrap();
1068 let store = LocalFsStore::new(dir.path());
1069 store.save(&make_env("prod-eu")).unwrap();
1070 let mut p = payload("fast2flow");
1071 p.environment_id = "prod-eu".to_string();
1072 p.customer_id = None;
1073 let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
1076 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
1077 }
1078
1079 #[test]
1080 fn add_non_local_with_customer_id_is_not_authz_denied() {
1081 let dir = tempdir().unwrap();
1087 let store = LocalFsStore::new(dir.path());
1088 store.save(&make_env("prod-eu")).unwrap();
1089 let mut p = payload("fast2flow");
1090 p.environment_id = "prod-eu".to_string();
1091 p.customer_id = Some("cust-acme".to_string());
1092 let result = add(&store, &OpFlags::default(), Some(p));
1093 assert!(
1094 !matches!(result, Err(OpError::Unauthorized { .. })),
1095 "named env must not be authz-denied; got {result:?}"
1096 );
1097 }
1098
1099 #[test]
1100 fn update_revenue_share_writes_new_version_and_chains() {
1101 let dir = tempdir().unwrap();
1102 let store = LocalFsStore::new(dir.path());
1103 store.save(&make_env("local")).unwrap();
1104 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1105 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1106 let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
1107 let did = added
1108 .result
1109 .get("deployment_id")
1110 .and_then(|v| v.as_str())
1111 .unwrap()
1112 .to_string();
1113
1114 update(
1115 &store,
1116 &OpFlags::default(),
1117 Some(BundleUpdatePayload {
1118 environment_id: "local".to_string(),
1119 deployment_id: did,
1120 status: None,
1121 route_binding: None,
1122 revenue_share: Some(vec![
1123 RevenueShareEntryPayload {
1124 party_id: "agency-a".to_string(),
1125 basis_points: 3_000,
1126 },
1127 RevenueShareEntryPayload {
1128 party_id: "greentic".to_string(),
1129 basis_points: 7_000,
1130 },
1131 ]),
1132 config_overrides: None,
1133 idempotency_key: None,
1134 }),
1135 )
1136 .unwrap();
1137
1138 let env = store.load(&parse_env_id("local").unwrap()).unwrap();
1139 let dep = &env.bundles[0];
1140 assert_eq!(
1141 dep.revenue_policy_ref,
1142 PathBuf::from("billing-policies/fast2flow/local-dev/v2.json.sig")
1143 );
1144 let env_dir = dir.path().join("local");
1145 assert!(
1146 env_dir
1147 .join("billing-policies/fast2flow/local-dev/v2.json")
1148 .is_file()
1149 );
1150 let audit = std::fs::read_to_string(env_dir.join("audit/events.jsonl")).unwrap();
1152 assert!(
1153 audit.contains("agency-a") && audit.contains("3000"),
1154 "update audit event must carry the revenue_share change: {audit}"
1155 );
1156 }
1157
1158 #[test]
1159 fn update_without_revenue_share_keeps_policy_ref() {
1160 let dir = tempdir().unwrap();
1161 let store = LocalFsStore::new(dir.path());
1162 store.save(&make_env("local")).unwrap();
1163 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1164 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1165 let added = add(&store, &OpFlags::default(), Some(payload("fast2flow"))).unwrap();
1166 let did = added
1167 .result
1168 .get("deployment_id")
1169 .and_then(|v| v.as_str())
1170 .unwrap()
1171 .to_string();
1172 update(
1173 &store,
1174 &OpFlags::default(),
1175 Some(BundleUpdatePayload {
1176 environment_id: "local".to_string(),
1177 deployment_id: did,
1178 status: Some(BundleDeploymentStatus::Paused),
1179 route_binding: None,
1180 revenue_share: None,
1181 config_overrides: None,
1182 idempotency_key: None,
1183 }),
1184 )
1185 .unwrap();
1186 let env = store.load(&parse_env_id("local").unwrap()).unwrap();
1187 assert_eq!(
1188 env.bundles[0].revenue_policy_ref,
1189 PathBuf::from("billing-policies/fast2flow/local-dev/v1.json.sig")
1190 );
1191 }
1192
1193 fn payload_with_overrides(
1196 bundle_id: &str,
1197 overrides: BTreeMap<String, BTreeMap<String, Value>>,
1198 ) -> BundleAddPayload {
1199 BundleAddPayload {
1200 config_overrides: overrides,
1201 ..payload(bundle_id)
1202 }
1203 }
1204
1205 fn single_override(
1206 pack_id: &str,
1207 key: &str,
1208 value: Value,
1209 ) -> BTreeMap<String, BTreeMap<String, Value>> {
1210 BTreeMap::from([(
1211 pack_id.to_string(),
1212 BTreeMap::from([(key.to_string(), value)]),
1213 )])
1214 }
1215
1216 #[test]
1217 fn add_persists_config_overrides_on_bundle_deployment() {
1218 let dir = tempdir().unwrap();
1219 let store = LocalFsStore::new(dir.path());
1220 store.save(&make_env("local")).unwrap();
1221 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1222 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1223 let overrides = single_override(
1224 "messaging-telegram",
1225 "api_base_url",
1226 Value::String("https://staging.example.com".to_string()),
1227 );
1228 let outcome = add(
1229 &store,
1230 &OpFlags::default(),
1231 Some(payload_with_overrides("fast2flow", overrides.clone())),
1232 )
1233 .unwrap();
1234 assert_eq!(outcome.op, "add");
1235 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1236 assert_eq!(env.bundles[0].config_overrides, overrides);
1237 }
1238
1239 #[test]
1240 fn add_rejects_structurally_invalid_config_overrides() {
1241 let dir = tempdir().unwrap();
1244 let store = LocalFsStore::new(dir.path());
1245 store.save(&make_env("local")).unwrap();
1246 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1247 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1248 let overrides = BTreeMap::from([(
1249 String::new(), BTreeMap::from([("k".to_string(), Value::String("v".to_string()))]),
1251 )]);
1252 let err = add(
1253 &store,
1254 &OpFlags::default(),
1255 Some(payload_with_overrides("fast2flow", overrides)),
1256 )
1257 .unwrap_err();
1258 let msg = format!("{err:?}");
1261 assert!(
1262 msg.contains("ConfigOverrideEmptyPackId"),
1263 "expected ConfigOverrideEmptyPackId, got {err:?}"
1264 );
1265 }
1266
1267 #[test]
1268 fn update_replaces_config_overrides_when_some() {
1269 let dir = tempdir().unwrap();
1270 let store = LocalFsStore::new(dir.path());
1271 store.save(&make_env("local")).unwrap();
1272 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1273 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1274 let initial = single_override(
1275 "messaging-telegram",
1276 "api_base_url",
1277 Value::String("https://v1.example.com".to_string()),
1278 );
1279 let added = add(
1280 &store,
1281 &OpFlags::default(),
1282 Some(payload_with_overrides("fast2flow", initial)),
1283 )
1284 .unwrap();
1285 let did = added
1286 .result
1287 .get("deployment_id")
1288 .and_then(|v| v.as_str())
1289 .unwrap()
1290 .to_string();
1291 let replaced = single_override(
1292 "messaging-telegram",
1293 "api_base_url",
1294 Value::String("https://v2.example.com".to_string()),
1295 );
1296 update(
1297 &store,
1298 &OpFlags::default(),
1299 Some(BundleUpdatePayload {
1300 environment_id: "local".to_string(),
1301 deployment_id: did,
1302 status: None,
1303 route_binding: None,
1304 revenue_share: None,
1305 config_overrides: Some(replaced.clone()),
1306 idempotency_key: None,
1307 }),
1308 )
1309 .unwrap();
1310 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1311 assert_eq!(env.bundles[0].config_overrides, replaced);
1312 }
1313
1314 #[test]
1315 fn update_with_none_leaves_config_overrides_untouched() {
1316 let dir = tempdir().unwrap();
1317 let store = LocalFsStore::new(dir.path());
1318 store.save(&make_env("local")).unwrap();
1319 let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
1320 crate::cli::tests_common::bootstrap_env_trust_root(&env_dir);
1321 let initial = single_override(
1322 "messaging-slack",
1323 "webhook_url",
1324 Value::String("https://hooks.slack/v1".to_string()),
1325 );
1326 let added = add(
1327 &store,
1328 &OpFlags::default(),
1329 Some(payload_with_overrides("fast2flow", initial.clone())),
1330 )
1331 .unwrap();
1332 let did = added
1333 .result
1334 .get("deployment_id")
1335 .and_then(|v| v.as_str())
1336 .unwrap()
1337 .to_string();
1338 update(
1340 &store,
1341 &OpFlags::default(),
1342 Some(BundleUpdatePayload {
1343 environment_id: "local".to_string(),
1344 deployment_id: did,
1345 status: Some(BundleDeploymentStatus::Paused),
1346 route_binding: None,
1347 revenue_share: None,
1348 config_overrides: None,
1349 idempotency_key: None,
1350 }),
1351 )
1352 .unwrap();
1353 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1354 assert_eq!(env.bundles[0].config_overrides, initial);
1355 }
1356
1357 #[test]
1358 fn config_overrides_audit_shape_lists_keys_not_values() {
1359 let overrides = BTreeMap::from([
1362 (
1363 "messaging-telegram".to_string(),
1364 BTreeMap::from([
1365 (
1366 "api_base_url".to_string(),
1367 Value::String("https://api.telegram.org/SECRET-TOKEN".to_string()),
1368 ),
1369 (
1370 "retry_max".to_string(),
1371 Value::Number(serde_json::Number::from(5)),
1372 ),
1373 ]),
1374 ),
1375 (
1376 "messaging-slack".to_string(),
1377 BTreeMap::from([(
1378 "webhook_url".to_string(),
1379 Value::String("https://hooks.slack/T0123/B0456/SECRET-PATH".to_string()),
1380 )]),
1381 ),
1382 ]);
1383 let shape = config_overrides_audit_shape(&overrides);
1384 let serialized = serde_json::to_string(&shape).unwrap();
1385 assert!(
1386 !serialized.contains("SECRET"),
1387 "audit shape leaked a value: {serialized}"
1388 );
1389 assert!(
1390 serialized.contains("api_base_url") && serialized.contains("retry_max"),
1391 "audit shape missing key names: {serialized}"
1392 );
1393 assert_eq!(
1395 serialized,
1396 r#"{"messaging-slack":["webhook_url"],"messaging-telegram":["api_base_url","retry_max"]}"#
1397 );
1398 }
1399}