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!(
249 "{}://{svc}:$DATABASE_PASSWORD@{host}:{port}/{svc}",
250 self.engine.as_str(),
251 svc = service_name,
252 host = self.host(),
253 port = self.port(),
254 )
255 }
256}
257
258#[derive(Clone, Debug)]
259pub struct CacheSpec {
260 pub engine: CacheEngine,
261 pub size: String,
262 pub shared: bool,
263 pub name: String,
264 pub namespace: String,
265 pub url_override: Option<String>,
266}
267
268impl CacheSpec {
269 pub fn host(&self) -> String {
270 format!("{}.{}.svc.cluster.local", self.name, self.namespace)
271 }
272 pub fn port(&self) -> u32 {
273 self.engine.default_port()
274 }
275 pub fn url(&self) -> String {
276 if let Some(ref url) = self.url_override {
277 return url.clone();
278 }
279 format!("redis://{}:{}", self.host(), self.port())
280 }
281}
282
283#[derive(Clone, Debug)]
284pub struct SecretsSpec {
285 pub provider: SecretProvider,
286 pub required: Vec<String>,
287 pub map: std::collections::BTreeMap<String, String>,
288 pub external_store: Option<ExternalStore>,
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq)]
292pub enum SecretProvider {
293 K8s,
294 ExternalSecrets,
295 Vault,
296 AwsSecretsManager,
297}
298
299impl SecretProvider {
300 pub fn parse(s: &str) -> Self {
301 match s {
302 "external-secrets" => Self::ExternalSecrets,
303 "vault" => Self::Vault,
304 "aws-secrets-manager" => Self::AwsSecretsManager,
305 _ => Self::K8s,
306 }
307 }
308 pub fn as_str(&self) -> &'static str {
309 match self {
310 Self::K8s => "k8s",
311 Self::ExternalSecrets => "external-secrets",
312 Self::Vault => "vault",
313 Self::AwsSecretsManager => "aws-secrets-manager",
314 }
315 }
316}
317
318#[derive(Clone, Debug)]
319pub struct ExternalStore {
320 pub name: String,
321 pub kind: String,
322}
323
324#[derive(Clone, Debug)]
325pub struct ConfigSpec {
326 pub engine: ConfigEngine,
327 pub path_prefix: Option<String>,
328 pub poll_interval_seconds: u64,
329 pub endpoints: Vec<String>,
330 pub repo: Option<String>,
331 pub git_ref: Option<String>,
332 pub sources: Vec<ConfigEngine>,
333}
334
335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336pub enum ConfigEngine {
337 Env,
338 Etcd,
339 Github,
340 Chained,
341}
342
343impl ConfigEngine {
344 pub fn parse(s: &str) -> Self {
345 match s {
346 "etcd" => Self::Etcd,
347 "github" => Self::Github,
348 "chained" => Self::Chained,
349 _ => Self::Env,
350 }
351 }
352 pub fn as_str(&self) -> &'static str {
353 match self {
354 Self::Env => "env",
355 Self::Etcd => "etcd",
356 Self::Github => "github",
357 Self::Chained => "chained",
358 }
359 }
360}
361
362pub(crate) fn resolve_config(raw: &RawConfigBlock) -> ConfigSpec {
363 ConfigSpec {
364 engine: ConfigEngine::parse(&raw.engine),
365 path_prefix: raw.path_prefix.clone(),
366 poll_interval_seconds: raw.poll_interval_seconds.unwrap_or(30),
367 endpoints: raw.endpoints.clone(),
368 repo: raw.repo.clone(),
369 git_ref: raw.git_ref.clone(),
370 sources: raw.sources.iter().map(|s| ConfigEngine::parse(s)).collect(),
371 }
372}
373
374#[derive(Clone, Debug)]
375pub struct MigrationsSpec {
376 pub tool: MigrationTool,
377 pub dir: String,
378 pub run_on: MigrationRunOn,
379 pub command: Vec<String>,
380}
381
382#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383pub enum MigrationTool {
384 Sqlx,
385 Refinery,
386 Flyway,
387 Custom,
388}
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub enum MigrationRunOn {
392 InitContainer,
393 Boot,
394 Manual,
395}
396
397#[derive(Debug, Deserialize, Clone)]
400#[serde(untagged)]
401pub(crate) enum RawCallerEntry {
402 Namespace(String),
403 Env(std::collections::BTreeMap<String, String>),
404}
405
406#[derive(Debug, Default, Deserialize, Clone)]
407#[serde(transparent)]
408pub(crate) struct RawCallers(pub std::collections::BTreeMap<String, RawCallerEntry>);
409
410pub(crate) fn resolve_callers(raw: &RawCallers, env: &str) -> Vec<crate::plan::ServiceRef> {
411 let mut base = std::collections::BTreeMap::new();
412 let mut overlay = std::collections::BTreeMap::new();
413
414 for (key, entry) in &raw.0 {
415 match entry {
416 RawCallerEntry::Namespace(ns) => {
417 base.insert(key.clone(), ns.clone());
418 }
419 RawCallerEntry::Env(map) if key == env => {
420 overlay = map.clone();
421 }
422 RawCallerEntry::Env(_) => {}
423 }
424 }
425
426 base.extend(overlay);
427 base.into_iter()
428 .map(|(name, namespace)| crate::plan::ServiceRef { name, namespace })
429 .collect()
430}
431
432pub fn select_env(explicit: Option<&str>) -> String {
436 if let Some(e) = explicit {
437 return e.to_string();
438 }
439 std::env::var("TONIN_ENV").unwrap_or_else(|_| "dev".to_string())
440}
441
442pub(crate) fn resolve_database(
443 raw: &RawDatabase,
444 env: &str,
445 service_name: &str,
446 service_namespace: &str,
447) -> DatabaseSpec {
448 let overlay = raw.envs.get(env);
449 let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
450 let engine = DatabaseEngine::parse(
451 overlay
452 .and_then(|o| o.engine.as_deref())
453 .unwrap_or(&raw.engine),
454 );
455 let version = overlay
456 .and_then(|o| o.version.clone())
457 .or_else(|| raw.version.clone())
458 .unwrap_or_else(|| default_db_version(engine));
459 let size = overlay
460 .and_then(|o| o.size.clone())
461 .or_else(|| raw.size.clone())
462 .unwrap_or_else(|| "2Gi".into());
463 let name = overlay
464 .and_then(|o| o.name.clone())
465 .or_else(|| raw.name.clone())
466 .unwrap_or_else(|| format!("{}-db", service_name));
467 let namespace = overlay
468 .and_then(|o| o.namespace.clone())
469 .or_else(|| raw.namespace.clone())
470 .unwrap_or_else(|| service_namespace.to_string());
471 let url_override = overlay.and_then(|o| o.url.clone());
472 DatabaseSpec {
473 engine,
474 version,
475 size,
476 shared,
477 name,
478 namespace,
479 url_override,
480 }
481}
482
483pub(crate) fn resolve_cache(
484 raw: &RawCache,
485 env: &str,
486 service_name: &str,
487 service_namespace: &str,
488) -> CacheSpec {
489 let overlay = raw.envs.get(env);
490 let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
491 let engine = CacheEngine::parse(
492 overlay
493 .and_then(|o| o.engine.as_deref())
494 .unwrap_or(&raw.engine),
495 );
496 let size = overlay
497 .and_then(|o| o.size.clone())
498 .or_else(|| raw.size.clone())
499 .unwrap_or_else(|| "1Gi".into());
500 let name = overlay
501 .and_then(|o| o.name.clone())
502 .or_else(|| raw.name.clone())
503 .unwrap_or_else(|| format!("{}-cache", service_name));
504 let namespace = overlay
505 .and_then(|o| o.namespace.clone())
506 .or_else(|| raw.namespace.clone())
507 .unwrap_or_else(|| service_namespace.to_string());
508 let url_override = overlay.and_then(|o| o.url.clone());
509 CacheSpec {
510 engine,
511 size,
512 shared,
513 name,
514 namespace,
515 url_override,
516 }
517}
518
519pub(crate) fn resolve_secrets(raw: &RawSecrets) -> SecretsSpec {
520 SecretsSpec {
521 provider: SecretProvider::parse(&raw.provider),
522 required: raw.required.clone(),
523 map: raw.map.clone(),
524 external_store: raw.external_store.as_ref().map(|e| ExternalStore {
525 name: e.name.clone(),
526 kind: e.kind.clone(),
527 }),
528 }
529}
530
531pub(crate) fn resolve_migrations(raw: &RawMigrations) -> MigrationsSpec {
532 let tool = match raw.tool.as_str() {
533 "refinery" => MigrationTool::Refinery,
534 "flyway" => MigrationTool::Flyway,
535 "custom" => MigrationTool::Custom,
536 _ => MigrationTool::Sqlx,
537 };
538 let run_on = match raw.run_on.as_str() {
539 "boot" => MigrationRunOn::Boot,
540 "manual" => MigrationRunOn::Manual,
541 _ => MigrationRunOn::InitContainer,
542 };
543 let command = match (tool, &raw.command) {
544 (MigrationTool::Custom, Some(cmd)) => cmd.clone(),
545 (MigrationTool::Sqlx, _) => vec![
546 "sqlx".into(),
547 "migrate".into(),
548 "run".into(),
549 "--source".into(),
550 raw.dir.clone(),
551 ],
552 (MigrationTool::Refinery, _) => vec![
553 "refinery".into(),
554 "migrate".into(),
555 "-p".into(),
556 raw.dir.clone(),
557 ],
558 (MigrationTool::Flyway, _) => vec![
559 "flyway".into(),
560 "-locations=filesystem:".to_string() + &raw.dir,
561 "migrate".into(),
562 ],
563 (MigrationTool::Custom, None) => Vec::new(),
564 };
565 MigrationsSpec {
566 tool,
567 dir: raw.dir.clone(),
568 run_on,
569 command,
570 }
571}
572
573#[derive(Clone, Debug, Default)]
576pub struct EmittedEnv {
577 pub literals: Vec<(String, String)>,
578 pub from_secret: Vec<String>,
579}
580
581impl EmittedEnv {
582 pub fn extend_database(&mut self, spec: &DatabaseSpec, service_name: &str) {
583 self.extend_database_named("DATABASE", spec, service_name);
584 }
585
586 pub fn extend_database_named(
587 &mut self,
588 var_prefix: &str,
589 spec: &DatabaseSpec,
590 service_name: &str,
591 ) {
592 if matches!(spec.engine, DatabaseEngine::None) {
593 return;
594 }
595 self.literals
596 .push((format!("{var_prefix}_URL"), spec.url_template(service_name)));
597 if spec.url_override.is_none() {
598 self.from_secret.push(format!("{var_prefix}_PASSWORD"));
599 }
600 }
601
602 pub fn extend_cache(&mut self, spec: &CacheSpec) {
603 self.extend_cache_named("REDIS", spec);
604 }
605
606 pub fn extend_cache_named(&mut self, var_prefix: &str, spec: &CacheSpec) {
607 if matches!(spec.engine, CacheEngine::None) {
608 return;
609 }
610 self.literals
611 .push((format!("{var_prefix}_URL"), spec.url()));
612 }
613
614 pub fn extend_secrets(&mut self, spec: &SecretsSpec) {
615 for key in &spec.required {
616 self.from_secret.push(key.clone());
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624
625 fn toml_to_raw_db(s: &str) -> RawDatabase {
626 toml::from_str::<toml::Value>(s)
627 .unwrap()
628 .get("database")
629 .unwrap()
630 .clone()
631 .try_into()
632 .unwrap()
633 }
634
635 #[test]
636 fn db_overlay_dev_wins_over_top_level() {
637 let toml = r#"
638 [database]
639 engine = "postgres"
640 shared = false
641 size = "10Gi"
642
643 [database.dev]
644 shared = true
645 name = "postgres"
646 namespace = "shared-dev"
647 "#;
648 let raw = toml_to_raw_db(toml);
649 let spec = resolve_database(&raw, "dev", "billing", "billing-ns");
650 assert!(spec.shared, "dev overlay forces shared=true");
651 assert_eq!(spec.name, "postgres");
652 assert_eq!(spec.namespace, "shared-dev");
653 assert_eq!(spec.engine, DatabaseEngine::Postgres);
654 assert_eq!(spec.size, "10Gi");
655 }
656
657 #[test]
658 fn db_prod_uses_owned_defaults() {
659 let toml = r#"
660 [database]
661 engine = "postgres"
662 shared = false
663 size = "10Gi"
664
665 [database.dev]
666 shared = true
667 name = "postgres"
668 namespace = "shared-dev"
669 "#;
670 let raw = toml_to_raw_db(toml);
671 let spec = resolve_database(&raw, "prod", "billing", "billing-ns");
672 assert!(!spec.shared);
673 assert_eq!(spec.name, "billing-db");
674 assert_eq!(spec.namespace, "billing-ns");
675 assert_eq!(spec.size, "10Gi");
676 }
677
678 #[test]
679 fn db_unknown_env_falls_back_to_top_level() {
680 let toml = r#"
681 [database]
682 engine = "postgres"
683 "#;
684 let raw = toml_to_raw_db(toml);
685 let spec = resolve_database(&raw, "staging", "audit", "audit");
686 assert!(!spec.shared);
687 assert_eq!(spec.engine, DatabaseEngine::Postgres);
688 }
689
690 #[test]
691 fn db_emits_url_and_password_secret() {
692 let toml = r#"
693 [database]
694 engine = "postgres"
695 shared = false
696 "#;
697 let raw = toml_to_raw_db(toml);
698 let spec = resolve_database(&raw, "prod", "billing", "shop");
699 let mut env = EmittedEnv::default();
700 env.extend_database(&spec, "billing");
701 assert_eq!(env.literals.len(), 1);
702 assert_eq!(env.literals[0].0, "DATABASE_URL");
703 assert!(env.literals[0].1.starts_with(
704 "postgres://billing:$DATABASE_PASSWORD@billing-db.shop.svc.cluster.local:5432/billing"
705 ));
706 assert_eq!(env.from_secret, vec!["DATABASE_PASSWORD".to_string()]);
707 }
708
709 #[test]
710 fn cache_shared_overlay() {
711 let toml = r#"
712 [cache]
713 engine = "redis"
714 shared = false
715
716 [cache.dev]
717 shared = true
718 name = "redis"
719 namespace = "shared-dev"
720 "#;
721 let raw: RawCache = toml::from_str::<toml::Value>(toml)
722 .unwrap()
723 .get("cache")
724 .unwrap()
725 .clone()
726 .try_into()
727 .unwrap();
728 let spec = resolve_cache(&raw, "dev", "billing", "shop");
729 assert!(spec.shared);
730 assert_eq!(spec.name, "redis");
731 assert_eq!(spec.namespace, "shared-dev");
732 assert_eq!(
733 spec.url(),
734 "redis://redis.shared-dev.svc.cluster.local:6379"
735 );
736 }
737
738 #[test]
739 fn secrets_default_provider_is_k8s() {
740 let raw = RawSecrets {
741 provider: default_secret_provider(),
742 required: vec!["JWT_SIGNING_KEY".into()],
743 map: Default::default(),
744 external_store: None,
745 };
746 let spec = resolve_secrets(&raw);
747 assert_eq!(spec.provider, SecretProvider::K8s);
748 assert_eq!(spec.required, vec!["JWT_SIGNING_KEY".to_string()]);
749 }
750
751 #[test]
752 fn migrations_sqlx_command_default() {
753 let raw = RawMigrations {
754 tool: "sqlx".into(),
755 dir: default_migrations_dir(),
756 run_on: default_run_on(),
757 command: None,
758 };
759 let spec = resolve_migrations(&raw);
760 assert_eq!(spec.tool, MigrationTool::Sqlx);
761 assert_eq!(spec.run_on, MigrationRunOn::InitContainer);
762 assert_eq!(
763 spec.command,
764 vec!["sqlx", "migrate", "run", "--source", "migrations/"]
765 );
766 }
767
768 #[test]
769 fn migrations_custom_requires_command() {
770 let raw = RawMigrations {
771 tool: "custom".into(),
772 dir: "migrations/".into(),
773 run_on: "init-container".into(),
774 command: Some(vec!["./migrate.sh".into(), "--all".into()]),
775 };
776 let spec = resolve_migrations(&raw);
777 assert_eq!(spec.tool, MigrationTool::Custom);
778 assert_eq!(spec.command, vec!["./migrate.sh", "--all"]);
779 }
780
781 fn parse_callers(toml_str: &str) -> RawCallers {
782 #[derive(serde::Deserialize)]
783 struct Wrapper {
784 callers: RawCallers,
785 }
786 toml::from_str::<Wrapper>(toml_str).unwrap().callers
787 }
788
789 #[test]
790 fn callers_base_only_no_overlay() {
791 let raw = parse_callers(
792 r#"
793 [callers]
794 gateway = "agnitiv"
795 zradar-platform = "agnitiv"
796 "#,
797 );
798 let callers = resolve_callers(&raw, "dev");
799 assert_eq!(callers.len(), 2);
800 assert!(callers.iter().all(|c| c.namespace == "agnitiv"));
801 }
802
803 #[test]
804 fn callers_dev_overlay_overrides_namespace() {
805 let raw = parse_callers(
806 r#"
807 [callers]
808 gateway = "agnitiv"
809 zradar-platform = "agnitiv"
810
811 [callers.dev]
812 gateway = "agnitiv-dev"
813 zradar-platform = "agnitiv-dev"
814 "#,
815 );
816 let dev = resolve_callers(&raw, "dev");
817 assert!(
818 dev.iter().all(|c| c.namespace == "agnitiv-dev"),
819 "dev overlay must win"
820 );
821 let prod = resolve_callers(&raw, "prod");
822 assert!(
823 prod.iter().all(|c| c.namespace == "agnitiv"),
824 "prod falls back to base"
825 );
826 }
827
828 #[test]
829 fn callers_dev_overlay_adds_new_caller() {
830 let raw = parse_callers(
831 r#"
832 [callers]
833 gateway = "agnitiv"
834
835 [callers.dev]
836 gateway = "agnitiv-dev"
837 debug-tool = "agnitiv-dev"
838 "#,
839 );
840 let dev = resolve_callers(&raw, "dev");
841 assert_eq!(dev.len(), 2, "overlay adds debug-tool");
842 let prod = resolve_callers(&raw, "prod");
843 assert_eq!(prod.len(), 1, "prod sees base only");
844 }
845
846 #[test]
847 fn db_dev_url_override_used_verbatim() {
848 let toml = r#"
849 [database]
850 engine = "postgres"
851
852 [database.dev]
853 shared = true
854 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
855 "#;
856 let raw = toml_to_raw_db(toml);
857 let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
858 assert_eq!(
859 spec.url_template("identity"),
860 "postgresql://postgres:postgres@shared.svc:5432/mydb"
861 );
862 assert!(spec.url_override.is_some());
863 }
864
865 #[test]
866 fn db_prod_no_url_override_keeps_template() {
867 let toml = r#"
868 [database]
869 engine = "postgres"
870
871 [database.dev]
872 shared = true
873 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
874 "#;
875 let raw = toml_to_raw_db(toml);
876 let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
877 assert!(spec.url_override.is_none());
878 assert!(
879 spec.url_template("identity").contains("$DATABASE_PASSWORD"),
880 "prod uses password-template URL"
881 );
882 }
883
884 #[test]
885 fn emitted_env_skips_password_when_url_override_set() {
886 let toml = r#"
887 [database]
888 engine = "postgres"
889
890 [database.dev]
891 shared = true
892 url = "postgresql://postgres:postgres@shared.svc:5432/mydb"
893 "#;
894 let raw = toml_to_raw_db(toml);
895 let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
896 let mut env = EmittedEnv::default();
897 env.extend_database(&spec, "identity");
898 assert!(
899 !env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"),
900 "url_override must suppress DATABASE_PASSWORD secret injection"
901 );
902 assert!(
903 env.literals
904 .iter()
905 .any(|(k, v)| k == "DATABASE_URL" && v.contains("shared.svc"))
906 );
907 }
908
909 #[test]
910 fn emitted_env_injects_password_when_no_url_override() {
911 let toml = r#"
912 [database]
913 engine = "postgres"
914 "#;
915 let raw = toml_to_raw_db(toml);
916 let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
917 let mut env = EmittedEnv::default();
918 env.extend_database(&spec, "identity");
919 assert!(env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"));
920 }
921
922 #[test]
923 fn named_database_emits_prefixed_vars() {
924 #[derive(serde::Deserialize)]
925 struct Wrapper {
926 databases: std::collections::BTreeMap<String, RawDatabase>,
927 }
928 let toml = r#"
929 [databases.write]
930 engine = "postgres"
931
932 [databases.write.dev]
933 shared = true
934 url = "postgresql://postgres:postgres@shared.svc:5432/app_dev"
935 "#;
936 let w: Wrapper = toml::from_str(toml).unwrap();
937 let spec = resolve_database(w.databases.get("write").unwrap(), "dev", "app", "agnitiv");
938 let mut env = EmittedEnv::default();
939 env.extend_database_named("WRITE_DATABASE", &spec, "app");
940 assert!(env.literals.iter().any(|(k, _)| k == "WRITE_DATABASE_URL"));
941 assert!(
942 !env.from_secret
943 .iter()
944 .any(|s| s == "WRITE_DATABASE_PASSWORD")
945 );
946 }
947
948 #[test]
949 fn named_cache_emits_prefixed_var() {
950 let toml = r#"
951 [cache]
952 engine = "redis"
953
954 [cache.dev]
955 shared = true
956 url = "redis://redis.shared-dev.svc:6379"
957 "#;
958 let raw: RawCache = toml::from_str::<toml::Value>(toml)
959 .unwrap()
960 .get("cache")
961 .unwrap()
962 .clone()
963 .try_into()
964 .unwrap();
965 let spec = resolve_cache(&raw, "dev", "identity", "agnitiv");
966 let mut env = EmittedEnv::default();
967 env.extend_cache_named("SESSION_REDIS", &spec);
968 assert!(
969 env.literals
970 .iter()
971 .any(|(k, v)| k == "SESSION_REDIS_URL" && v.contains("shared-dev"))
972 );
973 }
974
975 #[test]
976 fn env_selection_precedence() {
977 unsafe { std::env::set_var("TONIN_ENV", "staging") };
978 assert_eq!(select_env(Some("prod")), "prod");
979 assert_eq!(select_env(None), "staging");
980 unsafe { std::env::remove_var("TONIN_ENV") };
981 assert_eq!(select_env(None), "dev");
982 }
983}