Skip to main content

tonin_plugin/
stateful.rs

1//! Stateful dependencies (Phase 1 of stateful-deps design).
2//!
3//! Loads `[database]`, `[cache]`, `[secrets]`, `[migrations]` from
4//! `tonin.toml`, applies `[database.dev]` / `[database.prod]` env-overlay,
5//! and normalizes into types the renderer will consume.
6
7use serde::{Deserialize, Serialize};
8
9// ---------- on-disk TOML shape ----------
10
11#[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// ---------- normalized types (what the renderer consumes) ----------
141
142#[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        // `$(DATABASE_PASSWORD)` is the Kubernetes dependent-env syntax: the
249        // kubelet expands it from the DATABASE_PASSWORD env var (sourced from the
250        // Secret) provided that var is declared earlier in the container's env
251        // list. Bare `$VAR` is NOT expanded by Kubernetes.
252        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// ---------- [callers] with per-env overlay ----------
402
403#[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                // {env} is substituted so `gateway = "agnitiv-{env}"` resolves
422                // to "agnitiv-dev", "agnitiv-prod", etc. per environment.
423                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
441// ---------- env selection ----------
442
443/// Decide which env we're rendering for. Precedence: explicit arg > env var > "dev".
444pub 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// ---------- emitted env vars ----------
583
584#[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        // {env} in base; explicit [callers.prod] overrides just prod.
884        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}