1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use url::Url;
5
6pub use greentic_types::{ConnectionKind, DeploymentCtx, EnvId};
7
8#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct ConfigVersion(pub String);
11
12impl ConfigVersion {
13 pub fn v1() -> Self {
14 Self("1".to_string())
15 }
16}
17
18impl Default for ConfigVersion {
19 fn default() -> Self {
20 Self::v1()
21 }
22}
23
24#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25pub struct GreenticConfig {
26 #[serde(default = "ConfigVersion::v1")]
27 pub schema_version: ConfigVersion,
28 pub environment: EnvironmentConfig,
29 pub paths: PathsConfig,
30 #[serde(default)]
31 pub packs: Option<PacksConfig>,
32 #[serde(default)]
33 pub services: Option<ServicesConfig>,
34 #[serde(default)]
35 pub events: Option<EventsConfig>,
36 pub runtime: RuntimeConfig,
37 pub telemetry: TelemetryConfig,
38 pub network: NetworkConfig,
39 #[serde(default)]
40 pub deployer: Option<DeployerConfig>,
41 pub secrets: SecretsBackendRefConfig,
42 pub dev: Option<DevConfig>,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
46pub enum ConfigSource {
47 Default,
48 User,
49 Project,
50 Environment,
51 Cli,
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(transparent)]
56pub struct ProvenancePath(pub String);
57
58#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
59pub struct EnvironmentConfig {
60 pub env_id: EnvId,
61 pub deployment: Option<DeploymentCtx>,
62 pub connection: Option<ConnectionKind>,
63 pub region: Option<String>,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PathsConfig {
68 pub greentic_root: PathBuf,
69 pub state_dir: PathBuf,
70 pub cache_dir: PathBuf,
71 pub logs_dir: PathBuf,
72}
73
74#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
75#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ServicesConfig {
77 #[serde(default)]
78 pub events: Option<ServiceEndpointConfig>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub runner: Option<ServiceDefinitionConfig>,
82
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub deployer: Option<ServiceDefinitionConfig>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub events_transport: Option<ServiceDefinitionConfig>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub source: Option<ServiceDefinitionConfig>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub publish: Option<ServiceDefinitionConfig>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub store: Option<ServiceDefinitionConfig>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub metadata: Option<ServiceDefinitionConfig>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub oauth_broker: Option<ServiceDefinitionConfig>,
108}
109
110#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
111#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
112pub struct ServiceEndpointConfig {
113 pub url: Url,
114 #[serde(default)]
115 pub headers: Option<BTreeMap<String, String>>,
116}
117
118#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
121#[derive(Clone, Debug, Default, PartialEq, Eq)]
122pub struct ServiceDefinitionConfig {
123 pub transport: Option<ServiceTransportConfig>,
124 pub service: Option<ServiceConfig>,
125}
126
127impl Serialize for ServiceDefinitionConfig {
128 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
129 where
130 S: serde::Serializer,
131 {
132 if self.service.is_none() && self.transport.is_some() {
133 return self
134 .transport
135 .as_ref()
136 .expect("checked is_some")
137 .serialize(serializer);
138 }
139
140 let mut map = serde_json::Map::new();
141 if let Some(transport) = &self.transport {
142 let value = serde_json::to_value(transport).map_err(serde::ser::Error::custom)?;
143 let obj = value
144 .as_object()
145 .ok_or_else(|| serde::ser::Error::custom("expected map for transport"))?;
146 for (k, v) in obj {
147 map.insert(k.clone(), v.clone());
148 }
149 }
150 if let Some(service) = &self.service {
151 map.insert(
152 "service".to_string(),
153 serde_json::to_value(service).map_err(serde::ser::Error::custom)?,
154 );
155 }
156 map.serialize(serializer)
157 }
158}
159
160impl<'de> Deserialize<'de> for ServiceDefinitionConfig {
161 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
162 where
163 D: serde::Deserializer<'de>,
164 {
165 let value = serde_json::Value::deserialize(deserializer)?;
166
167 if let serde_json::Value::Object(mut map) = value {
168 let service = if let Some(service_value) = map.remove("service") {
169 Some(ServiceConfig::deserialize(service_value).map_err(serde::de::Error::custom)?)
170 } else {
171 None
172 };
173
174 let transport = if map.is_empty() {
175 None
176 } else {
177 let transport_value = serde_json::Value::Object(map);
178 ServiceTransportConfig::deserialize(transport_value).ok()
179 };
180
181 return Ok(ServiceDefinitionConfig { transport, service });
182 }
183
184 let transport = ServiceTransportConfig::deserialize(value).ok();
186
187 Ok(ServiceDefinitionConfig {
188 transport,
189 service: None,
190 })
191 }
192}
193
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
196#[serde(tag = "kind", rename_all = "kebab-case")]
197pub enum ServiceTransportConfig {
198 Noop,
200
201 Http {
206 url: url::Url,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 headers: Option<std::collections::BTreeMap<String, String>>,
209 },
210
211 Nats {
213 url: url::Url,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 subject_prefix: Option<String>,
216 },
217}
218
219#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
220#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ServiceConfig {
222 #[serde(default)]
223 pub bind_addr: Option<String>,
224 #[serde(default)]
225 pub port: Option<u16>,
226 #[serde(default)]
227 pub public_base_url: Option<String>,
228 #[serde(default)]
229 pub metrics: Option<MetricsConfig>,
230}
231
232#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
233#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
234pub struct MetricsConfig {
235 #[serde(default)]
236 pub enabled: Option<bool>,
237 #[serde(default)]
238 pub bind_addr: Option<String>,
239 #[serde(default)]
240 pub port: Option<u16>,
241 #[serde(default)]
242 pub path: Option<String>,
243}
244
245#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
246pub struct EventsConfig {
247 #[serde(default)]
248 pub reconnect: Option<ReconnectConfig>,
249 #[serde(default)]
250 pub backoff: Option<BackoffConfig>,
251}
252
253#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
254pub struct ReconnectConfig {
255 #[serde(default)]
256 pub enabled: Option<bool>,
257 #[serde(default)]
258 pub max_retries: Option<u32>,
259}
260
261#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
262pub struct BackoffConfig {
263 #[serde(default)]
264 pub initial_ms: Option<u64>,
265 #[serde(default)]
266 pub max_ms: Option<u64>,
267 #[serde(default)]
268 pub multiplier: Option<f64>,
269 #[serde(default)]
270 pub jitter: Option<bool>,
271}
272
273#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
274pub struct PacksConfig {
275 pub source: PackSourceConfig,
276 pub cache_dir: PathBuf,
277 #[serde(default)]
278 pub index_cache_ttl_secs: Option<u64>,
279 #[serde(default)]
280 pub trust: Option<PackTrustConfig>,
281}
282
283#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(tag = "type", rename_all = "snake_case")]
285pub enum PackSourceConfig {
286 LocalIndex { path: PathBuf },
287 HttpIndex { url: String },
288 OciRegistry { reference: String },
289}
290
291#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
292pub struct PackTrustConfig {
293 #[serde(default)]
294 pub public_keys: Vec<String>,
295 #[serde(default)]
296 pub require_signatures: bool,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
300pub struct RuntimeConfig {
301 #[serde(default)]
302 pub max_concurrency: Option<u32>,
303 #[serde(default)]
304 pub task_timeout_ms: Option<u64>,
305 #[serde(default)]
306 pub shutdown_grace_ms: Option<u64>,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub admin_endpoints: Option<AdminEndpointsConfig>,
310}
311
312#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
313pub struct AdminEndpointsConfig {
314 #[serde(default)]
316 pub secrets_explain_enabled: bool,
317}
318
319#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
320pub struct TelemetryConfig {
321 #[serde(default = "default_enabled")]
322 pub enabled: bool,
323 #[serde(default)]
324 pub exporter: TelemetryExporterKind,
325 #[serde(default)]
326 pub endpoint: Option<String>,
327 #[serde(default = "default_sampling")]
328 pub sampling: f32,
329}
330
331impl Default for TelemetryConfig {
332 fn default() -> Self {
333 Self {
334 enabled: default_enabled(),
335 exporter: TelemetryExporterKind::None,
336 endpoint: None,
337 sampling: default_sampling(),
338 }
339 }
340}
341
342fn default_enabled() -> bool {
343 true
344}
345
346fn default_sampling() -> f32 {
347 1.0
348}
349
350#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
351#[serde(rename_all = "snake_case")]
352pub enum TelemetryExporterKind {
353 Otlp,
354 Stdout,
355 #[default]
356 None,
357}
358
359#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
360pub struct NetworkConfig {
361 #[serde(default)]
362 pub proxy_url: Option<String>,
363 #[serde(default)]
364 pub tls_mode: TlsMode,
365 #[serde(default)]
366 pub connect_timeout_ms: Option<u64>,
367 #[serde(default)]
368 pub read_timeout_ms: Option<u64>,
369}
370
371impl Default for NetworkConfig {
372 fn default() -> Self {
373 Self {
374 proxy_url: None,
375 tls_mode: TlsMode::System,
376 connect_timeout_ms: None,
377 read_timeout_ms: None,
378 }
379 }
380}
381
382#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
383#[serde(rename_all = "snake_case")]
384pub enum TlsMode {
385 Disabled,
386 #[default]
387 System,
388 Strict,
389}
390
391#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
392pub struct DeployerConfig {
393 #[serde(default)]
395 pub base_domain: Option<String>,
396 #[serde(default)]
397 pub provider: Option<DeployerProviderDefaults>,
398}
399
400#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
401pub struct DeployerProviderDefaults {
402 #[serde(default)]
403 pub provider_kind: Option<String>,
404 #[serde(default)]
405 pub region: Option<String>,
406}
407
408#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
409pub struct SecretsBackendRefConfig {
410 #[serde(default = "default_backend_kind")]
411 pub kind: String,
412 #[serde(default)]
413 pub reference: Option<String>,
414}
415
416impl Default for SecretsBackendRefConfig {
417 fn default() -> Self {
418 Self {
419 kind: default_backend_kind(),
420 reference: None,
421 }
422 }
423}
424
425fn default_backend_kind() -> String {
426 "none".to_string()
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct DevConfig {
431 pub default_env: EnvId,
432 pub default_tenant: String,
433 #[serde(default)]
434 pub default_team: Option<String>,
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use url::Url;
441
442 fn sample_toml() -> &'static str {
443 r#"
444schema_version = "1"
445
446[environment]
447env_id = "dev"
448region = "us-east-1"
449
450[paths]
451greentic_root = "/workspace"
452state_dir = "/workspace/.greentic"
453cache_dir = "/workspace/.greentic/cache"
454logs_dir = "/workspace/.greentic/logs"
455
456[services.runner]
457kind = "http"
458url = "https://runner.greentic.local"
459headers = { "x-routing-key" = "tenant-1" }
460
461[services.runner.service]
462bind_addr = "0.0.0.0"
463port = 8080
464public_base_url = "https://runner-public.greentic.local"
465
466[services.runner.service.metrics]
467enabled = true
468bind_addr = "127.0.0.1"
469port = 9090
470path = "/metrics"
471
472[services.deployer]
473kind = "nats"
474url = "nats://nats.greentic.local:4222"
475subject_prefix = "greentic"
476
477[services.metadata]
478kind = "noop"
479
480[services.events]
481url = "https://events.greentic.local"
482headers = { "x-routing-key" = "tenant-1" }
483
484[events.reconnect]
485enabled = true
486max_retries = 25
487
488[events.backoff]
489initial_ms = 250
490max_ms = 30000
491multiplier = 2.0
492jitter = true
493
494[packs]
495cache_dir = "/workspace/.greentic/cache/packs"
496index_cache_ttl_secs = 3600
497
498[packs.source]
499type = "local_index"
500path = "/workspace/.greentic/packs/index.json"
501
502[packs.trust]
503public_keys = ["/keys/key1.pem", "/keys/key2.pem"]
504require_signatures = true
505
506[runtime]
507max_concurrency = 8
508task_timeout_ms = 30000
509shutdown_grace_ms = 5000
510
511[runtime.admin_endpoints]
512secrets_explain_enabled = true
513
514[telemetry]
515enabled = true
516exporter = "otlp"
517endpoint = "http://localhost:4317"
518sampling = 0.5
519
520[network]
521proxy_url = "http://proxy"
522tls_mode = "system"
523connect_timeout_ms = 1000
524read_timeout_ms = 2000
525
526[deployer]
527base_domain = "deploy.greentic.ai"
528
529[deployer.provider]
530provider_kind = "aws"
531region = "us-west-2"
532
533[secrets]
534kind = "vault"
535reference = "ops"
536
537[dev]
538default_env = "dev"
539default_tenant = "acme"
540default_team = "devex"
541"#
542 }
543
544 #[test]
545 fn toml_round_trip() {
546 let config: GreenticConfig = toml::from_str(sample_toml()).expect("deserialize");
547 let serialized = toml::to_string(&config).expect("serialize");
548 let config_back: GreenticConfig = toml::from_str(&serialized).expect("deserialize again");
549 assert_eq!(config, config_back);
550
551 let runner = config_back
552 .services
553 .as_ref()
554 .and_then(|s| s.runner.as_ref())
555 .expect("runner present");
556 let service = runner.service.as_ref().expect("service binding present");
557 assert_eq!(service.bind_addr.as_deref(), Some("0.0.0.0"));
558 assert_eq!(service.port, Some(8080));
559 assert_eq!(
560 service.public_base_url.as_deref(),
561 Some("https://runner-public.greentic.local")
562 );
563 let metrics = service.metrics.as_ref().expect("metrics present");
564 assert_eq!(metrics.enabled, Some(true));
565 assert_eq!(metrics.bind_addr.as_deref(), Some("127.0.0.1"));
566 assert_eq!(metrics.port, Some(9090));
567 assert_eq!(metrics.path.as_deref(), Some("/metrics"));
568 assert!(
569 config_back
570 .services
571 .as_ref()
572 .and_then(|s| s.store.as_ref())
573 .is_none(),
574 "store should remain absent when omitted"
575 );
576 }
577
578 #[test]
579 fn json_round_trip() {
580 let config: GreenticConfig = serde_json::from_str(
581 r#"{
582 "schema_version": "1",
583 "environment": {"env_id": "dev", "region": "us-west-2"},
584 "paths": {
585 "greentic_root": "/workspace",
586 "state_dir": "/workspace/.greentic",
587 "cache_dir": "/workspace/.greentic/cache",
588 "logs_dir": "/workspace/.greentic/logs"
589 },
590 "services": {
591 "events": {
592 "url": "https://events.greentic.local",
593 "headers": {"x-routing-key": "tenant-1"}
594 },
595 "runner": {
596 "kind": "http",
597 "url": "https://runner.greentic.local",
598 "headers": {"x-routing-key": "tenant-1"},
599 "service": {
600 "bind_addr": "0.0.0.0",
601 "port": 8080,
602 "public_base_url": "https://runner-public.greentic.local",
603 "metrics": {
604 "enabled": true,
605 "bind_addr": "127.0.0.1",
606 "port": 9090,
607 "path": "/metrics"
608 }
609 }
610 },
611 "deployer": {"kind": "nats", "url": "nats://nats.greentic.local:4222", "subject_prefix": "greentic"},
612 "store": {
613 "kind": "http",
614 "url": "https://store.greentic.local",
615 "service": {
616 "bind_addr": "0.0.0.0",
617 "port": 7070,
618 "metrics": {"enabled": true, "port": 9191}
619 }
620 },
621 "metadata": {"kind": "noop"}
622 },
623 "events": {
624 "reconnect": {"enabled": true, "max_retries": 10},
625 "backoff": {"initial_ms": 100, "max_ms": 5000, "multiplier": 1.5, "jitter": false}
626 },
627 "packs": {
628 "cache_dir": "/workspace/.greentic/cache/packs",
629 "index_cache_ttl_secs": 3600,
630 "source": {"type": "http_index", "url": "https://example.com/index.json"},
631 "trust": {"public_keys": ["inline-key", "/keys/key.pem"], "require_signatures": true}
632 },
633 "runtime": {"max_concurrency": 4, "task_timeout_ms": 120000, "shutdown_grace_ms": 1000, "admin_endpoints": {"secrets_explain_enabled": true}},
634 "telemetry": {"enabled": true, "exporter": "stdout", "sampling": 1.0},
635 "network": {"tls_mode": "system"},
636 "deployer": {"base_domain": "deploy.greentic.ai", "provider": {"provider_kind": "gcp", "region": "europe-west1"}},
637 "secrets": {"kind": "none"},
638 "dev": {"default_env": "dev", "default_tenant": "acme"}
639 }"#,
640 )
641 .expect("json decode");
642
643 let serialized = serde_json::to_string(&config).expect("json encode");
644 let round: GreenticConfig = serde_json::from_str(&serialized).expect("json decode round");
645 assert_eq!(config, round);
646
647 let runner = round
648 .services
649 .as_ref()
650 .and_then(|s| s.runner.as_ref())
651 .expect("runner present");
652 let service = runner.service.as_ref().expect("service binding present");
653 assert_eq!(service.port, Some(8080));
654
655 let store = round
656 .services
657 .as_ref()
658 .and_then(|s| s.store.as_ref())
659 .expect("store present");
660 let store_transport = store.transport.as_ref().expect("store transport present");
661 match store_transport {
662 ServiceTransportConfig::Http { url, .. } => {
663 assert_eq!(url.as_str(), "https://store.greentic.local/")
664 }
665 other => panic!("unexpected transport {other:?}"),
666 }
667 let store_service = store.service.as_ref().expect("store binding present");
668 assert_eq!(store_service.port, Some(7070));
669 let store_metrics = store_service
670 .metrics
671 .as_ref()
672 .expect("store metrics present");
673 assert_eq!(store_metrics.port, Some(9191));
674 }
675
676 #[test]
677 fn store_service_round_trips_with_bindings() {
678 let services: ServicesConfig = toml::from_str(
679 r#"
680[events]
681url = "https://events.greentic.local"
682
683[store]
684kind = "http"
685url = "https://store.greentic.local"
686
687[store.service]
688bind_addr = "0.0.0.0"
689port = 7070
690public_base_url = "https://store-public.greentic.local"
691
692[store.service.metrics]
693enabled = true
694bind_addr = "127.0.0.1"
695port = 9191
696path = "/metrics"
697"#,
698 )
699 .expect("deserialize store service");
700
701 let serialized = toml::to_string(&services).expect("serialize store");
702 let round: ServicesConfig = toml::from_str(&serialized).expect("deserialize store");
703
704 let store = round.store.expect("store config present");
705 let transport = store.transport.expect("transport present");
706 match transport {
707 ServiceTransportConfig::Http { url, .. } => {
708 assert_eq!(url.as_str(), "https://store.greentic.local/")
709 }
710 other => panic!("unexpected transport {other:?}"),
711 }
712
713 let service = store.service.expect("service present");
714 assert_eq!(service.bind_addr.as_deref(), Some("0.0.0.0"));
715 assert_eq!(service.port, Some(7070));
716 assert_eq!(
717 service.public_base_url.as_deref(),
718 Some("https://store-public.greentic.local")
719 );
720
721 let metrics = service.metrics.expect("metrics present");
722 assert_eq!(metrics.enabled, Some(true));
723 assert_eq!(metrics.bind_addr.as_deref(), Some("127.0.0.1"));
724 assert_eq!(metrics.port, Some(9191));
725 assert_eq!(metrics.path.as_deref(), Some("/metrics"));
726 }
727
728 #[test]
729 fn service_definition_accepts_transport_only_shape() {
730 let services: ServicesConfig = toml::from_str(
731 r#"
732[runner]
733kind = "http"
734url = "https://runner.greentic.local"
735 "#,
736 )
737 .expect("deserialize runner");
738
739 let runner = services.runner.expect("runner");
740 assert!(runner.service.is_none());
741 let transport = runner.transport.expect("transport");
742 match transport {
743 ServiceTransportConfig::Http { url, .. } => {
744 assert_eq!(url.as_str(), "https://runner.greentic.local/")
745 }
746 other => panic!("unexpected variant {other:?}"),
747 }
748 }
749
750 #[test]
751 fn services_and_events_are_schema_only() {
752 let endpoint = ServiceEndpointConfig {
753 url: Url::parse("https://events.greentic.local").unwrap(),
754 headers: None,
755 };
756 let services = ServicesConfig {
757 events: Some(endpoint),
758 ..Default::default()
759 };
760 let events = EventsConfig {
761 reconnect: Some(ReconnectConfig {
762 enabled: Some(true),
763 max_retries: Some(5),
764 }),
765 backoff: Some(BackoffConfig {
766 initial_ms: Some(100),
767 max_ms: Some(1000),
768 multiplier: Some(2.0),
769 jitter: Some(true),
770 }),
771 };
772
773 let serialized = toml::to_string(&services).expect("serialize services");
774 let services_back: ServicesConfig =
775 toml::from_str(&serialized).expect("deserialize services");
776 assert_eq!(
777 services_back.events.unwrap().url.as_str(),
778 "https://events.greentic.local/"
779 );
780
781 let serialized_events = serde_json::to_string(&events).expect("serialize events");
782 let events_back: EventsConfig =
783 serde_json::from_str(&serialized_events).expect("deserialize events");
784 assert_eq!(events_back.backoff.unwrap().initial_ms, Some(100));
785 }
786
787 #[test]
788 fn backward_compat_services_events_still_deserializes() {
789 let legacy_services: ServicesConfig = toml::from_str(
790 r#"
791[events]
792url = "https://events.greentic.local"
793headers = { "x-routing-key" = "tenant-1" }
794"#,
795 )
796 .expect("deserialize legacy services.events shape");
797 assert_eq!(
798 legacy_services.events.unwrap().url.as_str(),
799 "https://events.greentic.local/"
800 );
801 assert!(legacy_services.events_transport.is_none());
802 assert!(legacy_services.runner.is_none());
803 assert!(legacy_services.deployer.is_none());
804 assert!(legacy_services.metadata.is_none());
805 }
806
807 #[test]
808 fn service_transport_config_serializes_with_kind_tags() {
809 let noop = ServiceTransportConfig::Noop;
810 let http = ServiceTransportConfig::Http {
811 url: Url::parse("https://runner.greentic.local").unwrap(),
812 headers: None,
813 };
814 let nats = ServiceTransportConfig::Nats {
815 url: Url::parse("nats://nats.greentic.local:4222").unwrap(),
816 subject_prefix: Some("greentic".to_string()),
817 };
818
819 let noop_v = serde_json::to_value(&noop).expect("json");
820 assert_eq!(noop_v, serde_json::json!({"kind": "noop"}));
821
822 let http_v = serde_json::to_value(&http).expect("json");
823 assert_eq!(
824 http_v,
825 serde_json::json!({"kind": "http", "url": "https://runner.greentic.local/"})
826 );
827
828 let nats_v = serde_json::to_value(&nats).expect("json");
829 assert_eq!(
830 nats_v,
831 serde_json::json!({"kind": "nats", "url": "nats://nats.greentic.local:4222", "subject_prefix": "greentic"})
832 );
833 }
834}