1use crate::bundle_deployment::BundleDeployment;
9use crate::capability_slot::{CapabilitySlot, PackDescriptor};
10use crate::error::SpecError;
11use crate::ids::PackId;
12use crate::messaging_endpoint::MessagingEndpoint;
13use crate::refs::{ExtensionRef, SecretRef};
14use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
15use crate::revision::Revision;
16use crate::traffic_split::TrafficSplit;
17use crate::version::SchemaVersion;
18use greentic_types::EnvId;
19use serde::{Deserialize, Serialize};
20use std::collections::HashSet;
21use std::net::{IpAddr, Ipv4Addr, SocketAddr};
22use std::path::PathBuf;
23
24pub const DEFAULT_LISTEN_ADDR: SocketAddr =
29 SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct EnvironmentHostConfig {
36 pub env_id: EnvId,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub region: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub tenant_org_id: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub listen_addr: Option<SocketAddr>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub public_base_url: Option<String>,
58}
59
60impl EnvironmentHostConfig {
61 pub fn resolved_listen_addr(&self) -> SocketAddr {
66 self.listen_addr.unwrap_or(DEFAULT_LISTEN_ADDR)
67 }
68}
69
70pub fn validate_public_base_url(value: &str) -> Result<String, crate::error::SpecError> {
83 let trimmed = value.trim();
84 let invalid = |reason: &'static str| crate::error::SpecError::InvalidPublicBaseUrl {
85 value: trimmed.to_string(),
86 reason,
87 };
88 if trimmed.is_empty() {
89 return Err(invalid("must not be empty"));
90 }
91 if trimmed.chars().any(char::is_whitespace) {
92 return Err(invalid("must not contain whitespace"));
93 }
94 let uri: http::Uri = trimmed.parse().map_err(|_| invalid("is not a valid URI"))?;
96 match uri.scheme_str() {
98 Some("http") | Some("https") => {}
99 _ => return Err(invalid("must start with http:// or https://")),
100 }
101 let authority = uri
103 .authority()
104 .ok_or_else(|| invalid("must include a host"))?;
105 if authority.as_str().contains('@') {
107 return Err(invalid("must not include userinfo"));
108 }
109 if authority.host().is_empty() {
111 return Err(invalid("must include a host"));
112 }
113 if authority.as_str().len() > authority.host().len() && authority.port_u16().is_none() {
116 return Err(invalid("port is not a valid number"));
118 }
119 if uri.query().is_some() {
121 return Err(invalid("must not include a query string"));
122 }
123 if trimmed.contains('#') {
125 return Err(invalid("must not include a fragment"));
126 }
127 let path = uri.path();
129 if !path.is_empty() && path != "/" {
130 return Err(invalid("must be an origin without a path"));
131 }
132 Ok(trimmed.trim_end_matches('/').to_string())
133}
134
135#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
137pub struct EnvPackBinding {
138 pub slot: CapabilitySlot,
139 pub kind: PackDescriptor,
140 pub pack_ref: PackId,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub answers_ref: Option<PathBuf>,
144 #[serde(default)]
146 pub generation: u64,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub previous_binding_ref: Option<PathBuf>,
149}
150
151#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ExtensionBinding {
163 pub kind: PackDescriptor,
164 pub pack_ref: PackId,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub instance_id: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub answers_ref: Option<PathBuf>,
174 #[serde(default)]
176 pub generation: u64,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub previous_binding_ref: Option<PathBuf>,
179}
180
181impl ExtensionBinding {
182 pub fn validate(&self) -> Result<(), SpecError> {
186 if let Some(inst) = &self.instance_id {
187 crate::refs::validate_instance_id(inst).map_err(|e| {
188 SpecError::InvalidExtensionInstanceId {
189 path: self.kind.path().to_string(),
190 reason: e.to_string(),
191 }
192 })?;
193 }
194 Ok(())
195 }
196}
197
198#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
200pub struct Environment {
201 pub schema: SchemaVersion,
202 pub environment_id: EnvId,
203 pub name: String,
204 pub host_config: EnvironmentHostConfig,
205 pub packs: Vec<EnvPackBinding>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub credentials_ref: Option<SecretRef>,
210 #[serde(default)]
211 pub bundles: Vec<BundleDeployment>,
212 #[serde(default)]
213 pub revisions: Vec<Revision>,
214 #[serde(default)]
215 pub traffic_splits: Vec<TrafficSplit>,
216 #[serde(default)]
219 pub messaging_endpoints: Vec<MessagingEndpoint>,
220 #[serde(default)]
225 pub extensions: Vec<ExtensionBinding>,
226 #[serde(default)]
227 pub revocation: RevocationConfig,
228 #[serde(default)]
229 pub retention: RetentionPolicy,
230 #[serde(default)]
231 pub health: HealthStatus,
232}
233
234impl Environment {
235 pub fn schema_str() -> &'static str {
236 SchemaVersion::ENVIRONMENT_V1
237 }
238
239 pub fn pack_for_slot(&self, slot: CapabilitySlot) -> Option<&EnvPackBinding> {
241 self.packs.iter().find(|b| b.slot == slot)
242 }
243
244 pub fn extension_for_ref(&self, r: &ExtensionRef) -> Option<&ExtensionBinding> {
249 self.extensions
250 .iter()
251 .find(|b| b.kind.path() == r.path() && b.instance_id.as_deref() == r.instance_id())
252 }
253
254 pub fn validate(&self) -> Result<(), SpecError> {
268 if self.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
269 return Err(SpecError::SchemaMismatch {
270 expected: SchemaVersion::ENVIRONMENT_V1,
271 actual: self.schema.as_str().to_string(),
272 });
273 }
274
275 if self.host_config.env_id != self.environment_id {
276 return Err(SpecError::EnvIdMismatch {
277 context: "host_config",
278 expected: self.environment_id.clone(),
279 actual: self.host_config.env_id.clone(),
280 });
281 }
282
283 if let Some(url) = self.host_config.public_base_url.as_deref() {
284 validate_public_base_url(url)?;
285 }
286
287 let mut seen = [false; CapabilitySlot::ALL.len()];
290 for binding in &self.packs {
291 let idx = binding.slot as usize;
292 if seen[idx] {
293 return Err(SpecError::DuplicateCapabilitySlot(binding.slot));
294 }
295 seen[idx] = true;
296 }
297
298 if let Some(cred_ref) = &self.credentials_ref {
303 let actual = cred_ref.env_segment();
304 if actual != self.environment_id.as_str() {
305 return Err(SpecError::CrossEnvRef {
306 context: "credentials_ref",
307 uri: cred_ref.as_str().to_string(),
308 expected_env: self.environment_id.clone(),
309 actual_env: actual.to_string(),
310 });
311 }
312 }
313
314 for revision in &self.revisions {
315 revision.validate()?;
316 if revision.env_id != self.environment_id {
317 return Err(SpecError::EnvIdMismatch {
318 context: "revision",
319 expected: self.environment_id.clone(),
320 actual: revision.env_id.clone(),
321 });
322 }
323 }
324
325 for bundle in &self.bundles {
326 if bundle.env_id != self.environment_id {
327 return Err(SpecError::EnvIdMismatch {
328 context: "bundle_deployment",
329 expected: self.environment_id.clone(),
330 actual: bundle.env_id.clone(),
331 });
332 }
333 bundle.validate()?;
334 let mut revision_pack_ids: HashSet<&str> = HashSet::new();
335 for rev_id in &bundle.current_revisions {
336 let referenced = self
337 .revisions
338 .iter()
339 .find(|r| r.revision_id == *rev_id)
340 .ok_or(SpecError::UnknownRevision(*rev_id))?;
341 if referenced.deployment_id != bundle.deployment_id {
342 return Err(SpecError::BundleRevisionWrongDeployment {
343 deployment: bundle.deployment_id,
344 revision: *rev_id,
345 actual_deployment: referenced.deployment_id,
346 });
347 }
348 if referenced.bundle_id != bundle.bundle_id {
353 return Err(SpecError::BundleRevisionWrongBundle {
354 deployment: bundle.deployment_id,
355 revision: *rev_id,
356 expected_bundle: bundle.bundle_id.clone(),
357 actual_bundle: referenced.bundle_id.clone(),
358 });
359 }
360 revision_pack_ids.extend(referenced.pack_list.iter().map(|e| e.pack_id.as_str()));
361 }
362
363 if !bundle.config_overrides.is_empty() {
371 let mut deployment_pack_ids: HashSet<&str> = HashSet::new();
372 for rev in self.revisions.iter().filter(|r| {
373 r.deployment_id == bundle.deployment_id
374 && r.lifecycle != crate::RevisionLifecycle::Archived
375 }) {
376 deployment_pack_ids.extend(rev.pack_list.iter().map(|e| e.pack_id.as_str()));
377 }
378 if !deployment_pack_ids.is_empty() {
379 for override_pack_id in bundle.config_overrides.keys() {
380 if !deployment_pack_ids.contains(override_pack_id.as_str()) {
381 return Err(SpecError::ConfigOverridePackNotInRevisions {
382 deployment: bundle.deployment_id,
383 pack_id: override_pack_id.clone(),
384 });
385 }
386 }
387 }
388 }
389 }
390
391 for split in &self.traffic_splits {
392 if split.env_id != self.environment_id {
393 return Err(SpecError::EnvIdMismatch {
394 context: "traffic_split",
395 expected: self.environment_id.clone(),
396 actual: split.env_id.clone(),
397 });
398 }
399 split.validate()?;
400 let referenced_bundle = self
405 .bundles
406 .iter()
407 .find(|b| b.deployment_id == split.deployment_id)
408 .ok_or(SpecError::UnknownDeployment(split.deployment_id))?;
409 if referenced_bundle.bundle_id != split.bundle_id {
410 return Err(SpecError::SplitDeploymentBundleMismatch {
411 deployment: split.deployment_id,
412 split_bundle: split.bundle_id.clone(),
413 deployment_bundle: referenced_bundle.bundle_id.clone(),
414 });
415 }
416 for entry in &split.entries {
417 let referenced = self
418 .revisions
419 .iter()
420 .find(|r| r.revision_id == entry.revision_id)
421 .ok_or(SpecError::UnknownRevision(entry.revision_id))?;
422 if referenced.deployment_id != split.deployment_id {
423 return Err(SpecError::SplitRevisionWrongDeployment {
424 revision: entry.revision_id,
425 expected_deployment: split.deployment_id,
426 actual_deployment: referenced.deployment_id,
427 });
428 }
429 if referenced.bundle_id != split.bundle_id {
430 return Err(SpecError::SplitRevisionWrongBundle {
431 revision: entry.revision_id,
432 expected_bundle: split.bundle_id.clone(),
433 actual_bundle: referenced.bundle_id.clone(),
434 });
435 }
436 }
437 }
438
439 let mut seen_endpoint_ids = HashSet::with_capacity(self.messaging_endpoints.len());
443 let mut seen_provider_instances = HashSet::with_capacity(self.messaging_endpoints.len());
444 for endpoint in &self.messaging_endpoints {
445 endpoint.validate()?;
446 if endpoint.env_id != self.environment_id {
447 return Err(SpecError::EnvIdMismatch {
448 context: "messaging_endpoint",
449 expected: self.environment_id.clone(),
450 actual: endpoint.env_id.clone(),
451 });
452 }
453 if !seen_endpoint_ids.insert(endpoint.endpoint_id) {
454 return Err(SpecError::DuplicateMessagingEndpoint(endpoint.endpoint_id));
455 }
456 let instance_key = (
457 endpoint.provider_type.as_str(),
458 endpoint.provider_id.as_str(),
459 );
460 if !seen_provider_instances.insert(instance_key) {
461 return Err(SpecError::DuplicateProviderInstance {
462 provider_type: endpoint.provider_type.clone(),
463 provider_id: endpoint.provider_id.clone(),
464 });
465 }
466 for bundle_id in &endpoint.linked_bundles {
467 if !self.bundles.iter().any(|b| b.bundle_id == *bundle_id) {
468 return Err(SpecError::MessagingEndpointBundleNotLinked {
469 endpoint: endpoint.endpoint_id,
470 bundle: bundle_id.clone(),
471 });
472 }
473 }
474 if let Some(welcome) = &endpoint.welcome_flow
475 && !endpoint.linked_bundles.contains(&welcome.bundle_id)
476 {
477 return Err(SpecError::WelcomeFlowBundleNotLinked {
478 endpoint: endpoint.endpoint_id,
479 bundle: welcome.bundle_id.clone(),
480 });
481 }
482 }
483
484 let mut seen_extensions = HashSet::with_capacity(self.extensions.len());
488 for ext in &self.extensions {
489 ext.validate()?;
490 let key = (ext.kind.path(), ext.instance_id.as_deref());
491 if !seen_extensions.insert(key) {
492 return Err(SpecError::DuplicateExtension {
493 path: ext.kind.path().to_string(),
494 instance_id: ext.instance_id.clone(),
495 });
496 }
497 }
498
499 Ok(())
500 }
501}
502
503#[cfg(test)]
504mod public_base_url_tests {
505 use super::validate_public_base_url;
506
507 #[test]
508 fn accepts_https_origin() {
509 assert_eq!(
510 validate_public_base_url("https://chat.example.com").unwrap(),
511 "https://chat.example.com"
512 );
513 }
514
515 #[test]
516 fn accepts_http_origin() {
517 assert_eq!(
518 validate_public_base_url("http://localhost:8080").unwrap(),
519 "http://localhost:8080"
520 );
521 }
522
523 #[test]
524 fn trims_trailing_slash() {
525 assert_eq!(
528 validate_public_base_url("https://chat.example.com/").unwrap(),
529 "https://chat.example.com"
530 );
531 }
532
533 #[test]
534 fn rejects_path() {
535 let err = validate_public_base_url("https://chat.example.com/api").unwrap_err();
536 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
537 }
538
539 #[test]
540 fn rejects_query() {
541 let err = validate_public_base_url("https://chat.example.com?x=1").unwrap_err();
542 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
543 }
544
545 #[test]
546 fn rejects_fragment() {
547 let err = validate_public_base_url("https://chat.example.com#frag").unwrap_err();
548 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
549 }
550
551 #[test]
552 fn rejects_non_http_scheme() {
553 let err = validate_public_base_url("ftp://chat.example.com").unwrap_err();
554 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
555 }
556
557 #[test]
558 fn rejects_missing_scheme() {
559 let err = validate_public_base_url("chat.example.com").unwrap_err();
560 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
561 }
562
563 #[test]
564 fn rejects_empty_host() {
565 let err = validate_public_base_url("https:///path").unwrap_err();
566 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
567 }
568
569 #[test]
570 fn rejects_whitespace() {
571 let err = validate_public_base_url("https://chat .example.com").unwrap_err();
572 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
573 }
574
575 #[test]
576 fn trims_surrounding_whitespace_before_validation() {
577 assert_eq!(
580 validate_public_base_url(" https://chat.example.com ").unwrap(),
581 "https://chat.example.com"
582 );
583 }
584
585 #[test]
586 fn rejects_userinfo() {
587 let err = validate_public_base_url("https://user:pass@example.com").unwrap_err();
588 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
589 }
590
591 #[test]
592 fn rejects_empty_host_in_authority() {
593 let err = validate_public_base_url("https://:443").unwrap_err();
595 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
596 }
597
598 #[test]
599 fn rejects_authority_with_bad_port() {
600 let err = validate_public_base_url("https://example.com:bad").unwrap_err();
602 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
603 }
604
605 #[test]
606 fn accepts_ipv6_origin() {
607 assert_eq!(
609 validate_public_base_url("https://[::1]:8080").unwrap(),
610 "https://[::1]:8080"
611 );
612 }
613}