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(), ns.clone());
422 }
423 RawCallerEntry::Env(map) if key == env => {
424 overlay = map.clone();
425 }
426 RawCallerEntry::Env(_) => {}
427 }
428 }
429
430 base.extend(overlay);
431 base.into_iter()
432 .map(|(name, namespace)| crate::plan::ServiceRef { name, namespace })
433 .collect()
434}
435
436pub fn select_env(explicit: Option<&str>) -> String {
440 if let Some(e) = explicit {
441 return e.to_string();
442 }
443 std::env::var("TONIN_ENV").unwrap_or_else(|_| "dev".to_string())
444}
445
446pub(crate) fn resolve_database(
447 raw: &RawDatabase,
448 env: &str,
449 service_name: &str,
450 service_namespace: &str,
451) -> DatabaseSpec {
452 let overlay = raw.envs.get(env);
453 let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
454 let engine = DatabaseEngine::parse(
455 overlay
456 .and_then(|o| o.engine.as_deref())
457 .unwrap_or(&raw.engine),
458 );
459 let version = overlay
460 .and_then(|o| o.version.clone())
461 .or_else(|| raw.version.clone())
462 .unwrap_or_else(|| default_db_version(engine));
463 let size = overlay
464 .and_then(|o| o.size.clone())
465 .or_else(|| raw.size.clone())
466 .unwrap_or_else(|| "2Gi".into());
467 let name = overlay
468 .and_then(|o| o.name.clone())
469 .or_else(|| raw.name.clone())
470 .unwrap_or_else(|| format!("{}-db", service_name));
471 let namespace = overlay
472 .and_then(|o| o.namespace.clone())
473 .or_else(|| raw.namespace.clone())
474 .unwrap_or_else(|| service_namespace.to_string());
475 let url_override = overlay.and_then(|o| o.url.clone());
476 DatabaseSpec {
477 engine,
478 version,
479 size,
480 shared,
481 name,
482 namespace,
483 url_override,
484 }
485}
486
487pub(crate) fn resolve_cache(
488 raw: &RawCache,
489 env: &str,
490 service_name: &str,
491 service_namespace: &str,
492) -> CacheSpec {
493 let overlay = raw.envs.get(env);
494 let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
495 let engine = CacheEngine::parse(
496 overlay
497 .and_then(|o| o.engine.as_deref())
498 .unwrap_or(&raw.engine),
499 );
500 let size = overlay
501 .and_then(|o| o.size.clone())
502 .or_else(|| raw.size.clone())
503 .unwrap_or_else(|| "1Gi".into());
504 let name = overlay
505 .and_then(|o| o.name.clone())
506 .or_else(|| raw.name.clone())
507 .unwrap_or_else(|| format!("{}-cache", service_name));
508 let namespace = overlay
509 .and_then(|o| o.namespace.clone())
510 .or_else(|| raw.namespace.clone())
511 .unwrap_or_else(|| service_namespace.to_string());
512 let url_override = overlay.and_then(|o| o.url.clone());
513 CacheSpec {
514 engine,
515 size,
516 shared,
517 name,
518 namespace,
519 url_override,
520 }
521}
522
523pub(crate) fn resolve_secrets(raw: &RawSecrets) -> SecretsSpec {
524 SecretsSpec {
525 provider: SecretProvider::parse(&raw.provider),
526 required: raw.required.clone(),
527 map: raw.map.clone(),
528 external_store: raw.external_store.as_ref().map(|e| ExternalStore {
529 name: e.name.clone(),
530 kind: e.kind.clone(),
531 }),
532 }
533}
534
535pub(crate) fn resolve_migrations(raw: &RawMigrations) -> MigrationsSpec {
536 let tool = match raw.tool.as_str() {
537 "refinery" => MigrationTool::Refinery,
538 "flyway" => MigrationTool::Flyway,
539 "custom" => MigrationTool::Custom,
540 _ => MigrationTool::Sqlx,
541 };
542 let run_on = match raw.run_on.as_str() {
543 "boot" => MigrationRunOn::Boot,
544 "manual" => MigrationRunOn::Manual,
545 _ => MigrationRunOn::InitContainer,
546 };
547 let command = match (tool, &raw.command) {
548 (MigrationTool::Custom, Some(cmd)) => cmd.clone(),
549 (MigrationTool::Sqlx, _) => vec![
550 "sqlx".into(),
551 "migrate".into(),
552 "run".into(),
553 "--source".into(),
554 raw.dir.clone(),
555 ],
556 (MigrationTool::Refinery, _) => vec![
557 "refinery".into(),
558 "migrate".into(),
559 "-p".into(),
560 raw.dir.clone(),
561 ],
562 (MigrationTool::Flyway, _) => vec![
563 "flyway".into(),
564 "-locations=filesystem:".to_string() + &raw.dir,
565 "migrate".into(),
566 ],
567 (MigrationTool::Custom, None) => Vec::new(),
568 };
569 MigrationsSpec {
570 tool,
571 dir: raw.dir.clone(),
572 run_on,
573 command,
574 }
575}
576
577#[derive(Clone, Debug, Default)]
580pub struct EmittedEnv {
581 pub literals: Vec<(String, String)>,
582 pub from_secret: Vec<String>,
583}
584
585impl EmittedEnv {
586 pub fn extend_database(&mut self, spec: &DatabaseSpec, service_name: &str) {
587 self.extend_database_named("DATABASE", spec, service_name);
588 }
589
590 pub fn extend_database_named(
591 &mut self,
592 var_prefix: &str,
593 spec: &DatabaseSpec,
594 service_name: &str,
595 ) {
596 if matches!(spec.engine, DatabaseEngine::None) {
597 return;
598 }
599 self.literals
600 .push((format!("{var_prefix}_URL"), spec.url_template(service_name)));
601 if spec.url_override.is_none() {
602 self.from_secret.push(format!("{var_prefix}_PASSWORD"));
603 }
604 }
605
606 pub fn extend_cache(&mut self, spec: &CacheSpec) {
607 self.extend_cache_named("REDIS", spec);
608 }
609
610 pub fn extend_cache_named(&mut self, var_prefix: &str, spec: &CacheSpec) {
611 if matches!(spec.engine, CacheEngine::None) {
612 return;
613 }
614 self.literals
615 .push((format!("{var_prefix}_URL"), spec.url()));
616 }
617
618 pub fn extend_secrets(&mut self, spec: &SecretsSpec) {
619 for key in &spec.required {
620 self.from_secret.push(key.clone());
621 }
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 fn toml_to_raw_db(s: &str) -> RawDatabase {
630 toml::from_str::<toml::Value>(s)
631 .unwrap()
632 .get("database")
633 .unwrap()
634 .clone()
635 .try_into()
636 .unwrap()
637 }
638
639 #[test]
640 fn db_overlay_dev_wins_over_top_level() {
641 let toml = r#"
642 [database]
643 engine = "postgres"
644 shared = false
645 size = "10Gi"
646
647 [database.dev]
648 shared = true
649 name = "postgres"
650 namespace = "shared-dev"
651 "#;
652 let raw = toml_to_raw_db(toml);
653 let spec = resolve_database(&raw, "dev", "billing", "billing-ns");
654 assert!(spec.shared, "dev overlay forces shared=true");
655 assert_eq!(spec.name, "postgres");
656 assert_eq!(spec.namespace, "shared-dev");
657 assert_eq!(spec.engine, DatabaseEngine::Postgres);
658 assert_eq!(spec.size, "10Gi");
659 }
660
661 #[test]
662 fn db_prod_uses_owned_defaults() {
663 let toml = r#"
664 [database]
665 engine = "postgres"
666 shared = false
667 size = "10Gi"
668
669 [database.dev]
670 shared = true
671 name = "postgres"
672 namespace = "shared-dev"
673 "#;
674 let raw = toml_to_raw_db(toml);
675 let spec = resolve_database(&raw, "prod", "billing", "billing-ns");
676 assert!(!spec.shared);
677 assert_eq!(spec.name, "billing-db");
678 assert_eq!(spec.namespace, "billing-ns");
679 assert_eq!(spec.size, "10Gi");
680 }
681
682 #[test]
683 fn db_unknown_env_falls_back_to_top_level() {
684 let toml = r#"
685 [database]
686 engine = "postgres"
687 "#;
688 let raw = toml_to_raw_db(toml);
689 let spec = resolve_database(&raw, "staging", "audit", "audit");
690 assert!(!spec.shared);
691 assert_eq!(spec.engine, DatabaseEngine::Postgres);
692 }
693
694 #[test]
695 fn db_emits_url_and_password_secret() {
696 let toml = r#"
697 [database]
698 engine = "postgres"
699 shared = false
700 "#;
701 let raw = toml_to_raw_db(toml);
702 let spec = resolve_database(&raw, "prod", "billing", "shop");
703 let mut env = EmittedEnv::default();
704 env.extend_database(&spec, "billing");
705 assert_eq!(env.literals.len(), 1);
706 assert_eq!(env.literals[0].0, "DATABASE_URL");
707 assert!(env.literals[0].1.starts_with(
708 "postgres://billing:$(DATABASE_PASSWORD)@billing-db.shop.svc.cluster.local:5432/billing"
709 ));
710 assert_eq!(env.from_secret, vec!["DATABASE_PASSWORD".to_string()]);
711 }
712
713 #[test]
714 fn cache_shared_overlay() {
715 let toml = r#"
716 [cache]
717 engine = "redis"
718 shared = false
719
720 [cache.dev]
721 shared = true
722 name = "redis"
723 namespace = "shared-dev"
724 "#;
725 let raw: RawCache = toml::from_str::<toml::Value>(toml)
726 .unwrap()
727 .get("cache")
728 .unwrap()
729 .clone()
730 .try_into()
731 .unwrap();
732 let spec = resolve_cache(&raw, "dev", "billing", "shop");
733 assert!(spec.shared);
734 assert_eq!(spec.name, "redis");
735 assert_eq!(spec.namespace, "shared-dev");
736 assert_eq!(
737 spec.url(),
738 "redis://redis.shared-dev.svc.cluster.local:6379"
739 );
740 }
741
742 #[test]
743 fn secrets_default_provider_is_k8s() {
744 let raw = RawSecrets {
745 provider: default_secret_provider(),
746 required: vec!["JWT_SIGNING_KEY".into()],
747 map: Default::default(),
748 external_store: None,
749 };
750 let spec = resolve_secrets(&raw);
751 assert_eq!(spec.provider, SecretProvider::K8s);
752 assert_eq!(spec.required, vec!["JWT_SIGNING_KEY".to_string()]);
753 }
754
755 #[test]
756 fn migrations_sqlx_command_default() {
757 let raw = RawMigrations {
758 tool: "sqlx".into(),
759 dir: default_migrations_dir(),
760 run_on: default_run_on(),
761 command: None,
762 };
763 let spec = resolve_migrations(&raw);
764 assert_eq!(spec.tool, MigrationTool::Sqlx);
765 assert_eq!(spec.run_on, MigrationRunOn::InitContainer);
766 assert_eq!(
767 spec.command,
768 vec!["sqlx", "migrate", "run", "--source", "migrations/"]
769 );
770 }
771
772 #[test]
773 fn migrations_custom_requires_command() {
774 let raw = RawMigrations {
775 tool: "custom".into(),
776 dir: "migrations/".into(),
777 run_on: "init-container".into(),
778 command: Some(vec!["./migrate.sh".into(), "--all".into()]),
779 };
780 let spec = resolve_migrations(&raw);
781 assert_eq!(spec.tool, MigrationTool::Custom);
782 assert_eq!(spec.command, vec!["./migrate.sh", "--all"]);
783 }
784
785 fn parse_callers(toml_str: &str) -> RawCallers {
786 #[derive(serde::Deserialize)]
787 struct Wrapper {
788 callers: RawCallers,
789 }
790 toml::from_str::<Wrapper>(toml_str).unwrap().callers
791 }
792
793 #[test]
794 fn callers_base_only_no_overlay() {
795 let raw = parse_callers(
796 r#"
797 [callers]
798 gateway = "agnitiv"
799 zradar-platform = "agnitiv"
800 "#,
801 );
802 let callers = resolve_callers(&raw, "dev");
803 assert_eq!(callers.len(), 2);
804 assert!(callers.iter().all(|c| c.namespace == "agnitiv"));
805 }
806
807 #[test]
808 fn callers_dev_overlay_overrides_namespace() {
809 let raw = parse_callers(
810 r#"
811 [callers]
812 gateway = "agnitiv"
813 zradar-platform = "agnitiv"
814
815 [callers.dev]
816 gateway = "agnitiv-dev"
817 zradar-platform = "agnitiv-dev"
818 "#,
819 );
820 let dev = resolve_callers(&raw, "dev");
821 assert!(
822 dev.iter().all(|c| c.namespace == "agnitiv-dev"),
823 "dev overlay must win"
824 );
825 let prod = resolve_callers(&raw, "prod");
826 assert!(
827 prod.iter().all(|c| c.namespace == "agnitiv"),
828 "prod falls back to base"
829 );
830 }
831
832 #[test]
833 fn callers_dev_overlay_adds_new_caller() {
834 let raw = parse_callers(
835 r#"
836 [callers]
837 gateway = "agnitiv"
838
839 [callers.dev]
840 gateway = "agnitiv-dev"
841 debug-tool = "agnitiv-dev"
842 "#,
843 );
844 let dev = resolve_callers(&raw, "dev");
845 assert_eq!(dev.len(), 2, "overlay adds debug-tool");
846 let prod = resolve_callers(&raw, "prod");
847 assert_eq!(prod.len(), 1, "prod sees base only");
848 }
849
850 #[test]
851 fn db_dev_url_override_used_verbatim() {
852 let toml = r#"
853 [database]
854 engine = "postgres"
855
856 [database.dev]
857 shared = true
858 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
859 "#;
860 let raw = toml_to_raw_db(toml);
861 let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
862 assert_eq!(
863 spec.url_template("identity"),
864 "postgresql://postgres:postgres@shared.svc:5432/mydb"
865 );
866 assert!(spec.url_override.is_some());
867 }
868
869 #[test]
870 fn db_prod_no_url_override_keeps_template() {
871 let toml = r#"
872 [database]
873 engine = "postgres"
874
875 [database.dev]
876 shared = true
877 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
878 "#;
879 let raw = toml_to_raw_db(toml);
880 let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
881 assert!(spec.url_override.is_none());
882 assert!(
883 spec.url_template("identity")
884 .contains("$(DATABASE_PASSWORD)"),
885 "prod uses password-template URL with k8s $(VAR) expansion syntax"
886 );
887 }
888
889 #[test]
890 fn emitted_env_skips_password_when_url_override_set() {
891 let toml = r#"
892 [database]
893 engine = "postgres"
894
895 [database.dev]
896 shared = true
897 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
898 "#;
899 let raw = toml_to_raw_db(toml);
900 let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
901 let mut env = EmittedEnv::default();
902 env.extend_database(&spec, "identity");
903 assert!(
904 !env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"),
905 "url_override must suppress DATABASE_PASSWORD secret injection"
906 );
907 assert!(
908 env.literals
909 .iter()
910 .any(|(k, v)| k == "DATABASE_URL" && v.contains("shared.svc"))
911 );
912 }
913
914 #[test]
915 fn emitted_env_injects_password_when_no_url_override() {
916 let toml = r#"
917 [database]
918 engine = "postgres"
919 "#;
920 let raw = toml_to_raw_db(toml);
921 let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
922 let mut env = EmittedEnv::default();
923 env.extend_database(&spec, "identity");
924 assert!(env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"));
925 }
926
927 #[test]
928 fn named_database_emits_prefixed_vars() {
929 #[derive(serde::Deserialize)]
930 struct Wrapper {
931 databases: std::collections::BTreeMap<String, RawDatabase>,
932 }
933 let toml = r#"
934 [databases.write]
935 engine = "postgres"
936
937 [databases.write.dev]
938 shared = true
939 url = "postgresql://postgres:postgres@shared.svc:5432/app_dev"
940 "#;
941 let w: Wrapper = toml::from_str(toml).unwrap();
942 let spec = resolve_database(w.databases.get("write").unwrap(), "dev", "app", "agnitiv");
943 let mut env = EmittedEnv::default();
944 env.extend_database_named("WRITE_DATABASE", &spec, "app");
945 assert!(env.literals.iter().any(|(k, _)| k == "WRITE_DATABASE_URL"));
946 assert!(
947 !env.from_secret
948 .iter()
949 .any(|s| s == "WRITE_DATABASE_PASSWORD")
950 );
951 }
952
953 #[test]
954 fn named_cache_emits_prefixed_var() {
955 let toml = r#"
956 [cache]
957 engine = "redis"
958
959 [cache.dev]
960 shared = true
961 url = "redis://redis.shared-dev.svc:6379"
962 "#;
963 let raw: RawCache = toml::from_str::<toml::Value>(toml)
964 .unwrap()
965 .get("cache")
966 .unwrap()
967 .clone()
968 .try_into()
969 .unwrap();
970 let spec = resolve_cache(&raw, "dev", "identity", "agnitiv");
971 let mut env = EmittedEnv::default();
972 env.extend_cache_named("SESSION_REDIS", &spec);
973 assert!(
974 env.literals
975 .iter()
976 .any(|(k, v)| k == "SESSION_REDIS_URL" && v.contains("shared-dev"))
977 );
978 }
979
980 #[test]
981 fn env_selection_precedence() {
982 unsafe { std::env::set_var("TONIN_ENV", "staging") };
983 assert_eq!(select_env(Some("prod")), "prod");
984 assert_eq!(select_env(None), "staging");
985 unsafe { std::env::remove_var("TONIN_ENV") };
986 assert_eq!(select_env(None), "dev");
987 }
988}