use crate::bundle_deployment::BundleDeployment;
use crate::capability_slot::{CapabilitySlot, PackDescriptor};
use crate::error::SpecError;
use crate::ids::PackId;
use crate::messaging_endpoint::MessagingEndpoint;
use crate::refs::{ExtensionRef, SecretRef};
use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
use crate::revision::Revision;
use crate::traffic_split::TrafficSplit;
use crate::version::SchemaVersion;
use greentic_types::EnvId;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::path::PathBuf;
pub const DEFAULT_LISTEN_ADDR: SocketAddr =
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvironmentHostConfig {
pub env_id: EnvId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_org_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen_addr: Option<SocketAddr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub public_base_url: Option<String>,
}
impl EnvironmentHostConfig {
pub fn resolved_listen_addr(&self) -> SocketAddr {
self.listen_addr.unwrap_or(DEFAULT_LISTEN_ADDR)
}
}
pub fn validate_public_base_url(value: &str) -> Result<String, crate::error::SpecError> {
let trimmed = value.trim();
let invalid = |reason: &'static str| crate::error::SpecError::InvalidPublicBaseUrl {
value: trimmed.to_string(),
reason,
};
if trimmed.is_empty() {
return Err(invalid("must not be empty"));
}
if trimmed.chars().any(char::is_whitespace) {
return Err(invalid("must not contain whitespace"));
}
let uri: http::Uri = trimmed.parse().map_err(|_| invalid("is not a valid URI"))?;
match uri.scheme_str() {
Some("http") | Some("https") => {}
_ => return Err(invalid("must start with http:// or https://")),
}
let authority = uri
.authority()
.ok_or_else(|| invalid("must include a host"))?;
if authority.as_str().contains('@') {
return Err(invalid("must not include userinfo"));
}
if authority.host().is_empty() {
return Err(invalid("must include a host"));
}
if authority.as_str().len() > authority.host().len() && authority.port_u16().is_none() {
return Err(invalid("port is not a valid number"));
}
if uri.query().is_some() {
return Err(invalid("must not include a query string"));
}
if trimmed.contains('#') {
return Err(invalid("must not include a fragment"));
}
let path = uri.path();
if !path.is_empty() && path != "/" {
return Err(invalid("must be an origin without a path"));
}
Ok(trimmed.trim_end_matches('/').to_string())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvPackBinding {
pub slot: CapabilitySlot,
pub kind: PackDescriptor,
pub pack_ref: PackId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answers_ref: Option<PathBuf>,
#[serde(default)]
pub generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_binding_ref: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtensionBinding {
pub kind: PackDescriptor,
pub pack_ref: PackId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answers_ref: Option<PathBuf>,
#[serde(default)]
pub generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_binding_ref: Option<PathBuf>,
}
impl ExtensionBinding {
pub fn validate(&self) -> Result<(), SpecError> {
if let Some(inst) = &self.instance_id {
crate::refs::validate_instance_id(inst).map_err(|e| {
SpecError::InvalidExtensionInstanceId {
path: self.kind.path().to_string(),
reason: e.to_string(),
}
})?;
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Environment {
pub schema: SchemaVersion,
pub environment_id: EnvId,
pub name: String,
pub host_config: EnvironmentHostConfig,
pub packs: Vec<EnvPackBinding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credentials_ref: Option<SecretRef>,
#[serde(default)]
pub bundles: Vec<BundleDeployment>,
#[serde(default)]
pub revisions: Vec<Revision>,
#[serde(default)]
pub traffic_splits: Vec<TrafficSplit>,
#[serde(default)]
pub messaging_endpoints: Vec<MessagingEndpoint>,
#[serde(default)]
pub extensions: Vec<ExtensionBinding>,
#[serde(default)]
pub revocation: RevocationConfig,
#[serde(default)]
pub retention: RetentionPolicy,
#[serde(default)]
pub health: HealthStatus,
}
impl Environment {
pub fn schema_str() -> &'static str {
SchemaVersion::ENVIRONMENT_V1
}
pub fn pack_for_slot(&self, slot: CapabilitySlot) -> Option<&EnvPackBinding> {
self.packs.iter().find(|b| b.slot == slot)
}
pub fn extension_for_ref(&self, r: &ExtensionRef) -> Option<&ExtensionBinding> {
self.extensions
.iter()
.find(|b| b.kind.path() == r.path() && b.instance_id.as_deref() == r.instance_id())
}
pub fn validate(&self) -> Result<(), SpecError> {
if self.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
return Err(SpecError::SchemaMismatch {
expected: SchemaVersion::ENVIRONMENT_V1,
actual: self.schema.as_str().to_string(),
});
}
if self.host_config.env_id != self.environment_id {
return Err(SpecError::EnvIdMismatch {
context: "host_config",
expected: self.environment_id.clone(),
actual: self.host_config.env_id.clone(),
});
}
if let Some(url) = self.host_config.public_base_url.as_deref() {
validate_public_base_url(url)?;
}
let mut seen = [false; CapabilitySlot::ALL.len()];
for binding in &self.packs {
let idx = binding.slot as usize;
if seen[idx] {
return Err(SpecError::DuplicateCapabilitySlot(binding.slot));
}
seen[idx] = true;
}
if let Some(cred_ref) = &self.credentials_ref {
let actual = cred_ref.env_segment();
if actual != self.environment_id.as_str() {
return Err(SpecError::CrossEnvRef {
context: "credentials_ref",
uri: cred_ref.as_str().to_string(),
expected_env: self.environment_id.clone(),
actual_env: actual.to_string(),
});
}
}
for revision in &self.revisions {
revision.validate()?;
if revision.env_id != self.environment_id {
return Err(SpecError::EnvIdMismatch {
context: "revision",
expected: self.environment_id.clone(),
actual: revision.env_id.clone(),
});
}
}
for bundle in &self.bundles {
if bundle.env_id != self.environment_id {
return Err(SpecError::EnvIdMismatch {
context: "bundle_deployment",
expected: self.environment_id.clone(),
actual: bundle.env_id.clone(),
});
}
bundle.validate()?;
let mut revision_pack_ids: HashSet<&str> = HashSet::new();
for rev_id in &bundle.current_revisions {
let referenced = self
.revisions
.iter()
.find(|r| r.revision_id == *rev_id)
.ok_or(SpecError::UnknownRevision(*rev_id))?;
if referenced.deployment_id != bundle.deployment_id {
return Err(SpecError::BundleRevisionWrongDeployment {
deployment: bundle.deployment_id,
revision: *rev_id,
actual_deployment: referenced.deployment_id,
});
}
if referenced.bundle_id != bundle.bundle_id {
return Err(SpecError::BundleRevisionWrongBundle {
deployment: bundle.deployment_id,
revision: *rev_id,
expected_bundle: bundle.bundle_id.clone(),
actual_bundle: referenced.bundle_id.clone(),
});
}
revision_pack_ids.extend(referenced.pack_list.iter().map(|e| e.pack_id.as_str()));
}
if !bundle.config_overrides.is_empty() {
let mut deployment_pack_ids: HashSet<&str> = HashSet::new();
for rev in self.revisions.iter().filter(|r| {
r.deployment_id == bundle.deployment_id
&& r.lifecycle != crate::RevisionLifecycle::Archived
}) {
deployment_pack_ids.extend(rev.pack_list.iter().map(|e| e.pack_id.as_str()));
}
if !deployment_pack_ids.is_empty() {
for override_pack_id in bundle.config_overrides.keys() {
if !deployment_pack_ids.contains(override_pack_id.as_str()) {
return Err(SpecError::ConfigOverridePackNotInRevisions {
deployment: bundle.deployment_id,
pack_id: override_pack_id.clone(),
});
}
}
}
}
}
for split in &self.traffic_splits {
if split.env_id != self.environment_id {
return Err(SpecError::EnvIdMismatch {
context: "traffic_split",
expected: self.environment_id.clone(),
actual: split.env_id.clone(),
});
}
split.validate()?;
let referenced_bundle = self
.bundles
.iter()
.find(|b| b.deployment_id == split.deployment_id)
.ok_or(SpecError::UnknownDeployment(split.deployment_id))?;
if referenced_bundle.bundle_id != split.bundle_id {
return Err(SpecError::SplitDeploymentBundleMismatch {
deployment: split.deployment_id,
split_bundle: split.bundle_id.clone(),
deployment_bundle: referenced_bundle.bundle_id.clone(),
});
}
for entry in &split.entries {
let referenced = self
.revisions
.iter()
.find(|r| r.revision_id == entry.revision_id)
.ok_or(SpecError::UnknownRevision(entry.revision_id))?;
if referenced.deployment_id != split.deployment_id {
return Err(SpecError::SplitRevisionWrongDeployment {
revision: entry.revision_id,
expected_deployment: split.deployment_id,
actual_deployment: referenced.deployment_id,
});
}
if referenced.bundle_id != split.bundle_id {
return Err(SpecError::SplitRevisionWrongBundle {
revision: entry.revision_id,
expected_bundle: split.bundle_id.clone(),
actual_bundle: referenced.bundle_id.clone(),
});
}
}
}
let mut seen_endpoint_ids = HashSet::with_capacity(self.messaging_endpoints.len());
let mut seen_provider_instances = HashSet::with_capacity(self.messaging_endpoints.len());
for endpoint in &self.messaging_endpoints {
endpoint.validate()?;
if endpoint.env_id != self.environment_id {
return Err(SpecError::EnvIdMismatch {
context: "messaging_endpoint",
expected: self.environment_id.clone(),
actual: endpoint.env_id.clone(),
});
}
if !seen_endpoint_ids.insert(endpoint.endpoint_id) {
return Err(SpecError::DuplicateMessagingEndpoint(endpoint.endpoint_id));
}
let instance_key = (
endpoint.provider_type.as_str(),
endpoint.provider_id.as_str(),
);
if !seen_provider_instances.insert(instance_key) {
return Err(SpecError::DuplicateProviderInstance {
provider_type: endpoint.provider_type.clone(),
provider_id: endpoint.provider_id.clone(),
});
}
for bundle_id in &endpoint.linked_bundles {
if !self.bundles.iter().any(|b| b.bundle_id == *bundle_id) {
return Err(SpecError::MessagingEndpointBundleNotLinked {
endpoint: endpoint.endpoint_id,
bundle: bundle_id.clone(),
});
}
}
if let Some(welcome) = &endpoint.welcome_flow
&& !endpoint.linked_bundles.contains(&welcome.bundle_id)
{
return Err(SpecError::WelcomeFlowBundleNotLinked {
endpoint: endpoint.endpoint_id,
bundle: welcome.bundle_id.clone(),
});
}
}
let mut seen_extensions = HashSet::with_capacity(self.extensions.len());
for ext in &self.extensions {
ext.validate()?;
let key = (ext.kind.path(), ext.instance_id.as_deref());
if !seen_extensions.insert(key) {
return Err(SpecError::DuplicateExtension {
path: ext.kind.path().to_string(),
instance_id: ext.instance_id.clone(),
});
}
}
Ok(())
}
}
#[cfg(test)]
mod public_base_url_tests {
use super::validate_public_base_url;
#[test]
fn accepts_https_origin() {
assert_eq!(
validate_public_base_url("https://chat.example.com").unwrap(),
"https://chat.example.com"
);
}
#[test]
fn accepts_http_origin() {
assert_eq!(
validate_public_base_url("http://localhost:8080").unwrap(),
"http://localhost:8080"
);
}
#[test]
fn trims_trailing_slash() {
assert_eq!(
validate_public_base_url("https://chat.example.com/").unwrap(),
"https://chat.example.com"
);
}
#[test]
fn rejects_path() {
let err = validate_public_base_url("https://chat.example.com/api").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_query() {
let err = validate_public_base_url("https://chat.example.com?x=1").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_fragment() {
let err = validate_public_base_url("https://chat.example.com#frag").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_non_http_scheme() {
let err = validate_public_base_url("ftp://chat.example.com").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_missing_scheme() {
let err = validate_public_base_url("chat.example.com").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_empty_host() {
let err = validate_public_base_url("https:///path").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_whitespace() {
let err = validate_public_base_url("https://chat .example.com").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn trims_surrounding_whitespace_before_validation() {
assert_eq!(
validate_public_base_url(" https://chat.example.com ").unwrap(),
"https://chat.example.com"
);
}
#[test]
fn rejects_userinfo() {
let err = validate_public_base_url("https://user:pass@example.com").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_empty_host_in_authority() {
let err = validate_public_base_url("https://:443").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn rejects_authority_with_bad_port() {
let err = validate_public_base_url("https://example.com:bad").unwrap_err();
assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
}
#[test]
fn accepts_ipv6_origin() {
assert_eq!(
validate_public_base_url("https://[::1]:8080").unwrap(),
"https://[::1]:8080"
);
}
}