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                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
436// ---------- env selection ----------
437
438/// Decide which env we're rendering for. Precedence: explicit arg > env var > "dev".
439pub 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// ---------- emitted env vars ----------
578
579#[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}