1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Deserialize, Clone)]
12pub(crate) struct RawDatabase {
13 pub engine: String,
14 #[serde(default)]
15 pub version: Option<String>,
16 #[serde(default)]
17 pub size: Option<String>,
18 #[serde(default)]
19 pub shared: bool,
20 #[serde(default)]
21 pub name: Option<String>,
22 #[serde(default)]
23 pub namespace: Option<String>,
24 #[serde(default, flatten)]
25 pub envs: std::collections::BTreeMap<String, RawDatabaseEnv>,
26}
27
28#[derive(Debug, Deserialize, Clone, Default)]
29pub(crate) struct RawDatabaseEnv {
30 #[serde(default)]
31 pub engine: Option<String>,
32 #[serde(default)]
33 pub version: Option<String>,
34 #[serde(default)]
35 pub size: Option<String>,
36 #[serde(default)]
37 pub shared: Option<bool>,
38 #[serde(default)]
39 pub name: Option<String>,
40 #[serde(default)]
41 pub namespace: Option<String>,
42 #[serde(default)]
43 pub url: Option<String>,
44}
45
46#[derive(Debug, Deserialize, Clone)]
47pub(crate) struct RawCache {
48 pub engine: String,
49 #[serde(default)]
50 pub size: Option<String>,
51 #[serde(default)]
52 pub shared: bool,
53 #[serde(default)]
54 pub name: Option<String>,
55 #[serde(default)]
56 pub namespace: Option<String>,
57 #[serde(default, flatten)]
58 pub envs: std::collections::BTreeMap<String, RawCacheEnv>,
59}
60
61#[derive(Debug, Deserialize, Clone, Default)]
62pub(crate) struct RawCacheEnv {
63 #[serde(default)]
64 pub engine: Option<String>,
65 #[serde(default)]
66 pub size: Option<String>,
67 #[serde(default)]
68 pub shared: Option<bool>,
69 #[serde(default)]
70 pub name: Option<String>,
71 #[serde(default)]
72 pub namespace: Option<String>,
73 #[serde(default)]
74 pub url: Option<String>,
75}
76
77#[derive(Debug, Deserialize, Clone)]
78pub(crate) struct RawSecrets {
79 #[serde(default = "default_secret_provider")]
80 pub provider: String,
81 #[serde(default)]
82 pub required: Vec<String>,
83 #[serde(default)]
84 pub map: std::collections::BTreeMap<String, String>,
85 #[serde(default)]
86 pub external_store: Option<RawExternalStore>,
87}
88
89fn default_secret_provider() -> String {
90 "k8s".into()
91}
92
93#[derive(Debug, Deserialize, Clone)]
94pub(crate) struct RawExternalStore {
95 pub name: String,
96 pub kind: String,
97}
98
99#[derive(Debug, Deserialize, Clone)]
100pub(crate) struct RawConfigBlock {
101 #[serde(default = "default_config_engine")]
102 pub engine: String,
103 #[serde(default)]
104 pub path_prefix: Option<String>,
105 #[serde(default)]
106 pub poll_interval_seconds: Option<u64>,
107 #[serde(default)]
108 pub endpoints: Vec<String>,
109 #[serde(default)]
110 pub repo: Option<String>,
111 #[serde(default)]
112 pub git_ref: Option<String>,
113 #[serde(default)]
114 pub sources: Vec<String>,
115}
116
117fn default_config_engine() -> String {
118 "env".into()
119}
120
121#[derive(Debug, Deserialize, Clone)]
122pub(crate) struct RawMigrations {
123 pub tool: String,
124 #[serde(default = "default_migrations_dir")]
125 pub dir: String,
126 #[serde(default = "default_run_on")]
127 pub run_on: String,
128 #[serde(default)]
129 pub command: Option<Vec<String>>,
130}
131
132fn default_migrations_dir() -> String {
133 "migrations/".into()
134}
135
136fn default_run_on() -> String {
137 "init-container".into()
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum DatabaseEngine {
145 Postgres,
146 Mysql,
147 Sqlite,
148 Clickhouse,
149 None,
150}
151
152impl DatabaseEngine {
153 pub fn parse(s: &str) -> Self {
154 match s {
155 "postgres" => Self::Postgres,
156 "mysql" => Self::Mysql,
157 "sqlite" => Self::Sqlite,
158 "clickhouse" => Self::Clickhouse,
159 _ => Self::None,
160 }
161 }
162 pub fn as_str(&self) -> &'static str {
163 match self {
164 Self::Postgres => "postgres",
165 Self::Mysql => "mysql",
166 Self::Sqlite => "sqlite",
167 Self::Clickhouse => "clickhouse",
168 Self::None => "none",
169 }
170 }
171 pub fn default_port(&self) -> u32 {
172 match self {
173 Self::Postgres => 5432,
174 Self::Mysql => 3306,
175 Self::Clickhouse => 9000,
176 Self::Sqlite | Self::None => 0,
177 }
178 }
179}
180
181#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
182#[serde(rename_all = "lowercase")]
183pub enum CacheEngine {
184 Redis,
185 None,
186}
187
188impl CacheEngine {
189 pub fn parse(s: &str) -> Self {
190 match s {
191 "redis" => Self::Redis,
192 _ => Self::None,
193 }
194 }
195 pub fn as_str(&self) -> &'static str {
196 match self {
197 Self::Redis => "redis",
198 Self::None => "none",
199 }
200 }
201 pub fn default_port(&self) -> u32 {
202 match self {
203 Self::Redis => 6379,
204 Self::None => 0,
205 }
206 }
207}
208
209#[derive(Clone, Debug)]
210pub struct DatabaseSpec {
211 pub engine: DatabaseEngine,
212 pub version: String,
213 pub size: String,
214 pub shared: bool,
215 pub name: String,
216 pub namespace: String,
217 pub url_override: Option<String>,
218}
219
220fn default_db_version(engine: DatabaseEngine) -> String {
221 match engine {
222 DatabaseEngine::Postgres => "18".into(),
223 DatabaseEngine::Mysql => "8".into(),
224 DatabaseEngine::Clickhouse => "24.3".into(),
225 DatabaseEngine::Sqlite | DatabaseEngine::None => "latest".into(),
226 }
227}
228
229impl DatabaseSpec {
230 pub fn image(&self) -> String {
231 match self.engine {
232 DatabaseEngine::Postgres => format!("postgres:{}", self.version),
233 DatabaseEngine::Mysql => format!("mysql:{}", self.version),
234 DatabaseEngine::Clickhouse => format!("clickhouse/clickhouse-server:{}", self.version),
235 DatabaseEngine::Sqlite | DatabaseEngine::None => "".into(),
236 }
237 }
238 pub fn host(&self) -> String {
239 format!("{}.{}.svc.cluster.local", self.name, self.namespace)
240 }
241 pub fn port(&self) -> u32 {
242 self.engine.default_port()
243 }
244 pub fn url_template(&self, service_name: &str) -> String {
245 if let Some(ref url) = self.url_override {
246 return url.clone();
247 }
248 format!(
253 "{}://{svc}:$(DATABASE_PASSWORD)@{host}:{port}/{svc}",
254 self.engine.as_str(),
255 svc = service_name,
256 host = self.host(),
257 port = self.port(),
258 )
259 }
260}
261
262#[derive(Clone, Debug)]
263pub struct CacheSpec {
264 pub engine: CacheEngine,
265 pub size: String,
266 pub shared: bool,
267 pub name: String,
268 pub namespace: String,
269 pub url_override: Option<String>,
270}
271
272impl CacheSpec {
273 pub fn host(&self) -> String {
274 format!("{}.{}.svc.cluster.local", self.name, self.namespace)
275 }
276 pub fn port(&self) -> u32 {
277 self.engine.default_port()
278 }
279 pub fn url(&self) -> String {
280 if let Some(ref url) = self.url_override {
281 return url.clone();
282 }
283 format!("redis://{}:{}", self.host(), self.port())
284 }
285}
286
287#[derive(Clone, Debug)]
288pub struct SecretsSpec {
289 pub provider: SecretProvider,
290 pub required: Vec<String>,
291 pub map: std::collections::BTreeMap<String, String>,
292 pub external_store: Option<ExternalStore>,
293}
294
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub enum SecretProvider {
297 K8s,
298 ExternalSecrets,
299 Vault,
300 AwsSecretsManager,
301}
302
303impl SecretProvider {
304 pub fn parse(s: &str) -> Self {
305 match s {
306 "external-secrets" => Self::ExternalSecrets,
307 "vault" => Self::Vault,
308 "aws-secrets-manager" => Self::AwsSecretsManager,
309 _ => Self::K8s,
310 }
311 }
312 pub fn as_str(&self) -> &'static str {
313 match self {
314 Self::K8s => "k8s",
315 Self::ExternalSecrets => "external-secrets",
316 Self::Vault => "vault",
317 Self::AwsSecretsManager => "aws-secrets-manager",
318 }
319 }
320}
321
322#[derive(Clone, Debug)]
323pub struct ExternalStore {
324 pub name: String,
325 pub kind: String,
326}
327
328#[derive(Clone, Debug)]
329pub struct ConfigSpec {
330 pub engine: ConfigEngine,
331 pub path_prefix: Option<String>,
332 pub poll_interval_seconds: u64,
333 pub endpoints: Vec<String>,
334 pub repo: Option<String>,
335 pub git_ref: Option<String>,
336 pub sources: Vec<ConfigEngine>,
337}
338
339#[derive(Clone, Copy, Debug, PartialEq, Eq)]
340pub enum ConfigEngine {
341 Env,
342 Etcd,
343 Github,
344 Chained,
345}
346
347impl ConfigEngine {
348 pub fn parse(s: &str) -> Self {
349 match s {
350 "etcd" => Self::Etcd,
351 "github" => Self::Github,
352 "chained" => Self::Chained,
353 _ => Self::Env,
354 }
355 }
356 pub fn as_str(&self) -> &'static str {
357 match self {
358 Self::Env => "env",
359 Self::Etcd => "etcd",
360 Self::Github => "github",
361 Self::Chained => "chained",
362 }
363 }
364}
365
366pub(crate) fn resolve_config(raw: &RawConfigBlock) -> ConfigSpec {
367 ConfigSpec {
368 engine: ConfigEngine::parse(&raw.engine),
369 path_prefix: raw.path_prefix.clone(),
370 poll_interval_seconds: raw.poll_interval_seconds.unwrap_or(30),
371 endpoints: raw.endpoints.clone(),
372 repo: raw.repo.clone(),
373 git_ref: raw.git_ref.clone(),
374 sources: raw.sources.iter().map(|s| ConfigEngine::parse(s)).collect(),
375 }
376}
377
378#[derive(Clone, Debug)]
379pub struct MigrationsSpec {
380 pub tool: MigrationTool,
381 pub dir: String,
382 pub run_on: MigrationRunOn,
383 pub command: Vec<String>,
384}
385
386#[derive(Clone, Copy, Debug, PartialEq, Eq)]
387pub enum MigrationTool {
388 Sqlx,
389 Refinery,
390 Flyway,
391 Custom,
392}
393
394#[derive(Clone, Copy, Debug, PartialEq, Eq)]
395pub enum MigrationRunOn {
396 InitContainer,
397 Boot,
398 Manual,
399}
400
401#[derive(Debug, Deserialize, Clone)]
404#[serde(untagged)]
405pub(crate) enum RawCallerEntry {
406 Namespace(String),
407 Env(std::collections::BTreeMap<String, String>),
408}
409
410#[derive(Debug, Default, Deserialize, Clone)]
411#[serde(transparent)]
412pub(crate) struct RawCallers(pub std::collections::BTreeMap<String, RawCallerEntry>);
413
414pub(crate) fn resolve_callers(raw: &RawCallers, env: &str) -> Vec<crate::plan::ServiceRef> {
415 let mut base = std::collections::BTreeMap::new();
416 let mut overlay = std::collections::BTreeMap::new();
417
418 for (key, entry) in &raw.0 {
419 match entry {
420 RawCallerEntry::Namespace(ns) => {
421 base.insert(key.clone(), crate::plan::apply_env(ns, env));
424 }
425 RawCallerEntry::Env(map) if key == env => {
426 overlay = map
427 .iter()
428 .map(|(k, v)| (k.clone(), crate::plan::apply_env(v, env)))
429 .collect();
430 }
431 RawCallerEntry::Env(_) => {}
432 }
433 }
434
435 base.extend(overlay);
436 base.into_iter()
437 .map(|(name, namespace)| crate::plan::ServiceRef { name, namespace })
438 .collect()
439}
440
441pub fn select_env(explicit: Option<&str>) -> String {
445 if let Some(e) = explicit {
446 return e.to_string();
447 }
448 std::env::var("TONIN_ENV").unwrap_or_else(|_| "dev".to_string())
449}
450
451pub(crate) fn resolve_database(
452 raw: &RawDatabase,
453 env: &str,
454 service_name: &str,
455 service_namespace: &str,
456) -> DatabaseSpec {
457 let overlay = raw.envs.get(env);
458 let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
459 let engine = DatabaseEngine::parse(
460 overlay
461 .and_then(|o| o.engine.as_deref())
462 .unwrap_or(&raw.engine),
463 );
464 let version = overlay
465 .and_then(|o| o.version.clone())
466 .or_else(|| raw.version.clone())
467 .unwrap_or_else(|| default_db_version(engine));
468 let size = overlay
469 .and_then(|o| o.size.clone())
470 .or_else(|| raw.size.clone())
471 .unwrap_or_else(|| "2Gi".into());
472 let name = overlay
473 .and_then(|o| o.name.clone())
474 .or_else(|| raw.name.clone())
475 .unwrap_or_else(|| format!("{}-db", service_name));
476 let namespace = overlay
477 .and_then(|o| o.namespace.clone())
478 .or_else(|| raw.namespace.clone())
479 .unwrap_or_else(|| service_namespace.to_string());
480 let url_override = overlay.and_then(|o| o.url.clone());
481 DatabaseSpec {
482 engine,
483 version,
484 size,
485 shared,
486 name,
487 namespace,
488 url_override,
489 }
490}
491
492pub(crate) fn resolve_cache(
493 raw: &RawCache,
494 env: &str,
495 service_name: &str,
496 service_namespace: &str,
497) -> CacheSpec {
498 let overlay = raw.envs.get(env);
499 let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
500 let engine = CacheEngine::parse(
501 overlay
502 .and_then(|o| o.engine.as_deref())
503 .unwrap_or(&raw.engine),
504 );
505 let size = overlay
506 .and_then(|o| o.size.clone())
507 .or_else(|| raw.size.clone())
508 .unwrap_or_else(|| "1Gi".into());
509 let name = overlay
510 .and_then(|o| o.name.clone())
511 .or_else(|| raw.name.clone())
512 .unwrap_or_else(|| format!("{}-cache", service_name));
513 let namespace = overlay
514 .and_then(|o| o.namespace.clone())
515 .or_else(|| raw.namespace.clone())
516 .unwrap_or_else(|| service_namespace.to_string());
517 let url_override = overlay.and_then(|o| o.url.clone());
518 CacheSpec {
519 engine,
520 size,
521 shared,
522 name,
523 namespace,
524 url_override,
525 }
526}
527
528pub(crate) fn resolve_secrets(raw: &RawSecrets) -> SecretsSpec {
529 SecretsSpec {
530 provider: SecretProvider::parse(&raw.provider),
531 required: raw.required.clone(),
532 map: raw.map.clone(),
533 external_store: raw.external_store.as_ref().map(|e| ExternalStore {
534 name: e.name.clone(),
535 kind: e.kind.clone(),
536 }),
537 }
538}
539
540pub(crate) fn resolve_migrations(raw: &RawMigrations) -> MigrationsSpec {
541 let tool = match raw.tool.as_str() {
542 "refinery" => MigrationTool::Refinery,
543 "flyway" => MigrationTool::Flyway,
544 "custom" => MigrationTool::Custom,
545 _ => MigrationTool::Sqlx,
546 };
547 let run_on = match raw.run_on.as_str() {
548 "boot" => MigrationRunOn::Boot,
549 "manual" => MigrationRunOn::Manual,
550 _ => MigrationRunOn::InitContainer,
551 };
552 let command = match (tool, &raw.command) {
553 (MigrationTool::Custom, Some(cmd)) => cmd.clone(),
554 (MigrationTool::Sqlx, _) => vec![
555 "sqlx".into(),
556 "migrate".into(),
557 "run".into(),
558 "--source".into(),
559 raw.dir.clone(),
560 ],
561 (MigrationTool::Refinery, _) => vec![
562 "refinery".into(),
563 "migrate".into(),
564 "-p".into(),
565 raw.dir.clone(),
566 ],
567 (MigrationTool::Flyway, _) => vec![
568 "flyway".into(),
569 "-locations=filesystem:".to_string() + &raw.dir,
570 "migrate".into(),
571 ],
572 (MigrationTool::Custom, None) => Vec::new(),
573 };
574 MigrationsSpec {
575 tool,
576 dir: raw.dir.clone(),
577 run_on,
578 command,
579 }
580}
581
582#[derive(Clone, Debug, Default)]
585pub struct EmittedEnv {
586 pub literals: Vec<(String, String)>,
587 pub from_secret: Vec<String>,
588}
589
590impl EmittedEnv {
591 pub fn extend_database(&mut self, spec: &DatabaseSpec, service_name: &str) {
592 self.extend_database_named("DATABASE", spec, service_name);
593 }
594
595 pub fn extend_database_named(
596 &mut self,
597 var_prefix: &str,
598 spec: &DatabaseSpec,
599 service_name: &str,
600 ) {
601 if matches!(spec.engine, DatabaseEngine::None) {
602 return;
603 }
604 self.literals
605 .push((format!("{var_prefix}_URL"), spec.url_template(service_name)));
606 if spec.url_override.is_none() {
607 self.from_secret.push(format!("{var_prefix}_PASSWORD"));
608 }
609 }
610
611 pub fn extend_cache(&mut self, spec: &CacheSpec) {
612 self.extend_cache_named("REDIS", spec);
613 }
614
615 pub fn extend_cache_named(&mut self, var_prefix: &str, spec: &CacheSpec) {
616 if matches!(spec.engine, CacheEngine::None) {
617 return;
618 }
619 self.literals
620 .push((format!("{var_prefix}_URL"), spec.url()));
621 }
622
623 pub fn extend_secrets(&mut self, spec: &SecretsSpec) {
624 for key in &spec.required {
625 self.from_secret.push(key.clone());
626 }
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 fn toml_to_raw_db(s: &str) -> RawDatabase {
635 toml::from_str::<toml::Value>(s)
636 .unwrap()
637 .get("database")
638 .unwrap()
639 .clone()
640 .try_into()
641 .unwrap()
642 }
643
644 #[test]
645 fn db_overlay_dev_wins_over_top_level() {
646 let toml = r#"
647 [database]
648 engine = "postgres"
649 shared = false
650 size = "10Gi"
651
652 [database.dev]
653 shared = true
654 name = "postgres"
655 namespace = "shared-dev"
656 "#;
657 let raw = toml_to_raw_db(toml);
658 let spec = resolve_database(&raw, "dev", "billing", "billing-ns");
659 assert!(spec.shared, "dev overlay forces shared=true");
660 assert_eq!(spec.name, "postgres");
661 assert_eq!(spec.namespace, "shared-dev");
662 assert_eq!(spec.engine, DatabaseEngine::Postgres);
663 assert_eq!(spec.size, "10Gi");
664 }
665
666 #[test]
667 fn db_prod_uses_owned_defaults() {
668 let toml = r#"
669 [database]
670 engine = "postgres"
671 shared = false
672 size = "10Gi"
673
674 [database.dev]
675 shared = true
676 name = "postgres"
677 namespace = "shared-dev"
678 "#;
679 let raw = toml_to_raw_db(toml);
680 let spec = resolve_database(&raw, "prod", "billing", "billing-ns");
681 assert!(!spec.shared);
682 assert_eq!(spec.name, "billing-db");
683 assert_eq!(spec.namespace, "billing-ns");
684 assert_eq!(spec.size, "10Gi");
685 }
686
687 #[test]
688 fn db_unknown_env_falls_back_to_top_level() {
689 let toml = r#"
690 [database]
691 engine = "postgres"
692 "#;
693 let raw = toml_to_raw_db(toml);
694 let spec = resolve_database(&raw, "staging", "audit", "audit");
695 assert!(!spec.shared);
696 assert_eq!(spec.engine, DatabaseEngine::Postgres);
697 }
698
699 #[test]
700 fn db_emits_url_and_password_secret() {
701 let toml = r#"
702 [database]
703 engine = "postgres"
704 shared = false
705 "#;
706 let raw = toml_to_raw_db(toml);
707 let spec = resolve_database(&raw, "prod", "billing", "shop");
708 let mut env = EmittedEnv::default();
709 env.extend_database(&spec, "billing");
710 assert_eq!(env.literals.len(), 1);
711 assert_eq!(env.literals[0].0, "DATABASE_URL");
712 assert!(env.literals[0].1.starts_with(
713 "postgres://billing:$(DATABASE_PASSWORD)@billing-db.shop.svc.cluster.local:5432/billing"
714 ));
715 assert_eq!(env.from_secret, vec!["DATABASE_PASSWORD".to_string()]);
716 }
717
718 #[test]
719 fn cache_shared_overlay() {
720 let toml = r#"
721 [cache]
722 engine = "redis"
723 shared = false
724
725 [cache.dev]
726 shared = true
727 name = "redis"
728 namespace = "shared-dev"
729 "#;
730 let raw: RawCache = toml::from_str::<toml::Value>(toml)
731 .unwrap()
732 .get("cache")
733 .unwrap()
734 .clone()
735 .try_into()
736 .unwrap();
737 let spec = resolve_cache(&raw, "dev", "billing", "shop");
738 assert!(spec.shared);
739 assert_eq!(spec.name, "redis");
740 assert_eq!(spec.namespace, "shared-dev");
741 assert_eq!(
742 spec.url(),
743 "redis://redis.shared-dev.svc.cluster.local:6379"
744 );
745 }
746
747 #[test]
748 fn secrets_default_provider_is_k8s() {
749 let raw = RawSecrets {
750 provider: default_secret_provider(),
751 required: vec!["JWT_SIGNING_KEY".into()],
752 map: Default::default(),
753 external_store: None,
754 };
755 let spec = resolve_secrets(&raw);
756 assert_eq!(spec.provider, SecretProvider::K8s);
757 assert_eq!(spec.required, vec!["JWT_SIGNING_KEY".to_string()]);
758 }
759
760 #[test]
761 fn migrations_sqlx_command_default() {
762 let raw = RawMigrations {
763 tool: "sqlx".into(),
764 dir: default_migrations_dir(),
765 run_on: default_run_on(),
766 command: None,
767 };
768 let spec = resolve_migrations(&raw);
769 assert_eq!(spec.tool, MigrationTool::Sqlx);
770 assert_eq!(spec.run_on, MigrationRunOn::InitContainer);
771 assert_eq!(
772 spec.command,
773 vec!["sqlx", "migrate", "run", "--source", "migrations/"]
774 );
775 }
776
777 #[test]
778 fn migrations_custom_requires_command() {
779 let raw = RawMigrations {
780 tool: "custom".into(),
781 dir: "migrations/".into(),
782 run_on: "init-container".into(),
783 command: Some(vec!["./migrate.sh".into(), "--all".into()]),
784 };
785 let spec = resolve_migrations(&raw);
786 assert_eq!(spec.tool, MigrationTool::Custom);
787 assert_eq!(spec.command, vec!["./migrate.sh", "--all"]);
788 }
789
790 fn parse_callers(toml_str: &str) -> RawCallers {
791 #[derive(serde::Deserialize)]
792 struct Wrapper {
793 callers: RawCallers,
794 }
795 toml::from_str::<Wrapper>(toml_str).unwrap().callers
796 }
797
798 #[test]
799 fn callers_base_only_no_overlay() {
800 let raw = parse_callers(
801 r#"
802 [callers]
803 gateway = "agnitiv"
804 zradar-platform = "agnitiv"
805 "#,
806 );
807 let callers = resolve_callers(&raw, "dev");
808 assert_eq!(callers.len(), 2);
809 assert!(callers.iter().all(|c| c.namespace == "agnitiv"));
810 }
811
812 #[test]
813 fn callers_dev_overlay_overrides_namespace() {
814 let raw = parse_callers(
815 r#"
816 [callers]
817 gateway = "agnitiv"
818 zradar-platform = "agnitiv"
819
820 [callers.dev]
821 gateway = "agnitiv-dev"
822 zradar-platform = "agnitiv-dev"
823 "#,
824 );
825 let dev = resolve_callers(&raw, "dev");
826 assert!(
827 dev.iter().all(|c| c.namespace == "agnitiv-dev"),
828 "dev overlay must win"
829 );
830 let prod = resolve_callers(&raw, "prod");
831 assert!(
832 prod.iter().all(|c| c.namespace == "agnitiv"),
833 "prod falls back to base"
834 );
835 }
836
837 #[test]
838 fn callers_dev_overlay_adds_new_caller() {
839 let raw = parse_callers(
840 r#"
841 [callers]
842 gateway = "agnitiv"
843
844 [callers.dev]
845 gateway = "agnitiv-dev"
846 debug-tool = "agnitiv-dev"
847 "#,
848 );
849 let dev = resolve_callers(&raw, "dev");
850 assert_eq!(dev.len(), 2, "overlay adds debug-tool");
851 let prod = resolve_callers(&raw, "prod");
852 assert_eq!(prod.len(), 1, "prod sees base only");
853 }
854
855 #[test]
856 fn callers_env_placeholder_resolves_per_env() {
857 let raw = parse_callers(
858 r#"
859 [callers]
860 gateway = "agnitiv-{env}"
861 zradar-platform = "agnitiv-{env}"
862 "#,
863 );
864 let dev = resolve_callers(&raw, "dev");
865 assert!(
866 dev.iter().all(|c| c.namespace == "agnitiv-dev"),
867 "dev: {{env}} -> -dev"
868 );
869 let staging = resolve_callers(&raw, "staging");
870 assert!(
871 staging.iter().all(|c| c.namespace == "agnitiv-staging"),
872 "staging: {{env}} -> -staging"
873 );
874 let prod = resolve_callers(&raw, "prod");
875 assert!(
876 prod.iter().all(|c| c.namespace == "agnitiv-prod"),
877 "prod: {{env}} -> -prod"
878 );
879 }
880
881 #[test]
882 fn callers_env_placeholder_with_prod_override() {
883 let raw = parse_callers(
885 r#"
886 [callers]
887 gateway = "agnitiv-{env}"
888 zradar-platform = "agnitiv-{env}"
889
890 [callers.prod]
891 gateway = "agnitiv"
892 zradar-platform = "agnitiv"
893 "#,
894 );
895 let dev = resolve_callers(&raw, "dev");
896 assert!(dev.iter().all(|c| c.namespace == "agnitiv-dev"));
897 let staging = resolve_callers(&raw, "staging");
898 assert!(staging.iter().all(|c| c.namespace == "agnitiv-staging"));
899 let prod = resolve_callers(&raw, "prod");
900 assert!(
901 prod.iter().all(|c| c.namespace == "agnitiv"),
902 "prod override wins over {{env}}"
903 );
904 }
905
906 #[test]
907 fn db_dev_url_override_used_verbatim() {
908 let toml = r#"
909 [database]
910 engine = "postgres"
911
912 [database.dev]
913 shared = true
914 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
915 "#;
916 let raw = toml_to_raw_db(toml);
917 let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
918 assert_eq!(
919 spec.url_template("identity"),
920 "postgresql://postgres:postgres@shared.svc:5432/mydb"
921 );
922 assert!(spec.url_override.is_some());
923 }
924
925 #[test]
926 fn db_prod_no_url_override_keeps_template() {
927 let toml = r#"
928 [database]
929 engine = "postgres"
930
931 [database.dev]
932 shared = true
933 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
934 "#;
935 let raw = toml_to_raw_db(toml);
936 let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
937 assert!(spec.url_override.is_none());
938 assert!(
939 spec.url_template("identity")
940 .contains("$(DATABASE_PASSWORD)"),
941 "prod uses password-template URL with k8s $(VAR) expansion syntax"
942 );
943 }
944
945 #[test]
946 fn emitted_env_skips_password_when_url_override_set() {
947 let toml = r#"
948 [database]
949 engine = "postgres"
950
951 [database.dev]
952 shared = true
953 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
954 "#;
955 let raw = toml_to_raw_db(toml);
956 let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
957 let mut env = EmittedEnv::default();
958 env.extend_database(&spec, "identity");
959 assert!(
960 !env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"),
961 "url_override must suppress DATABASE_PASSWORD secret injection"
962 );
963 assert!(
964 env.literals
965 .iter()
966 .any(|(k, v)| k == "DATABASE_URL" && v.contains("shared.svc"))
967 );
968 }
969
970 #[test]
971 fn emitted_env_injects_password_when_no_url_override() {
972 let toml = r#"
973 [database]
974 engine = "postgres"
975 "#;
976 let raw = toml_to_raw_db(toml);
977 let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
978 let mut env = EmittedEnv::default();
979 env.extend_database(&spec, "identity");
980 assert!(env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"));
981 }
982
983 #[test]
984 fn named_database_emits_prefixed_vars() {
985 #[derive(serde::Deserialize)]
986 struct Wrapper {
987 databases: std::collections::BTreeMap<String, RawDatabase>,
988 }
989 let toml = r#"
990 [databases.write]
991 engine = "postgres"
992
993 [databases.write.dev]
994 shared = true
995 url = "postgresql://postgres:postgres@shared.svc:5432/app_dev"
996 "#;
997 let w: Wrapper = toml::from_str(toml).unwrap();
998 let spec = resolve_database(w.databases.get("write").unwrap(), "dev", "app", "agnitiv");
999 let mut env = EmittedEnv::default();
1000 env.extend_database_named("WRITE_DATABASE", &spec, "app");
1001 assert!(env.literals.iter().any(|(k, _)| k == "WRITE_DATABASE_URL"));
1002 assert!(
1003 !env.from_secret
1004 .iter()
1005 .any(|s| s == "WRITE_DATABASE_PASSWORD")
1006 );
1007 }
1008
1009 #[test]
1010 fn named_cache_emits_prefixed_var() {
1011 let toml = r#"
1012 [cache]
1013 engine = "redis"
1014
1015 [cache.dev]
1016 shared = true
1017 url = "redis://redis.shared-dev.svc:6379"
1018 "#;
1019 let raw: RawCache = toml::from_str::<toml::Value>(toml)
1020 .unwrap()
1021 .get("cache")
1022 .unwrap()
1023 .clone()
1024 .try_into()
1025 .unwrap();
1026 let spec = resolve_cache(&raw, "dev", "identity", "agnitiv");
1027 let mut env = EmittedEnv::default();
1028 env.extend_cache_named("SESSION_REDIS", &spec);
1029 assert!(
1030 env.literals
1031 .iter()
1032 .any(|(k, v)| k == "SESSION_REDIS_URL" && v.contains("shared-dev"))
1033 );
1034 }
1035
1036 #[test]
1037 fn env_selection_precedence() {
1038 unsafe { std::env::set_var("TONIN_ENV", "staging") };
1039 assert_eq!(select_env(Some("prod")), "prod");
1040 assert_eq!(select_env(None), "staging");
1041 unsafe { std::env::remove_var("TONIN_ENV") };
1042 assert_eq!(select_env(None), "dev");
1043 }
1044}