1use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::environment::{EnvPackBinding, Environment, EnvironmentHostConfig, ExtensionBinding};
27use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
28use crate::version::SchemaVersion;
29use greentic_types::EnvId;
30
31pub mod revisions;
34pub use revisions::*;
35
36pub mod inline_stash;
40
41pub mod traffic;
44pub use traffic::*;
45
46pub mod bindings;
49pub use bindings::*;
50
51pub mod trust_root;
54pub use trust_root::*;
55
56pub mod bundles;
60pub use bundles::*;
61
62pub mod messaging;
66pub use messaging::*;
67
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
72pub enum EngineError {
73 #[error("environment `{0}` not found")]
76 NotFound(EnvId),
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub enum FieldUpdate<T> {
106 #[default]
108 Keep,
109 Set(T),
111 Clear,
114}
115
116impl<T> FieldUpdate<T> {
117 pub fn from_option(opt: Option<T>) -> Self {
121 match opt {
122 Some(v) => Self::Set(v),
123 None => Self::Keep,
124 }
125 }
126
127 pub fn from_double_option(opt: Option<Option<T>>) -> Self {
130 match opt {
131 None => Self::Keep,
132 Some(None) => Self::Clear,
133 Some(Some(v)) => Self::Set(v),
134 }
135 }
136
137 pub fn is_keep(&self) -> bool {
139 matches!(self, Self::Keep)
140 }
141
142 pub fn apply_to(self, target: &mut Option<T>) {
145 match self {
146 Self::Keep => {}
147 Self::Set(v) => *target = Some(v),
148 Self::Clear => *target = None,
149 }
150 }
151}
152
153#[derive(Serialize)]
157#[serde(untagged)]
158enum FieldUpdateRepr<T> {
159 Set { value: T },
160 Clear { clear: bool },
161}
162
163#[derive(Deserialize)]
166#[serde(deny_unknown_fields)]
167struct FieldUpdateDeHelper<T> {
168 value: Option<T>,
169 clear: Option<bool>,
170}
171
172impl<T: Serialize> Serialize for FieldUpdate<T> {
173 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
174 match self {
175 Self::Keep => serializer.serialize_none(),
178 Self::Set(v) => FieldUpdateRepr::Set { value: v }.serialize(serializer),
179 Self::Clear => FieldUpdateRepr::<&T>::Clear { clear: true }.serialize(serializer),
180 }
181 }
182}
183
184impl<'de, T: Deserialize<'de>> Deserialize<'de> for FieldUpdate<T> {
185 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
186 match Option::<FieldUpdateDeHelper<T>>::deserialize(deserializer)? {
194 None => Ok(Self::Keep),
195 Some(h) => match (h.value, h.clear) {
196 (Some(_), Some(_)) => Err(serde::de::Error::custom(
197 "field update cannot carry both `value` and `clear`",
198 )),
199 (Some(v), None) => Ok(Self::Set(v)),
200 (None, Some(true)) => Ok(Self::Clear),
201 (None, Some(false)) => Ok(Self::Keep),
202 (None, None) => Err(serde::de::Error::custom(
203 "field update object must carry `value` or `clear`",
204 )),
205 },
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct CreateEnvironmentPayload {
219 pub env_id: EnvId,
220 pub name: String,
221 pub host_config: EnvironmentHostConfig,
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct UpdateEnvironmentPayload {
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub name: Option<String>,
238 #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
239 pub region: FieldUpdate<String>,
240 #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
241 pub tenant_org_id: FieldUpdate<String>,
242 #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
243 pub listen_addr: FieldUpdate<std::net::SocketAddr>,
244 #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
245 pub public_base_url: FieldUpdate<String>,
246 #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
250 pub gui_enabled: FieldUpdate<bool>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct MigrateSeedPayload {
265 pub host_config: EnvironmentHostConfig,
266 pub revocation: RevocationConfig,
267 pub retention: RetentionPolicy,
268 pub health: HealthStatus,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct MigrateMergePayload {
275 pub packs: Vec<EnvPackBinding>,
276 pub extensions: Vec<ExtensionBinding>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub seed_if_missing: Option<MigrateSeedPayload>,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct MergeReport {
289 pub merged_slots: Vec<String>,
290 pub merged_extensions: Vec<String>,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
313pub struct ExtensionKey {
314 pub kind_path: String,
315 #[serde(default)]
316 pub instance_id: Option<String>,
317}
318
319impl ExtensionKey {
320 pub fn new(kind_path: impl Into<String>, instance_id: Option<String>) -> Self {
321 Self {
322 kind_path: kind_path.into(),
323 instance_id,
324 }
325 }
326
327 pub fn from_binding(b: &ExtensionBinding) -> Self {
330 Self {
331 kind_path: b.kind.path().to_string(),
332 instance_id: b.instance_id.clone(),
333 }
334 }
335
336 pub fn matches(&self, b: &ExtensionBinding) -> bool {
339 b.kind.path() == self.kind_path && b.instance_id.as_deref() == self.instance_id.as_deref()
340 }
341}
342
343impl std::fmt::Display for ExtensionKey {
348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349 match &self.instance_id {
350 Some(inst) => write!(f, "{}/{}", self.kind_path, inst),
351 None => f.write_str(&self.kind_path),
352 }
353 }
354}
355
356pub fn fresh_environment(
376 env_id: &EnvId,
377 name: String,
378 host_config: EnvironmentHostConfig,
379 revocation: RevocationConfig,
380 retention: RetentionPolicy,
381 health: HealthStatus,
382) -> Environment {
383 Environment {
384 schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
385 environment_id: env_id.clone(),
386 name,
387 host_config: EnvironmentHostConfig {
388 env_id: env_id.clone(),
389 ..host_config
390 },
391 packs: Vec::new(),
392 credentials_ref: None,
393 bundles: Vec::new(),
394 revisions: Vec::new(),
395 traffic_splits: Vec::new(),
396 messaging_endpoints: Vec::new(),
397 extensions: Vec::new(),
398 revocation,
399 retention,
400 health,
401 }
402}
403
404pub fn apply_environment_update(env: &mut Environment, patch: UpdateEnvironmentPayload) {
409 if let Some(name) = patch.name {
410 env.name = name;
411 }
412 patch.region.apply_to(&mut env.host_config.region);
413 patch
414 .tenant_org_id
415 .apply_to(&mut env.host_config.tenant_org_id);
416 patch.listen_addr.apply_to(&mut env.host_config.listen_addr);
417 patch
418 .public_base_url
419 .apply_to(&mut env.host_config.public_base_url);
420 patch.gui_enabled.apply_to(&mut env.host_config.gui_enabled);
421}
422
423pub fn seed_or_existing(
428 existing: Option<Environment>,
429 env_id: &EnvId,
430 seed: Option<MigrateSeedPayload>,
431) -> Result<Environment, EngineError> {
432 match existing {
433 Some(env) => Ok(env),
434 None => match seed {
435 Some(seed) => Ok(fresh_environment(
436 env_id,
437 env_id.as_str().to_string(),
438 seed.host_config,
439 seed.revocation,
440 seed.retention,
441 seed.health,
442 )),
443 None => Err(EngineError::NotFound(env_id.clone())),
444 },
445 }
446}
447
448pub fn merge_bindings(
456 env: &mut Environment,
457 packs: Vec<EnvPackBinding>,
458 extensions: Vec<ExtensionBinding>,
459) -> MergeReport {
460 let mut merged_slots = Vec::new();
461 for binding in packs {
462 if env.packs.iter().any(|b| b.slot == binding.slot) {
463 continue;
464 }
465 merged_slots.push(binding.slot.to_string());
466 env.packs.push(binding);
467 }
468 let mut merged_extensions = Vec::new();
469 for ext in extensions {
470 let key = ExtensionKey::from_binding(&ext);
471 if env.extensions.iter().any(|e| key.matches(e)) {
472 continue;
473 }
474 merged_extensions.push(key.to_string());
475 env.extensions.push(ext);
476 }
477 MergeReport {
478 merged_slots,
479 merged_extensions,
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use serde_json::json;
487
488 fn env_id() -> EnvId {
489 EnvId::try_from("local").unwrap()
490 }
491
492 fn host_config() -> EnvironmentHostConfig {
493 EnvironmentHostConfig {
494 env_id: env_id(),
495 region: None,
496 tenant_org_id: None,
497 listen_addr: None,
498 public_base_url: None,
499 gui_enabled: None,
500 }
501 }
502
503 fn minimal_env() -> Environment {
504 fresh_environment(
505 &env_id(),
506 "local".to_string(),
507 host_config(),
508 RevocationConfig::default(),
509 RetentionPolicy::default(),
510 HealthStatus::default(),
511 )
512 }
513
514 #[test]
517 fn field_update_default_is_keep() {
518 assert!(FieldUpdate::<String>::default().is_keep());
519 }
520
521 #[test]
522 fn field_update_from_option_maps_none_to_keep_and_some_to_set() {
523 assert_eq!(FieldUpdate::from_option(None::<u32>), FieldUpdate::Keep);
524 assert_eq!(FieldUpdate::from_option(Some(7)), FieldUpdate::Set(7));
525 }
526
527 #[test]
528 fn field_update_from_double_option_maps_tristate() {
529 assert_eq!(
530 FieldUpdate::from_double_option(None::<Option<u32>>),
531 FieldUpdate::Keep
532 );
533 assert_eq!(
534 FieldUpdate::from_double_option(Some(None::<u32>)),
535 FieldUpdate::Clear
536 );
537 assert_eq!(
538 FieldUpdate::from_double_option(Some(Some(7))),
539 FieldUpdate::Set(7)
540 );
541 }
542
543 #[test]
544 fn field_update_apply_to_covers_all_arms() {
545 let mut target = Some("old".to_string());
546 FieldUpdate::Keep.apply_to(&mut target);
547 assert_eq!(target.as_deref(), Some("old"));
548 FieldUpdate::Set("new".to_string()).apply_to(&mut target);
549 assert_eq!(target.as_deref(), Some("new"));
550 FieldUpdate::<String>::Clear.apply_to(&mut target);
551 assert_eq!(target, None);
552 FieldUpdate::<String>::Clear.apply_to(&mut target);
554 assert_eq!(target, None);
555 }
556
557 #[test]
558 fn update_environment_payload_defaults_to_all_keep() {
559 let payload = UpdateEnvironmentPayload::default();
560 assert!(payload.name.is_none());
561 assert!(payload.region.is_keep());
562 assert!(payload.tenant_org_id.is_keep());
563 assert!(payload.listen_addr.is_keep());
564 assert!(payload.public_base_url.is_keep());
565 }
566
567 #[test]
568 fn extension_key_identity_distinguishes_none_from_named_instance() {
569 let key = ExtensionKey::new("capability/memory/long-term", Some("default".to_string()));
570 assert_eq!(key.to_string(), "capability/memory/long-term/default");
571
572 let unnamed = ExtensionKey::new("capability/memory/long-term", None);
576 assert_eq!(unnamed.to_string(), "capability/memory/long-term");
577 assert_ne!(unnamed, key, "None and Some(_) must differ");
578
579 let mut set = std::collections::HashSet::new();
580 set.insert(key.clone());
581 assert!(set.contains(&key));
582 assert!(
583 !set.contains(&unnamed),
584 "None key must not hash-collide with Some(_) key"
585 );
586 set.insert(unnamed);
587 assert_eq!(set.len(), 2);
588 }
589
590 #[test]
593 fn update_payload_wire_format_is_pinned() {
594 let payload = UpdateEnvironmentPayload {
596 name: Some("renamed".to_string()),
597 region: FieldUpdate::Set("eu-west-1".to_string()),
598 tenant_org_id: FieldUpdate::Clear,
599 listen_addr: FieldUpdate::Keep,
600 public_base_url: FieldUpdate::Keep,
601 gui_enabled: FieldUpdate::Keep,
602 };
603 assert_eq!(
604 serde_json::to_value(&payload).unwrap(),
605 json!({
606 "name": "renamed",
607 "region": {"value": "eu-west-1"},
608 "tenant_org_id": {"clear": true},
609 })
610 );
611 }
612
613 #[test]
614 fn update_payload_deserializes_tristate() {
615 let payload: UpdateEnvironmentPayload = serde_json::from_value(json!({
616 "region": {"value": "eu-west-1"},
617 "tenant_org_id": {"clear": true},
618 }))
619 .unwrap();
620 assert_eq!(payload.name, None);
621 assert_eq!(payload.region, FieldUpdate::Set("eu-west-1".to_string()));
622 assert_eq!(payload.tenant_org_id, FieldUpdate::Clear);
623 assert!(payload.listen_addr.is_keep());
624 assert!(payload.public_base_url.is_keep());
625 }
626
627 #[test]
628 fn field_update_clear_false_and_null_deserialize_to_keep() {
629 let payload: UpdateEnvironmentPayload = serde_json::from_value(json!({
630 "region": {"clear": false},
631 "tenant_org_id": null,
632 }))
633 .unwrap();
634 assert!(payload.region.is_keep());
635 assert!(payload.tenant_org_id.is_keep());
636 }
637
638 #[test]
639 fn field_update_rejects_contradictory_value_and_clear() {
640 let err = serde_json::from_value::<UpdateEnvironmentPayload>(json!({
641 "region": {"value": "x", "clear": true},
642 }))
643 .unwrap_err();
644 assert!(
645 err.to_string().contains("cannot carry both"),
646 "expected contradictory-field rejection: {err}"
647 );
648 }
649
650 #[test]
651 fn field_update_rejects_unknown_keys() {
652 let err = serde_json::from_value::<UpdateEnvironmentPayload>(json!({
653 "region": {"unknown_key": 1},
654 }))
655 .unwrap_err();
656 assert!(
657 err.to_string().contains("unknown field"),
658 "expected unknown-key rejection: {err}"
659 );
660 }
661
662 #[test]
663 fn create_payload_round_trips() {
664 let payload = CreateEnvironmentPayload {
665 env_id: env_id(),
666 name: "local".to_string(),
667 host_config: host_config(),
668 };
669 let value = serde_json::to_value(&payload).unwrap();
670 assert_eq!(value["env_id"], "local");
671 assert_eq!(value["name"], "local");
672 let back: CreateEnvironmentPayload = serde_json::from_value(value).unwrap();
673 assert_eq!(back.env_id, payload.env_id);
674 assert_eq!(back.name, payload.name);
675 }
676
677 #[test]
678 fn migrate_payload_omits_absent_seed() {
679 let payload = MigrateMergePayload {
680 packs: Vec::new(),
681 extensions: Vec::new(),
682 seed_if_missing: None,
683 };
684 let value = serde_json::to_value(&payload).unwrap();
685 assert!(value.get("seed_if_missing").is_none());
686 let back: MigrateMergePayload =
687 serde_json::from_value(json!({"packs": [], "extensions": []})).unwrap();
688 assert!(back.seed_if_missing.is_none());
689 }
690
691 #[test]
694 fn fresh_environment_pins_host_config_env_id() {
695 let other = EnvId::try_from("other").unwrap();
696 let mut hc = host_config();
697 hc.env_id = other;
698 let env = fresh_environment(
699 &env_id(),
700 "local".to_string(),
701 hc,
702 RevocationConfig::default(),
703 RetentionPolicy::default(),
704 HealthStatus::default(),
705 );
706 assert_eq!(env.host_config.env_id, env_id());
707 assert_eq!(env.environment_id, env_id());
708 assert!(env.packs.is_empty() && env.bundles.is_empty());
709 }
710
711 #[test]
712 fn apply_environment_update_patches_and_clears() {
713 let mut env = minimal_env();
714 env.host_config.region = Some("us-east-1".to_string());
715 apply_environment_update(
716 &mut env,
717 UpdateEnvironmentPayload {
718 name: Some("renamed".to_string()),
719 region: FieldUpdate::Clear,
720 tenant_org_id: FieldUpdate::Set("org-1".to_string()),
721 listen_addr: FieldUpdate::Keep,
722 public_base_url: FieldUpdate::Keep,
723 gui_enabled: FieldUpdate::Keep,
724 },
725 );
726 assert_eq!(env.name, "renamed");
727 assert_eq!(env.host_config.region, None);
728 assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("org-1"));
729 }
730
731 #[test]
732 fn seed_or_existing_passes_through_seeds_and_rejects() {
733 let existing = minimal_env();
734 let out = seed_or_existing(Some(existing.clone()), &env_id(), None).unwrap();
735 assert_eq!(out.name, existing.name);
736
737 let seeded = seed_or_existing(
738 None,
739 &env_id(),
740 Some(MigrateSeedPayload {
741 host_config: host_config(),
742 revocation: RevocationConfig::default(),
743 retention: RetentionPolicy::default(),
744 health: HealthStatus::default(),
745 }),
746 )
747 .unwrap();
748 assert_eq!(seeded.name, "local");
749
750 let err = seed_or_existing(None, &env_id(), None).unwrap_err();
751 assert_eq!(err, EngineError::NotFound(env_id()));
752 }
753
754 #[test]
755 fn merge_bindings_dedups_slots_and_extension_keys() {
756 use crate::capability_slot::{CapabilitySlot, PackDescriptor};
757 use crate::ids::PackId;
758
759 let pack = |slot: CapabilitySlot| EnvPackBinding {
760 slot,
761 kind: PackDescriptor::try_new("greentic.secrets@1.0.0").unwrap(),
762 pack_ref: PackId::new("greentic.secrets"),
763 answers_ref: None,
764 generation: 0,
765 previous_binding_ref: None,
766 };
767 let ext = |instance: Option<&str>| ExtensionBinding {
768 kind: PackDescriptor::try_new("greentic.memory@0.1.0").unwrap(),
769 pack_ref: PackId::new("greentic.memory"),
770 instance_id: instance.map(str::to_string),
771 answers_ref: None,
772 generation: 0,
773 previous_binding_ref: None,
774 };
775
776 let mut env = minimal_env();
777 env.packs.push(pack(CapabilitySlot::Secrets));
778 env.extensions.push(ext(None));
779
780 let report = merge_bindings(
781 &mut env,
782 vec![pack(CapabilitySlot::Secrets), pack(CapabilitySlot::State)],
783 vec![ext(None), ext(Some("alt"))],
784 );
785
786 assert_eq!(report.merged_slots, vec!["state".to_string()]);
788 assert_eq!(
789 report.merged_extensions,
790 vec!["greentic.memory/alt".to_string()]
791 );
792 assert_eq!(env.packs.len(), 2);
793 assert_eq!(env.extensions.len(), 2);
794 }
795}