use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::environment::{EnvPackBinding, Environment, EnvironmentHostConfig, ExtensionBinding};
use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
use crate::version::SchemaVersion;
use greentic_types::EnvId;
pub mod revisions;
pub use revisions::*;
pub mod inline_stash;
pub mod traffic;
pub use traffic::*;
pub mod bindings;
pub use bindings::*;
pub mod trust_root;
pub use trust_root::*;
pub mod bundles;
pub use bundles::*;
pub mod messaging;
pub use messaging::*;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EngineError {
#[error("environment `{0}` not found")]
NotFound(EnvId),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum FieldUpdate<T> {
#[default]
Keep,
Set(T),
Clear,
}
impl<T> FieldUpdate<T> {
pub fn from_option(opt: Option<T>) -> Self {
match opt {
Some(v) => Self::Set(v),
None => Self::Keep,
}
}
pub fn from_double_option(opt: Option<Option<T>>) -> Self {
match opt {
None => Self::Keep,
Some(None) => Self::Clear,
Some(Some(v)) => Self::Set(v),
}
}
pub fn is_keep(&self) -> bool {
matches!(self, Self::Keep)
}
pub fn apply_to(self, target: &mut Option<T>) {
match self {
Self::Keep => {}
Self::Set(v) => *target = Some(v),
Self::Clear => *target = None,
}
}
}
#[derive(Serialize)]
#[serde(untagged)]
enum FieldUpdateRepr<T> {
Set { value: T },
Clear { clear: bool },
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FieldUpdateDeHelper<T> {
value: Option<T>,
clear: Option<bool>,
}
impl<T: Serialize> Serialize for FieldUpdate<T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Keep => serializer.serialize_none(),
Self::Set(v) => FieldUpdateRepr::Set { value: v }.serialize(serializer),
Self::Clear => FieldUpdateRepr::<&T>::Clear { clear: true }.serialize(serializer),
}
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for FieldUpdate<T> {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
match Option::<FieldUpdateDeHelper<T>>::deserialize(deserializer)? {
None => Ok(Self::Keep),
Some(h) => match (h.value, h.clear) {
(Some(_), Some(_)) => Err(serde::de::Error::custom(
"field update cannot carry both `value` and `clear`",
)),
(Some(v), None) => Ok(Self::Set(v)),
(None, Some(true)) => Ok(Self::Clear),
(None, Some(false)) => Ok(Self::Keep),
(None, None) => Err(serde::de::Error::custom(
"field update object must carry `value` or `clear`",
)),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateEnvironmentPayload {
pub env_id: EnvId,
pub name: String,
pub host_config: EnvironmentHostConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateEnvironmentPayload {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
pub region: FieldUpdate<String>,
#[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
pub tenant_org_id: FieldUpdate<String>,
#[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
pub listen_addr: FieldUpdate<std::net::SocketAddr>,
#[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
pub public_base_url: FieldUpdate<String>,
#[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
pub gui_enabled: FieldUpdate<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrateSeedPayload {
pub host_config: EnvironmentHostConfig,
pub revocation: RevocationConfig,
pub retention: RetentionPolicy,
pub health: HealthStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrateMergePayload {
pub packs: Vec<EnvPackBinding>,
pub extensions: Vec<ExtensionBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed_if_missing: Option<MigrateSeedPayload>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeReport {
pub merged_slots: Vec<String>,
pub merged_extensions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ExtensionKey {
pub kind_path: String,
#[serde(default)]
pub instance_id: Option<String>,
}
impl ExtensionKey {
pub fn new(kind_path: impl Into<String>, instance_id: Option<String>) -> Self {
Self {
kind_path: kind_path.into(),
instance_id,
}
}
pub fn from_binding(b: &ExtensionBinding) -> Self {
Self {
kind_path: b.kind.path().to_string(),
instance_id: b.instance_id.clone(),
}
}
pub fn matches(&self, b: &ExtensionBinding) -> bool {
b.kind.path() == self.kind_path && b.instance_id.as_deref() == self.instance_id.as_deref()
}
}
impl std::fmt::Display for ExtensionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.instance_id {
Some(inst) => write!(f, "{}/{}", self.kind_path, inst),
None => f.write_str(&self.kind_path),
}
}
}
pub fn fresh_environment(
env_id: &EnvId,
name: String,
host_config: EnvironmentHostConfig,
revocation: RevocationConfig,
retention: RetentionPolicy,
health: HealthStatus,
) -> Environment {
Environment {
schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
environment_id: env_id.clone(),
name,
host_config: EnvironmentHostConfig {
env_id: env_id.clone(),
..host_config
},
packs: Vec::new(),
credentials_ref: None,
bundles: Vec::new(),
revisions: Vec::new(),
traffic_splits: Vec::new(),
messaging_endpoints: Vec::new(),
extensions: Vec::new(),
revocation,
retention,
health,
}
}
pub fn apply_environment_update(env: &mut Environment, patch: UpdateEnvironmentPayload) {
if let Some(name) = patch.name {
env.name = name;
}
patch.region.apply_to(&mut env.host_config.region);
patch
.tenant_org_id
.apply_to(&mut env.host_config.tenant_org_id);
patch.listen_addr.apply_to(&mut env.host_config.listen_addr);
patch
.public_base_url
.apply_to(&mut env.host_config.public_base_url);
patch.gui_enabled.apply_to(&mut env.host_config.gui_enabled);
}
pub fn seed_or_existing(
existing: Option<Environment>,
env_id: &EnvId,
seed: Option<MigrateSeedPayload>,
) -> Result<Environment, EngineError> {
match existing {
Some(env) => Ok(env),
None => match seed {
Some(seed) => Ok(fresh_environment(
env_id,
env_id.as_str().to_string(),
seed.host_config,
seed.revocation,
seed.retention,
seed.health,
)),
None => Err(EngineError::NotFound(env_id.clone())),
},
}
}
pub fn merge_bindings(
env: &mut Environment,
packs: Vec<EnvPackBinding>,
extensions: Vec<ExtensionBinding>,
) -> MergeReport {
let mut merged_slots = Vec::new();
for binding in packs {
if env.packs.iter().any(|b| b.slot == binding.slot) {
continue;
}
merged_slots.push(binding.slot.to_string());
env.packs.push(binding);
}
let mut merged_extensions = Vec::new();
for ext in extensions {
let key = ExtensionKey::from_binding(&ext);
if env.extensions.iter().any(|e| key.matches(e)) {
continue;
}
merged_extensions.push(key.to_string());
env.extensions.push(ext);
}
MergeReport {
merged_slots,
merged_extensions,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn env_id() -> EnvId {
EnvId::try_from("local").unwrap()
}
fn host_config() -> EnvironmentHostConfig {
EnvironmentHostConfig {
env_id: env_id(),
region: None,
tenant_org_id: None,
listen_addr: None,
public_base_url: None,
gui_enabled: None,
}
}
fn minimal_env() -> Environment {
fresh_environment(
&env_id(),
"local".to_string(),
host_config(),
RevocationConfig::default(),
RetentionPolicy::default(),
HealthStatus::default(),
)
}
#[test]
fn field_update_default_is_keep() {
assert!(FieldUpdate::<String>::default().is_keep());
}
#[test]
fn field_update_from_option_maps_none_to_keep_and_some_to_set() {
assert_eq!(FieldUpdate::from_option(None::<u32>), FieldUpdate::Keep);
assert_eq!(FieldUpdate::from_option(Some(7)), FieldUpdate::Set(7));
}
#[test]
fn field_update_from_double_option_maps_tristate() {
assert_eq!(
FieldUpdate::from_double_option(None::<Option<u32>>),
FieldUpdate::Keep
);
assert_eq!(
FieldUpdate::from_double_option(Some(None::<u32>)),
FieldUpdate::Clear
);
assert_eq!(
FieldUpdate::from_double_option(Some(Some(7))),
FieldUpdate::Set(7)
);
}
#[test]
fn field_update_apply_to_covers_all_arms() {
let mut target = Some("old".to_string());
FieldUpdate::Keep.apply_to(&mut target);
assert_eq!(target.as_deref(), Some("old"));
FieldUpdate::Set("new".to_string()).apply_to(&mut target);
assert_eq!(target.as_deref(), Some("new"));
FieldUpdate::<String>::Clear.apply_to(&mut target);
assert_eq!(target, None);
FieldUpdate::<String>::Clear.apply_to(&mut target);
assert_eq!(target, None);
}
#[test]
fn update_environment_payload_defaults_to_all_keep() {
let payload = UpdateEnvironmentPayload::default();
assert!(payload.name.is_none());
assert!(payload.region.is_keep());
assert!(payload.tenant_org_id.is_keep());
assert!(payload.listen_addr.is_keep());
assert!(payload.public_base_url.is_keep());
}
#[test]
fn extension_key_identity_distinguishes_none_from_named_instance() {
let key = ExtensionKey::new("capability/memory/long-term", Some("default".to_string()));
assert_eq!(key.to_string(), "capability/memory/long-term/default");
let unnamed = ExtensionKey::new("capability/memory/long-term", None);
assert_eq!(unnamed.to_string(), "capability/memory/long-term");
assert_ne!(unnamed, key, "None and Some(_) must differ");
let mut set = std::collections::HashSet::new();
set.insert(key.clone());
assert!(set.contains(&key));
assert!(
!set.contains(&unnamed),
"None key must not hash-collide with Some(_) key"
);
set.insert(unnamed);
assert_eq!(set.len(), 2);
}
#[test]
fn update_payload_wire_format_is_pinned() {
let payload = UpdateEnvironmentPayload {
name: Some("renamed".to_string()),
region: FieldUpdate::Set("eu-west-1".to_string()),
tenant_org_id: FieldUpdate::Clear,
listen_addr: FieldUpdate::Keep,
public_base_url: FieldUpdate::Keep,
gui_enabled: FieldUpdate::Keep,
};
assert_eq!(
serde_json::to_value(&payload).unwrap(),
json!({
"name": "renamed",
"region": {"value": "eu-west-1"},
"tenant_org_id": {"clear": true},
})
);
}
#[test]
fn update_payload_deserializes_tristate() {
let payload: UpdateEnvironmentPayload = serde_json::from_value(json!({
"region": {"value": "eu-west-1"},
"tenant_org_id": {"clear": true},
}))
.unwrap();
assert_eq!(payload.name, None);
assert_eq!(payload.region, FieldUpdate::Set("eu-west-1".to_string()));
assert_eq!(payload.tenant_org_id, FieldUpdate::Clear);
assert!(payload.listen_addr.is_keep());
assert!(payload.public_base_url.is_keep());
}
#[test]
fn field_update_clear_false_and_null_deserialize_to_keep() {
let payload: UpdateEnvironmentPayload = serde_json::from_value(json!({
"region": {"clear": false},
"tenant_org_id": null,
}))
.unwrap();
assert!(payload.region.is_keep());
assert!(payload.tenant_org_id.is_keep());
}
#[test]
fn field_update_rejects_contradictory_value_and_clear() {
let err = serde_json::from_value::<UpdateEnvironmentPayload>(json!({
"region": {"value": "x", "clear": true},
}))
.unwrap_err();
assert!(
err.to_string().contains("cannot carry both"),
"expected contradictory-field rejection: {err}"
);
}
#[test]
fn field_update_rejects_unknown_keys() {
let err = serde_json::from_value::<UpdateEnvironmentPayload>(json!({
"region": {"unknown_key": 1},
}))
.unwrap_err();
assert!(
err.to_string().contains("unknown field"),
"expected unknown-key rejection: {err}"
);
}
#[test]
fn create_payload_round_trips() {
let payload = CreateEnvironmentPayload {
env_id: env_id(),
name: "local".to_string(),
host_config: host_config(),
};
let value = serde_json::to_value(&payload).unwrap();
assert_eq!(value["env_id"], "local");
assert_eq!(value["name"], "local");
let back: CreateEnvironmentPayload = serde_json::from_value(value).unwrap();
assert_eq!(back.env_id, payload.env_id);
assert_eq!(back.name, payload.name);
}
#[test]
fn migrate_payload_omits_absent_seed() {
let payload = MigrateMergePayload {
packs: Vec::new(),
extensions: Vec::new(),
seed_if_missing: None,
};
let value = serde_json::to_value(&payload).unwrap();
assert!(value.get("seed_if_missing").is_none());
let back: MigrateMergePayload =
serde_json::from_value(json!({"packs": [], "extensions": []})).unwrap();
assert!(back.seed_if_missing.is_none());
}
#[test]
fn fresh_environment_pins_host_config_env_id() {
let other = EnvId::try_from("other").unwrap();
let mut hc = host_config();
hc.env_id = other;
let env = fresh_environment(
&env_id(),
"local".to_string(),
hc,
RevocationConfig::default(),
RetentionPolicy::default(),
HealthStatus::default(),
);
assert_eq!(env.host_config.env_id, env_id());
assert_eq!(env.environment_id, env_id());
assert!(env.packs.is_empty() && env.bundles.is_empty());
}
#[test]
fn apply_environment_update_patches_and_clears() {
let mut env = minimal_env();
env.host_config.region = Some("us-east-1".to_string());
apply_environment_update(
&mut env,
UpdateEnvironmentPayload {
name: Some("renamed".to_string()),
region: FieldUpdate::Clear,
tenant_org_id: FieldUpdate::Set("org-1".to_string()),
listen_addr: FieldUpdate::Keep,
public_base_url: FieldUpdate::Keep,
gui_enabled: FieldUpdate::Keep,
},
);
assert_eq!(env.name, "renamed");
assert_eq!(env.host_config.region, None);
assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("org-1"));
}
#[test]
fn seed_or_existing_passes_through_seeds_and_rejects() {
let existing = minimal_env();
let out = seed_or_existing(Some(existing.clone()), &env_id(), None).unwrap();
assert_eq!(out.name, existing.name);
let seeded = seed_or_existing(
None,
&env_id(),
Some(MigrateSeedPayload {
host_config: host_config(),
revocation: RevocationConfig::default(),
retention: RetentionPolicy::default(),
health: HealthStatus::default(),
}),
)
.unwrap();
assert_eq!(seeded.name, "local");
let err = seed_or_existing(None, &env_id(), None).unwrap_err();
assert_eq!(err, EngineError::NotFound(env_id()));
}
#[test]
fn merge_bindings_dedups_slots_and_extension_keys() {
use crate::capability_slot::{CapabilitySlot, PackDescriptor};
use crate::ids::PackId;
let pack = |slot: CapabilitySlot| EnvPackBinding {
slot,
kind: PackDescriptor::try_new("greentic.secrets@1.0.0").unwrap(),
pack_ref: PackId::new("greentic.secrets"),
answers_ref: None,
generation: 0,
previous_binding_ref: None,
};
let ext = |instance: Option<&str>| ExtensionBinding {
kind: PackDescriptor::try_new("greentic.memory@0.1.0").unwrap(),
pack_ref: PackId::new("greentic.memory"),
instance_id: instance.map(str::to_string),
answers_ref: None,
generation: 0,
previous_binding_ref: None,
};
let mut env = minimal_env();
env.packs.push(pack(CapabilitySlot::Secrets));
env.extensions.push(ext(None));
let report = merge_bindings(
&mut env,
vec![pack(CapabilitySlot::Secrets), pack(CapabilitySlot::State)],
vec![ext(None), ext(Some("alt"))],
);
assert_eq!(report.merged_slots, vec!["state".to_string()]);
assert_eq!(
report.merged_extensions,
vec!["greentic.memory/alt".to_string()]
);
assert_eq!(env.packs.len(), 2);
assert_eq!(env.extensions.len(), 2);
}
}