Skip to main content

tonin_plugin/
plan.rs

1//! Plan: typed deployment description loaded from `tonin.toml`.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::stateful::{
9    self, CacheSpec, ConfigSpec, DatabaseSpec, EmittedEnv, MigrationsSpec, RawCache, RawCallers,
10    RawConfigBlock, RawDatabase, RawMigrations, RawSecrets, SecretsSpec,
11};
12
13#[derive(Debug, thiserror::Error)]
14pub enum Error {
15    #[error("reading {0}: {1}")]
16    Io(PathBuf, #[source] std::io::Error),
17    #[error("parsing {0}: {1}")]
18    Toml(PathBuf, #[source] toml::de::Error),
19    #[error(
20        "{path}: schema = {found:?} is not supported by this CLI. \
21         Supported schemas: {supported:?}. \
22         Upgrade the CLI, or set `schema = \"{current}\"` at the top of tonin.toml."
23    )]
24    UnsupportedSchema {
25        path: PathBuf,
26        found: String,
27        supported: Vec<String>,
28        current: String,
29    },
30    #[error("depends_on.{name}: {reason}")]
31    InvalidDependency { name: String, reason: String },
32    #[error(
33        "{context}: namespace {value:?} has an unresolved placeholder \
34         (only `{{env}}` is supported)"
35    )]
36    UnresolvedNamespace { context: String, value: String },
37}
38
39#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "lowercase")]
41pub enum Mesh {
42    #[default]
43    Cilium,
44    Istio,
45    Linkerd,
46    None,
47}
48
49impl Mesh {
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Mesh::Cilium => "cilium",
53            Mesh::Istio => "istio",
54            Mesh::Linkerd => "linkerd",
55            Mesh::None => "none",
56        }
57    }
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
61pub struct ServiceRef {
62    pub name: String,
63    pub namespace: String,
64}
65
66impl ServiceRef {
67    pub fn identity(&self) -> String {
68        format!("{}.{}", self.name, self.namespace)
69    }
70}
71
72/// One `[depends_on]` entry parsed from TOML, before environment resolution.
73///
74/// Both the shorthand (`name = "<ns>"`) and the table form
75/// (`name = { namespace = "<ns>", <env> = "<ns>", envs = [..] }`) land here.
76struct DepSpec {
77    /// Default namespace pattern (may contain `{env}`), if declared.
78    namespace: Option<String>,
79    /// Per-env namespace overrides (env name → pattern).
80    env_overrides: BTreeMap<String, String>,
81    /// Restrict the dependency to these envs; `None` ⇒ every env.
82    envs: Option<Vec<String>>,
83}
84
85/// Substitute the `{env}` placeholder in a namespace pattern.
86pub(crate) fn apply_env(pattern: &str, env: &str) -> String {
87    pattern.replace("{env}", env)
88}
89
90/// Reject a namespace that still carries an unresolved `{...}` placeholder.
91/// `{env}` is the only supported token, so anything left over is a typo.
92fn ensure_resolved(context: &str, value: &str) -> Result<(), Error> {
93    if value.contains('{') || value.contains('}') {
94        return Err(Error::UnresolvedNamespace {
95            context: context.to_string(),
96            value: value.to_string(),
97        });
98    }
99    Ok(())
100}
101
102/// Parse a single `[depends_on]` entry (string shorthand or table form).
103fn parse_dependency(name: &str, value: toml::Value) -> Result<DepSpec, Error> {
104    let invalid = |reason: String| Error::InvalidDependency {
105        name: name.to_string(),
106        reason,
107    };
108    match value {
109        toml::Value::String(s) => Ok(DepSpec {
110            namespace: Some(s),
111            env_overrides: BTreeMap::new(),
112            envs: None,
113        }),
114        toml::Value::Table(table) => {
115            let mut namespace = None;
116            let mut envs = None;
117            let mut env_overrides = BTreeMap::new();
118            for (key, val) in table {
119                match key.as_str() {
120                    "namespace" => {
121                        namespace = Some(
122                            val.as_str()
123                                .ok_or_else(|| invalid("`namespace` must be a string".into()))?
124                                .to_string(),
125                        );
126                    }
127                    "envs" => {
128                        let arr = val
129                            .as_array()
130                            .ok_or_else(|| invalid("`envs` must be an array of strings".into()))?;
131                        let mut list = Vec::with_capacity(arr.len());
132                        for item in arr {
133                            list.push(
134                                item.as_str()
135                                    .ok_or_else(|| {
136                                        invalid("`envs` must be an array of strings".into())
137                                    })?
138                                    .to_string(),
139                            );
140                        }
141                        envs = Some(list);
142                    }
143                    // Any other key is a per-env namespace override (`prod = "..."`).
144                    other => {
145                        let ns = val.as_str().ok_or_else(|| {
146                            invalid(format!("override `{other}` must be a namespace string"))
147                        })?;
148                        env_overrides.insert(other.to_string(), ns.to_string());
149                    }
150                }
151            }
152            Ok(DepSpec {
153                namespace,
154                env_overrides,
155                envs,
156            })
157        }
158        other => Err(invalid(format!(
159            "expected a namespace string or a table, got {}",
160            other.type_str()
161        ))),
162    }
163}
164
165/// Resolve `[depends_on]` for one environment into concrete egress targets.
166///
167/// - A dependency whose `envs` whitelist excludes `env` is dropped.
168/// - The namespace is the per-env override if present, else the default,
169///   with `{env}` substituted.
170/// - `@inherit` drops the entry from rendered output (the namespace is
171///   supplied at deploy time / by GitOps).
172/// - An active dependency with no resolvable namespace — or one left with an
173///   unresolved placeholder — is a hard error. There is no silent fallback to
174///   a base value, which is what let a dev namespace leak into prod before.
175fn resolve_depends_on(
176    raw: BTreeMap<String, toml::Value>,
177    env: &str,
178) -> Result<Vec<ServiceRef>, Error> {
179    let mut out = Vec::new();
180    for (name, value) in raw {
181        let spec = parse_dependency(&name, value)?;
182        if let Some(envs) = &spec.envs
183            && !envs.iter().any(|e| e == env)
184        {
185            continue; // not active in this environment
186        }
187        let Some(pattern) = spec.env_overrides.get(env).or(spec.namespace.as_ref()) else {
188            return Err(Error::InvalidDependency {
189                name: name.clone(),
190                reason: format!(
191                    "has no namespace for env '{env}' \
192                     (set {name}.{env}, use \"{{env}}\", or mark \"@inherit\")"
193                ),
194            });
195        };
196        let resolved = apply_env(pattern, env);
197        if resolved == "@inherit" {
198            continue; // namespace owned by the deploy layer; omit egress entry
199        }
200        ensure_resolved(&format!("depends_on.{name}"), &resolved)?;
201        if resolved.is_empty() {
202            return Err(Error::InvalidDependency {
203                name: name.clone(),
204                reason: format!("namespace for env '{env}' is empty"),
205            });
206        }
207        out.push(ServiceRef {
208            name,
209            namespace: resolved,
210        });
211    }
212    Ok(out)
213}
214
215// ---------- on-disk TOML shape ----------
216
217/// The schema version this CLI knows how to read.
218pub const CURRENT_SCHEMA: &str = "v1";
219pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
220
221/// Minimum `tonin` CLI version that can fully render all `tonin.toml`
222/// features exposed by this version of `tonin-plugin`.
223///
224/// The CLI checks this at `tonin k8s generate` / `tonin helm generate` time
225/// and emits a warning (never an error) when it is older. Services continue
226/// to work — the check is advisory so teams can upgrade at their own pace.
227///
228/// Bump this constant (in the same commit) whenever a new `tonin.toml`
229/// section or field is added that older CLI versions would silently ignore.
230///
231/// 0.6.0: per-environment namespaces and dependencies (`{env}` placeholders
232/// and the Cargo-style `[depends_on]` table form) — a CLI older than this
233/// can't render them.
234pub const RECOMMENDED_CLI_MIN: &str = "0.6.0";
235
236#[derive(Debug, Deserialize)]
237struct RawConfig {
238    #[serde(default)]
239    schema: Option<String>,
240    service: RawService,
241    deploy: RawDeploy,
242    resources: RawResources,
243    #[serde(default)]
244    autoscale: Option<RawAutoscale>,
245    // String shorthand (`name = "<ns>"`) or table form
246    // (`name = { namespace = "<ns>", <env> = "<ns>", envs = [..] }`).
247    // Parsed as raw values and interpreted by `resolve_depends_on`.
248    #[serde(default)]
249    depends_on: BTreeMap<String, toml::Value>,
250    #[serde(default)]
251    callers: RawCallers,
252    #[serde(default)]
253    database: Option<RawDatabase>,
254    #[serde(default)]
255    databases: std::collections::BTreeMap<String, RawDatabase>,
256    #[serde(default)]
257    cache: Option<RawCache>,
258    #[serde(default)]
259    caches: std::collections::BTreeMap<String, RawCache>,
260    #[serde(default)]
261    secrets: Option<RawSecrets>,
262    #[serde(default)]
263    migrations: Option<RawMigrations>,
264    #[serde(default)]
265    config: Option<RawConfigBlock>,
266    #[serde(default)]
267    client: Option<RawClientConfig>,
268}
269
270#[derive(Debug, Deserialize)]
271struct RawService {
272    name: String,
273    version: String,
274    #[serde(default)]
275    language: Option<String>,
276    #[serde(default, rename = "type")]
277    kind: Option<String>,
278    #[serde(default)]
279    web_mode: Option<String>,
280    #[serde(default)]
281    #[allow(dead_code)]
282    codec: Option<String>,
283    /// Explicit listen port. Optional — defaults per kind when unset
284    /// (web: 8080/3000 by mode, http: 8080, gRPC backend: 50051).
285    #[serde(default)]
286    port: Option<u32>,
287    /// HTTP health-probe config (`[service.health]`).
288    #[serde(default)]
289    health: Option<RawHealth>,
290    /// Additional HTTP endpoint (`[service.http]`). Lets a gRPC `backend` ALSO
291    /// serve HTTP (health/metrics/admin) — the two are not mutually exclusive.
292    #[serde(default)]
293    http: Option<RawHttpEndpoint>,
294}
295
296#[derive(Debug, Deserialize)]
297struct RawHealth {
298    #[serde(default)]
299    path: Option<String>,
300    #[serde(default)]
301    port: Option<u32>,
302}
303
304#[derive(Debug, Deserialize)]
305struct RawHttpEndpoint {
306    port: u32,
307    #[serde(default)]
308    health_path: Option<String>,
309}
310
311#[derive(Debug, Deserialize)]
312struct RawDeploy {
313    replicas: u32,
314    #[serde(default)]
315    mesh: Option<Mesh>,
316    #[serde(default = "default_true")]
317    mcp_sidecar: bool,
318    namespace: String,
319    #[serde(default)]
320    expose: Option<String>,
321    #[serde(default, flatten)]
322    envs: std::collections::BTreeMap<String, RawDeployEnv>,
323}
324
325#[derive(Debug, Deserialize, Default)]
326struct RawDeployEnv {
327    #[serde(default)]
328    replicas: Option<u32>,
329    #[serde(default)]
330    namespace: Option<String>,
331    #[serde(default)]
332    mesh: Option<Mesh>,
333    #[serde(default)]
334    mcp_sidecar: Option<bool>,
335    #[serde(default)]
336    expose: Option<String>,
337}
338
339#[derive(Debug, Deserialize)]
340struct RawResources {
341    cpu: String,
342    memory: String,
343}
344
345#[derive(Debug, Deserialize)]
346struct RawAutoscale {
347    max_replicas: u32,
348}
349
350fn default_true() -> bool {
351    true
352}
353
354#[derive(Debug, Default, Deserialize)]
355struct RawClientConfig {
356    #[serde(default = "default_true")]
357    coalesce: bool,
358    #[serde(default)]
359    cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
360}
361
362#[derive(Debug, Deserialize)]
363struct RawMethodCacheConfig {
364    ttl_ms: u64,
365    #[serde(default = "default_cache_capacity")]
366    capacity: usize,
367}
368
369fn default_cache_capacity() -> usize {
370    1_000
371}
372
373#[derive(Clone, Debug, Serialize)]
374pub struct MethodCacheSpec {
375    pub ttl_ms: u64,
376    pub capacity: usize,
377}
378
379#[derive(Clone, Debug, Serialize)]
380pub struct ClientSpec {
381    pub coalesce: bool,
382    pub caches: Vec<(String, MethodCacheSpec)>,
383}
384
385impl Default for ClientSpec {
386    fn default() -> Self {
387        Self {
388            coalesce: true,
389            caches: Vec::new(),
390        }
391    }
392}
393
394// ---------- normalized Plan ----------
395
396#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
397#[serde(rename_all = "lowercase")]
398pub enum ServiceKind {
399    Backend,
400    Web,
401    Http,
402}
403
404impl ServiceKind {
405    pub fn as_str(&self) -> &'static str {
406        match self {
407            ServiceKind::Backend => "backend",
408            ServiceKind::Web => "web",
409            ServiceKind::Http => "http",
410        }
411    }
412    pub fn is_web(&self) -> bool {
413        matches!(self, ServiceKind::Web)
414    }
415    pub fn is_http(&self) -> bool {
416        matches!(self, ServiceKind::Http)
417    }
418}
419
420/// Resolved HTTP health-probe configuration (`[service.health]`).
421#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
422pub struct HealthSpec {
423    pub path: String,
424    pub port: u32,
425    /// `true` → emit a native Kubernetes `grpc:` probe on `port` (gRPC
426    /// services); `false` → `httpGet` on `port` at `path`.
427    pub grpc: bool,
428}
429
430#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
431#[serde(rename_all = "lowercase")]
432pub enum WebMode {
433    Spa,
434    Bff,
435}
436
437impl WebMode {
438    pub fn as_str(&self) -> &'static str {
439        match self {
440            WebMode::Spa => "spa",
441            WebMode::Bff => "bff",
442        }
443    }
444    pub fn container_port(&self) -> u32 {
445        match self {
446            WebMode::Spa => 8080,
447            WebMode::Bff => 3000,
448        }
449    }
450}
451
452#[derive(Clone, Debug)]
453pub struct Plan {
454    pub name: String,
455    pub version: String,
456    pub language: String,
457    pub kind: ServiceKind,
458    pub web_mode: Option<WebMode>,
459    /// Effective primary container/listen port (`[service].port` or per-kind default).
460    pub port: u32,
461    /// Additional HTTP port, when a gRPC backend also serves HTTP (`[service.http]`).
462    pub http_port: Option<u32>,
463    /// HTTP health probe, when an HTTP surface exists (always for http; opt-in otherwise).
464    pub health: Option<HealthSpec>,
465    pub namespace: String,
466    pub mesh: Mesh,
467    pub replicas: u32,
468    pub max_replicas: u32,
469    pub mcp_sidecar: bool,
470    pub expose: Option<String>,
471    pub cpu: String,
472    pub memory: String,
473    pub image: String,
474    pub depends_on: Vec<ServiceRef>,
475    pub callers: Vec<ServiceRef>,
476    pub dir: PathBuf,
477    pub database: Option<DatabaseSpec>,
478    pub named_databases: Vec<(String, DatabaseSpec)>,
479    pub cache: Option<CacheSpec>,
480    pub named_caches: Vec<(String, CacheSpec)>,
481    pub secrets: Option<SecretsSpec>,
482    pub migrations: Option<MigrationsSpec>,
483    pub config: Option<ConfigSpec>,
484    pub emitted_env: EmittedEnv,
485    pub selected_env: String,
486    pub client: ClientSpec,
487}
488
489impl Plan {
490    pub fn load(toml_path: &Path) -> Result<Self, Error> {
491        Self::load_with_env(toml_path, &stateful::select_env(None))
492    }
493
494    pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
495        let raw_str = std::fs::read_to_string(toml_path)
496            .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
497        let raw: RawConfig =
498            toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
499
500        if let Some(v) = raw.schema.as_deref()
501            && !SUPPORTED_SCHEMAS.contains(&v)
502        {
503            return Err(Error::UnsupportedSchema {
504                path: toml_path.to_path_buf(),
505                found: v.to_string(),
506                supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
507                current: CURRENT_SCHEMA.to_string(),
508            });
509        }
510
511        let depends_on = resolve_depends_on(raw.depends_on, env)?;
512
513        let explicit_callers = stateful::resolve_callers(&raw.callers, env);
514
515        let deploy_overlay = raw.deploy.envs.get(env);
516        let deploy_replicas = deploy_overlay
517            .and_then(|o| o.replicas)
518            .unwrap_or(raw.deploy.replicas);
519        let deploy_namespace = {
520            let raw_ns = deploy_overlay
521                .and_then(|o| o.namespace.clone())
522                .unwrap_or(raw.deploy.namespace);
523            let ns = apply_env(&raw_ns, env);
524            ensure_resolved("deploy.namespace", &ns)?;
525            ns
526        };
527        let deploy_mesh = deploy_overlay
528            .and_then(|o| o.mesh)
529            .or(raw.deploy.mesh)
530            .unwrap_or_default();
531        let deploy_mcp_sidecar = deploy_overlay
532            .and_then(|o| o.mcp_sidecar)
533            .unwrap_or(raw.deploy.mcp_sidecar);
534        let deploy_expose = deploy_overlay
535            .and_then(|o| o.expose.clone())
536            .or(raw.deploy.expose);
537
538        let max_replicas = raw
539            .autoscale
540            .as_ref()
541            .map(|a| a.max_replicas)
542            .unwrap_or(deploy_replicas);
543
544        let dir = toml_path
545            .parent()
546            .map(Path::to_path_buf)
547            .unwrap_or_else(|| PathBuf::from("."));
548
549        let image = std::env::var("TONIN_IMAGE_PREFIX")
550            .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
551            .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
552
553        let kind = match raw.service.kind.as_deref() {
554            Some("web") => ServiceKind::Web,
555            Some("http") => ServiceKind::Http,
556            _ => ServiceKind::Backend,
557        };
558        let web_mode = match (kind, raw.service.web_mode.as_deref()) {
559            (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
560            (ServiceKind::Web, _) => Some(WebMode::Spa),
561            _ => None,
562        };
563
564        // Effective listen port. Explicit `[service].port` wins; otherwise the
565        // per-kind default — preserving pre-port-field output for web/backend.
566        let port = raw.service.port.unwrap_or_else(|| match kind {
567            ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
568            ServiceKind::Http => 8080,
569            ServiceKind::Backend => 50051,
570        });
571
572        // Additional HTTP port for a gRPC backend that also serves HTTP
573        // (`[service.http]`). None for http-primary (its primary port is already
574        // HTTP) and for services with no secondary endpoint.
575        let http_port = match kind {
576            ServiceKind::Http => None,
577            _ => raw.service.http.as_ref().map(|h| h.port),
578        };
579
580        // Health probe, auto-derived from the service's surface and always
581        // present:
582        //   * An HTTP surface (http/web kind, or a `[service.http]` port on a
583        //     gRPC backend) → httpGet on that port.
584        //   * A pure gRPC backend → a native Kubernetes `grpc:` probe
585        //     (grpc.health.v1, served by the tonin runtime) on the gRPC port.
586        //     httpGet on a gRPC port can never succeed, so we never emit it.
587        // `[service.health]` overrides path/port for the HTTP case.
588        let http_surface_port = match kind {
589            ServiceKind::Http | ServiceKind::Web => Some(port),
590            ServiceKind::Backend => http_port,
591        };
592        let health = Some(match http_surface_port {
593            Some(hp) => {
594                let declared = raw.service.health.as_ref();
595                let secondary = raw.service.http.as_ref();
596                HealthSpec {
597                    grpc: false,
598                    path: declared
599                        .and_then(|h| h.path.clone())
600                        .or_else(|| secondary.and_then(|h| h.health_path.clone()))
601                        .unwrap_or_else(|| "/health".into()),
602                    port: declared.and_then(|h| h.port).unwrap_or(hp),
603                }
604            }
605            None => HealthSpec {
606                grpc: true,
607                path: String::new(),
608                port,
609            },
610        });
611
612        // MCP sidecar proxies to a gRPC server on :50051, so it cannot front an
613        // HTTP-primary service — force it off for kind = http.
614        let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
615
616        let svc_name = raw.service.name.clone();
617        let svc_ns = deploy_namespace.clone();
618        let database = raw
619            .database
620            .as_ref()
621            .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
622        let named_databases: Vec<(String, DatabaseSpec)> = raw
623            .databases
624            .iter()
625            .map(|(name, r)| {
626                (
627                    name.clone(),
628                    stateful::resolve_database(r, env, &svc_name, &svc_ns),
629                )
630            })
631            .collect();
632        let cache = raw
633            .cache
634            .as_ref()
635            .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
636        let named_caches: Vec<(String, CacheSpec)> = raw
637            .caches
638            .iter()
639            .map(|(name, r)| {
640                (
641                    name.clone(),
642                    stateful::resolve_cache(r, env, &svc_name, &svc_ns),
643                )
644            })
645            .collect();
646        let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
647        let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
648        let config = raw.config.as_ref().map(stateful::resolve_config);
649
650        let client = raw
651            .client
652            .map(|c| {
653                let mut caches: Vec<(String, MethodCacheSpec)> = c
654                    .cache
655                    .into_iter()
656                    .map(|(method, mc)| {
657                        (
658                            method,
659                            MethodCacheSpec {
660                                ttl_ms: mc.ttl_ms,
661                                capacity: mc.capacity,
662                            },
663                        )
664                    })
665                    .collect();
666                caches.sort_by(|a, b| a.0.cmp(&b.0));
667                ClientSpec {
668                    coalesce: c.coalesce,
669                    caches,
670                }
671            })
672            .unwrap_or_default();
673
674        let mut emitted_env = EmittedEnv::default();
675        if let Some(d) = &database {
676            emitted_env.extend_database(d, &svc_name);
677        }
678        for (name, d) in &named_databases {
679            let prefix = format!("{}_DATABASE", name.to_uppercase());
680            emitted_env.extend_database_named(&prefix, d, &svc_name);
681        }
682        if let Some(c) = &cache {
683            emitted_env.extend_cache(c);
684        }
685        for (name, c) in &named_caches {
686            let prefix = format!("{}_REDIS", name.to_uppercase());
687            emitted_env.extend_cache_named(&prefix, c);
688        }
689        if let Some(s) = &secrets {
690            emitted_env.extend_secrets(s);
691        }
692
693        Ok(Plan {
694            name: raw.service.name,
695            version: raw.service.version,
696            language: raw.service.language.unwrap_or_else(|| "rust".into()),
697            kind,
698            web_mode,
699            port,
700            http_port,
701            health,
702            namespace: deploy_namespace,
703            mesh: deploy_mesh,
704            replicas: deploy_replicas,
705            max_replicas,
706            mcp_sidecar,
707            expose: deploy_expose,
708            cpu: raw.resources.cpu,
709            memory: raw.resources.memory,
710            image,
711            depends_on,
712            callers: explicit_callers,
713            dir,
714            database,
715            named_databases,
716            cache,
717            named_caches,
718            secrets,
719            migrations,
720            config,
721            client,
722            emitted_env,
723            selected_env: env.to_string(),
724        })
725    }
726
727    pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
728        Self::load_workspace_with_env(root, &stateful::select_env(None))
729    }
730
731    pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
732        let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
733            .into_iter()
734            .filter_map(|e| e.ok())
735            .filter(|e| e.file_name() == "tonin.toml")
736            .map(|e| Plan::load_with_env(e.path(), env))
737            .collect::<Result<_, _>>()?;
738
739        let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
740            .iter()
741            .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
742            .collect();
743        for plan in plans.iter_mut() {
744            for (caller_name, caller_ns, deps) in &snapshot {
745                if deps
746                    .iter()
747                    .any(|d| d.name == plan.name && d.namespace == plan.namespace)
748                {
749                    plan.callers.push(ServiceRef {
750                        name: caller_name.clone(),
751                        namespace: caller_ns.clone(),
752                    });
753                }
754            }
755            plan.callers.sort();
756            plan.callers.dedup();
757        }
758
759        plans.sort_by(|a, b| a.name.cmp(&b.name));
760        Ok(plans)
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use std::sync::atomic::{AtomicU32, Ordering};
768
769    static COUNTER: AtomicU32 = AtomicU32::new(0);
770
771    const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
772
773    /// Write `<service block> + BASE` to a temp tonin.toml and load it.
774    fn load(service: &str) -> Plan {
775        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
776        let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
777        std::fs::create_dir_all(&dir).unwrap();
778        let path = dir.join("tonin.toml");
779        std::fs::write(&path, format!("{service}{BASE}")).unwrap();
780        let plan = Plan::load_with_env(&path, "prod").unwrap();
781        let _ = std::fs::remove_dir_all(&dir);
782        plan
783    }
784
785    #[test]
786    fn backend_defaults_unchanged() {
787        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
788        assert_eq!(p.kind, ServiceKind::Backend);
789        assert_eq!(p.port, 50051);
790        assert_eq!(p.http_port, None);
791        // A pure gRPC backend gets an auto native grpc: probe on the gRPC port.
792        let h = p.health.expect("backend gets an auto gRPC health probe");
793        assert!(h.grpc, "gRPC service uses a grpc: probe");
794        assert_eq!(h.port, 50051);
795        assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
796    }
797
798    #[test]
799    fn backend_port_override() {
800        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
801        assert_eq!(p.port, 9090);
802    }
803
804    #[test]
805    fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
806        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
807        assert_eq!(p.kind, ServiceKind::Http);
808        assert_eq!(p.port, 8080);
809        let h = p.health.expect("http services get a default probe");
810        assert_eq!(h.path, "/health");
811        assert_eq!(h.port, 8080);
812        assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
813    }
814
815    #[test]
816    fn http_explicit_port_and_health_path() {
817        let p = load(
818            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
819        );
820        assert_eq!(p.port, 7001);
821        let h = p.health.unwrap();
822        assert_eq!(h.path, "/healthz");
823        assert_eq!(h.port, 7001);
824    }
825
826    #[test]
827    fn backend_with_http_exposes_both() {
828        let p = load(
829            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
830        );
831        assert_eq!(p.kind, ServiceKind::Backend);
832        assert_eq!(p.port, 50051, "gRPC primary port preserved");
833        assert_eq!(p.http_port, Some(8081));
834        let h = p.health.unwrap();
835        assert_eq!(h.path, "/healthz");
836        assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
837        assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
838    }
839
840    // ---- depends_on per-env resolution ------------------------------------
841
842    /// A complete tonin.toml with [service] + [resources]; tests append
843    /// [deploy] and [depends_on]. Bodies use literal `{env}` (no format!),
844    /// so they're concatenated rather than interpolated.
845    const SVC: &str = "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
846
847    fn try_load_env(body: &str, env: &str) -> Result<Plan, Error> {
848        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
849        let dir = std::env::temp_dir().join(format!("tonin-plan-dep-{}-{n}", std::process::id()));
850        std::fs::create_dir_all(&dir).unwrap();
851        let path = dir.join("tonin.toml");
852        std::fs::write(&path, body).unwrap();
853        let plan = Plan::load_with_env(&path, env);
854        let _ = std::fs::remove_dir_all(&dir);
855        plan
856    }
857
858    fn dep(plan: &Plan, name: &str) -> Option<String> {
859        plan.depends_on
860            .iter()
861            .find(|d| d.name == name)
862            .map(|d| d.namespace.clone())
863    }
864
865    #[test]
866    fn depends_on_literal_is_backward_compatible() {
867        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-dev\"\n"].concat();
868        let p = try_load_env(&body, "prod").unwrap();
869        // No {env} → literal namespace, identical in every env (today's behaviour).
870        assert_eq!(dep(&p, "identity").as_deref(), Some("agnitiv-dev"));
871    }
872
873    #[test]
874    fn depends_on_env_placeholder_resolves_per_env() {
875        let body = [
876            SVC,
877            "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{env}\"\n[depends_on]\nidentity = \"agnitiv-{env}\"\n",
878        ]
879        .concat();
880        let dev = try_load_env(&body, "dev").unwrap();
881        assert_eq!(dev.namespace, "agnitiv-dev");
882        assert_eq!(dep(&dev, "identity").as_deref(), Some("agnitiv-dev"));
883        let prod = try_load_env(&body, "prod").unwrap();
884        assert_eq!(prod.namespace, "agnitiv-prod");
885        assert_eq!(dep(&prod, "identity").as_deref(), Some("agnitiv-prod"));
886    }
887
888    #[test]
889    fn depends_on_table_per_env_override_wins() {
890        let body = [
891            SVC,
892            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar = { namespace = \"zradar-{env}\", prod = \"zradar-shared\" }\n",
893        ]
894        .concat();
895        assert_eq!(
896            dep(&try_load_env(&body, "dev").unwrap(), "zradar").as_deref(),
897            Some("zradar-dev")
898        );
899        assert_eq!(
900            dep(&try_load_env(&body, "prod").unwrap(), "zradar").as_deref(),
901            Some("zradar-shared")
902        );
903    }
904
905    #[test]
906    fn depends_on_envs_whitelist_scopes_dependency() {
907        let body = [
908            SVC,
909            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\naudit = { namespace = \"security-{env}\", envs = [\"prod\"] }\n",
910        ]
911        .concat();
912        assert!(
913            dep(&try_load_env(&body, "dev").unwrap(), "audit").is_none(),
914            "absent in dev"
915        );
916        assert_eq!(
917            dep(&try_load_env(&body, "prod").unwrap(), "audit").as_deref(),
918            Some("security-prod")
919        );
920    }
921
922    #[test]
923    fn depends_on_inherit_is_omitted_from_output() {
924        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"@inherit\" }\n"].concat();
925        assert!(dep(&try_load_env(&body, "prod").unwrap(), "billing").is_none());
926    }
927
928    #[test]
929    fn depends_on_unresolved_placeholder_is_error() {
930        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-{environment}\"\n"].concat();
931        let err = try_load_env(&body, "prod").unwrap_err();
932        assert!(
933            matches!(err, Error::UnresolvedNamespace { .. }),
934            "got {err:?}"
935        );
936    }
937
938    #[test]
939    fn depends_on_missing_namespace_for_env_is_error() {
940        // Only a dev override; prod has nothing to resolve to → hard error,
941        // never a silent fallback.
942        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { dev = \"agnitiv-dev\" }\n"].concat();
943        let err = try_load_env(&body, "prod").unwrap_err();
944        assert!(
945            matches!(err, Error::InvalidDependency { .. }),
946            "got {err:?}"
947        );
948    }
949
950    #[test]
951    fn depends_on_bad_type_is_error() {
952        let body = [
953            SVC,
954            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = 123\n",
955        ]
956        .concat();
957        let err = try_load_env(&body, "prod").unwrap_err();
958        assert!(
959            matches!(err, Error::InvalidDependency { .. }),
960            "got {err:?}"
961        );
962    }
963
964    #[test]
965    fn deploy_namespace_unresolved_placeholder_is_error() {
966        let body = [
967            SVC,
968            "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{cluster}\"\n",
969        ]
970        .concat();
971        let err = try_load_env(&body, "prod").unwrap_err();
972        assert!(
973            matches!(err, Error::UnresolvedNamespace { .. }),
974            "got {err:?}"
975        );
976    }
977}