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 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub gui_enabled: Option<bool>,
66}
67
68pub const GUI_DEFAULT_ENV_ID: &str = "local";
72
73impl EnvironmentHostConfig {
74 pub fn resolved_listen_addr(&self) -> SocketAddr {
79 self.listen_addr.unwrap_or(DEFAULT_LISTEN_ADDR)
80 }
81
82 pub fn resolved_gui_enabled(&self) -> bool {
88 self.gui_enabled
89 .unwrap_or(self.env_id.as_str() == GUI_DEFAULT_ENV_ID)
90 }
91}
92
93pub fn validate_public_base_url(value: &str) -> Result<String, crate::error::SpecError> {
106 let trimmed = value.trim();
107 let invalid = |reason: &'static str| crate::error::SpecError::InvalidPublicBaseUrl {
108 value: trimmed.to_string(),
109 reason,
110 };
111 if trimmed.is_empty() {
112 return Err(invalid("must not be empty"));
113 }
114 if trimmed.chars().any(char::is_whitespace) {
115 return Err(invalid("must not contain whitespace"));
116 }
117 let uri: http::Uri = trimmed.parse().map_err(|_| invalid("is not a valid URI"))?;
119 match uri.scheme_str() {
121 Some("http") | Some("https") => {}
122 _ => return Err(invalid("must start with http:// or https://")),
123 }
124 let authority = uri
126 .authority()
127 .ok_or_else(|| invalid("must include a host"))?;
128 if authority.as_str().contains('@') {
130 return Err(invalid("must not include userinfo"));
131 }
132 if authority.host().is_empty() {
134 return Err(invalid("must include a host"));
135 }
136 if authority.as_str().len() > authority.host().len() && authority.port_u16().is_none() {
139 return Err(invalid("port is not a valid number"));
141 }
142 if uri.query().is_some() {
144 return Err(invalid("must not include a query string"));
145 }
146 if trimmed.contains('#') {
148 return Err(invalid("must not include a fragment"));
149 }
150 let path = uri.path();
152 if !path.is_empty() && path != "/" {
153 return Err(invalid("must be an origin without a path"));
154 }
155 Ok(trimmed.trim_end_matches('/').to_string())
156}
157
158#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
160pub struct EnvPackBinding {
161 pub slot: CapabilitySlot,
162 pub kind: PackDescriptor,
163 pub pack_ref: PackId,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub answers_ref: Option<PathBuf>,
167 #[serde(default)]
169 pub generation: u64,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub previous_binding_ref: Option<PathBuf>,
172}
173
174#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
185pub struct ExtensionBinding {
186 pub kind: PackDescriptor,
187 pub pack_ref: PackId,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub instance_id: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub answers_ref: Option<PathBuf>,
197 #[serde(default)]
199 pub generation: u64,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub previous_binding_ref: Option<PathBuf>,
202}
203
204impl ExtensionBinding {
205 pub fn validate(&self) -> Result<(), SpecError> {
209 if let Some(inst) = &self.instance_id {
210 crate::refs::validate_instance_id(inst).map_err(|e| {
211 SpecError::InvalidExtensionInstanceId {
212 path: self.kind.path().to_string(),
213 reason: e.to_string(),
214 }
215 })?;
216 }
217 Ok(())
218 }
219}
220
221#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
223pub struct Environment {
224 pub schema: SchemaVersion,
225 pub environment_id: EnvId,
226 pub name: String,
227 pub host_config: EnvironmentHostConfig,
228 pub packs: Vec<EnvPackBinding>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub credentials_ref: Option<SecretRef>,
233 #[serde(default)]
234 pub bundles: Vec<BundleDeployment>,
235 #[serde(default)]
236 pub revisions: Vec<Revision>,
237 #[serde(default)]
238 pub traffic_splits: Vec<TrafficSplit>,
239 #[serde(default)]
242 pub messaging_endpoints: Vec<MessagingEndpoint>,
243 #[serde(default)]
248 pub extensions: Vec<ExtensionBinding>,
249 #[serde(default)]
250 pub revocation: RevocationConfig,
251 #[serde(default)]
252 pub retention: RetentionPolicy,
253 #[serde(default)]
254 pub health: HealthStatus,
255}
256
257impl Environment {
258 pub fn schema_str() -> &'static str {
259 SchemaVersion::ENVIRONMENT_V1
260 }
261
262 pub fn pack_for_slot(&self, slot: CapabilitySlot) -> Option<&EnvPackBinding> {
264 self.packs.iter().find(|b| b.slot == slot)
265 }
266
267 pub fn extension_for_ref(&self, r: &ExtensionRef) -> Option<&ExtensionBinding> {
272 self.extensions
273 .iter()
274 .find(|b| b.kind.path() == r.path() && b.instance_id.as_deref() == r.instance_id())
275 }
276
277 pub fn validate(&self) -> Result<(), SpecError> {
291 if self.schema.as_str() != SchemaVersion::ENVIRONMENT_V1 {
292 return Err(SpecError::SchemaMismatch {
293 expected: SchemaVersion::ENVIRONMENT_V1,
294 actual: self.schema.as_str().to_string(),
295 });
296 }
297
298 if self.host_config.env_id != self.environment_id {
299 return Err(SpecError::EnvIdMismatch {
300 context: "host_config",
301 expected: self.environment_id.clone(),
302 actual: self.host_config.env_id.clone(),
303 });
304 }
305
306 if let Some(url) = self.host_config.public_base_url.as_deref() {
307 validate_public_base_url(url)?;
308 }
309
310 let mut seen = [false; CapabilitySlot::ALL.len()];
313 for binding in &self.packs {
314 let idx = binding.slot as usize;
315 if seen[idx] {
316 return Err(SpecError::DuplicateCapabilitySlot(binding.slot));
317 }
318 seen[idx] = true;
319 }
320
321 if let Some(cred_ref) = &self.credentials_ref {
326 let actual = cred_ref.env_segment();
327 if actual != self.environment_id.as_str() {
328 return Err(SpecError::CrossEnvRef {
329 context: "credentials_ref",
330 uri: cred_ref.as_str().to_string(),
331 expected_env: self.environment_id.clone(),
332 actual_env: actual.to_string(),
333 });
334 }
335 }
336
337 for revision in &self.revisions {
338 revision.validate()?;
339 if revision.env_id != self.environment_id {
340 return Err(SpecError::EnvIdMismatch {
341 context: "revision",
342 expected: self.environment_id.clone(),
343 actual: revision.env_id.clone(),
344 });
345 }
346 }
347
348 for bundle in &self.bundles {
349 if bundle.env_id != self.environment_id {
350 return Err(SpecError::EnvIdMismatch {
351 context: "bundle_deployment",
352 expected: self.environment_id.clone(),
353 actual: bundle.env_id.clone(),
354 });
355 }
356 bundle.validate()?;
357 let mut revision_pack_ids: HashSet<&str> = HashSet::new();
358 for rev_id in &bundle.current_revisions {
359 let referenced = self
360 .revisions
361 .iter()
362 .find(|r| r.revision_id == *rev_id)
363 .ok_or(SpecError::UnknownRevision(*rev_id))?;
364 if referenced.deployment_id != bundle.deployment_id {
365 return Err(SpecError::BundleRevisionWrongDeployment {
366 deployment: bundle.deployment_id,
367 revision: *rev_id,
368 actual_deployment: referenced.deployment_id,
369 });
370 }
371 if referenced.bundle_id != bundle.bundle_id {
376 return Err(SpecError::BundleRevisionWrongBundle {
377 deployment: bundle.deployment_id,
378 revision: *rev_id,
379 expected_bundle: bundle.bundle_id.clone(),
380 actual_bundle: referenced.bundle_id.clone(),
381 });
382 }
383 revision_pack_ids.extend(referenced.pack_list.iter().map(|e| e.pack_id.as_str()));
384 }
385
386 if !bundle.config_overrides.is_empty() {
394 let mut deployment_pack_ids: HashSet<&str> = HashSet::new();
395 for rev in self.revisions.iter().filter(|r| {
396 r.deployment_id == bundle.deployment_id
397 && r.lifecycle != crate::RevisionLifecycle::Archived
398 }) {
399 deployment_pack_ids.extend(rev.pack_list.iter().map(|e| e.pack_id.as_str()));
400 }
401 if !deployment_pack_ids.is_empty() {
402 for override_pack_id in bundle.config_overrides.keys() {
403 if !deployment_pack_ids.contains(override_pack_id.as_str()) {
404 return Err(SpecError::ConfigOverridePackNotInRevisions {
405 deployment: bundle.deployment_id,
406 pack_id: override_pack_id.clone(),
407 });
408 }
409 }
410 }
411 }
412 }
413
414 for split in &self.traffic_splits {
415 if split.env_id != self.environment_id {
416 return Err(SpecError::EnvIdMismatch {
417 context: "traffic_split",
418 expected: self.environment_id.clone(),
419 actual: split.env_id.clone(),
420 });
421 }
422 split.validate()?;
423 let referenced_bundle = self
428 .bundles
429 .iter()
430 .find(|b| b.deployment_id == split.deployment_id)
431 .ok_or(SpecError::UnknownDeployment(split.deployment_id))?;
432 if referenced_bundle.bundle_id != split.bundle_id {
433 return Err(SpecError::SplitDeploymentBundleMismatch {
434 deployment: split.deployment_id,
435 split_bundle: split.bundle_id.clone(),
436 deployment_bundle: referenced_bundle.bundle_id.clone(),
437 });
438 }
439 for entry in &split.entries {
440 let referenced = self
441 .revisions
442 .iter()
443 .find(|r| r.revision_id == entry.revision_id)
444 .ok_or(SpecError::UnknownRevision(entry.revision_id))?;
445 if referenced.deployment_id != split.deployment_id {
446 return Err(SpecError::SplitRevisionWrongDeployment {
447 revision: entry.revision_id,
448 expected_deployment: split.deployment_id,
449 actual_deployment: referenced.deployment_id,
450 });
451 }
452 if referenced.bundle_id != split.bundle_id {
453 return Err(SpecError::SplitRevisionWrongBundle {
454 revision: entry.revision_id,
455 expected_bundle: split.bundle_id.clone(),
456 actual_bundle: referenced.bundle_id.clone(),
457 });
458 }
459 }
460 }
461
462 let mut seen_endpoint_ids = HashSet::with_capacity(self.messaging_endpoints.len());
466 let mut seen_provider_instances = HashSet::with_capacity(self.messaging_endpoints.len());
467 for endpoint in &self.messaging_endpoints {
468 endpoint.validate()?;
469 if endpoint.env_id != self.environment_id {
470 return Err(SpecError::EnvIdMismatch {
471 context: "messaging_endpoint",
472 expected: self.environment_id.clone(),
473 actual: endpoint.env_id.clone(),
474 });
475 }
476 if !seen_endpoint_ids.insert(endpoint.endpoint_id) {
477 return Err(SpecError::DuplicateMessagingEndpoint(endpoint.endpoint_id));
478 }
479 let instance_key = (
480 endpoint.provider_type.as_str(),
481 endpoint.provider_id.as_str(),
482 );
483 if !seen_provider_instances.insert(instance_key) {
484 return Err(SpecError::DuplicateProviderInstance {
485 provider_type: endpoint.provider_type.clone(),
486 provider_id: endpoint.provider_id.clone(),
487 });
488 }
489 for bundle_id in &endpoint.linked_bundles {
490 if !self.bundles.iter().any(|b| b.bundle_id == *bundle_id) {
491 return Err(SpecError::MessagingEndpointBundleNotLinked {
492 endpoint: endpoint.endpoint_id,
493 bundle: bundle_id.clone(),
494 });
495 }
496 }
497 if let Some(welcome) = &endpoint.welcome_flow
498 && !endpoint.linked_bundles.contains(&welcome.bundle_id)
499 {
500 return Err(SpecError::WelcomeFlowBundleNotLinked {
501 endpoint: endpoint.endpoint_id,
502 bundle: welcome.bundle_id.clone(),
503 });
504 }
505 }
506
507 let mut seen_extensions = HashSet::with_capacity(self.extensions.len());
511 for ext in &self.extensions {
512 ext.validate()?;
513 let key = (ext.kind.path(), ext.instance_id.as_deref());
514 if !seen_extensions.insert(key) {
515 return Err(SpecError::DuplicateExtension {
516 path: ext.kind.path().to_string(),
517 instance_id: ext.instance_id.clone(),
518 });
519 }
520 }
521
522 Ok(())
523 }
524}
525
526#[cfg(test)]
527mod public_base_url_tests {
528 use super::validate_public_base_url;
529
530 #[test]
531 fn accepts_https_origin() {
532 assert_eq!(
533 validate_public_base_url("https://chat.example.com").unwrap(),
534 "https://chat.example.com"
535 );
536 }
537
538 #[test]
539 fn accepts_http_origin() {
540 assert_eq!(
541 validate_public_base_url("http://localhost:8080").unwrap(),
542 "http://localhost:8080"
543 );
544 }
545
546 #[test]
547 fn trims_trailing_slash() {
548 assert_eq!(
551 validate_public_base_url("https://chat.example.com/").unwrap(),
552 "https://chat.example.com"
553 );
554 }
555
556 #[test]
557 fn rejects_path() {
558 let err = validate_public_base_url("https://chat.example.com/api").unwrap_err();
559 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
560 }
561
562 #[test]
563 fn rejects_query() {
564 let err = validate_public_base_url("https://chat.example.com?x=1").unwrap_err();
565 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
566 }
567
568 #[test]
569 fn rejects_fragment() {
570 let err = validate_public_base_url("https://chat.example.com#frag").unwrap_err();
571 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
572 }
573
574 #[test]
575 fn rejects_non_http_scheme() {
576 let err = validate_public_base_url("ftp://chat.example.com").unwrap_err();
577 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
578 }
579
580 #[test]
581 fn rejects_missing_scheme() {
582 let err = validate_public_base_url("chat.example.com").unwrap_err();
583 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
584 }
585
586 #[test]
587 fn rejects_empty_host() {
588 let err = validate_public_base_url("https:///path").unwrap_err();
589 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
590 }
591
592 #[test]
593 fn rejects_whitespace() {
594 let err = validate_public_base_url("https://chat .example.com").unwrap_err();
595 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
596 }
597
598 #[test]
599 fn trims_surrounding_whitespace_before_validation() {
600 assert_eq!(
603 validate_public_base_url(" https://chat.example.com ").unwrap(),
604 "https://chat.example.com"
605 );
606 }
607
608 #[test]
609 fn rejects_userinfo() {
610 let err = validate_public_base_url("https://user:pass@example.com").unwrap_err();
611 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
612 }
613
614 #[test]
615 fn rejects_empty_host_in_authority() {
616 let err = validate_public_base_url("https://:443").unwrap_err();
618 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
619 }
620
621 #[test]
622 fn rejects_authority_with_bad_port() {
623 let err = validate_public_base_url("https://example.com:bad").unwrap_err();
625 assert!(matches!(err, crate::SpecError::InvalidPublicBaseUrl { .. }));
626 }
627
628 #[test]
629 fn accepts_ipv6_origin() {
630 assert_eq!(
632 validate_public_base_url("https://[::1]:8080").unwrap(),
633 "https://[::1]:8080"
634 );
635 }
636}
637
638#[cfg(test)]
639mod gui_enabled_tests {
640 use super::{EnvironmentHostConfig, GUI_DEFAULT_ENV_ID};
641 use greentic_types::EnvId;
642
643 fn host_config(env_id: &str, gui_enabled: Option<bool>) -> EnvironmentHostConfig {
644 EnvironmentHostConfig {
645 env_id: EnvId::try_from(env_id).unwrap(),
646 region: None,
647 tenant_org_id: None,
648 listen_addr: None,
649 public_base_url: None,
650 gui_enabled,
651 }
652 }
653
654 #[test]
655 fn unset_defaults_on_for_local() {
656 assert_eq!(GUI_DEFAULT_ENV_ID, "local");
657 assert!(host_config("local", None).resolved_gui_enabled());
658 }
659
660 #[test]
661 fn unset_defaults_off_for_non_local() {
662 assert!(!host_config("staging", None).resolved_gui_enabled());
663 }
664
665 #[test]
666 fn explicit_value_overrides_env_id_default() {
667 assert!(!host_config("local", Some(false)).resolved_gui_enabled());
669 assert!(host_config("staging", Some(true)).resolved_gui_enabled());
671 }
672}