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}
31
32#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "lowercase")]
34pub enum Mesh {
35    #[default]
36    Cilium,
37    Istio,
38    Linkerd,
39    None,
40}
41
42impl Mesh {
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            Mesh::Cilium => "cilium",
46            Mesh::Istio => "istio",
47            Mesh::Linkerd => "linkerd",
48            Mesh::None => "none",
49        }
50    }
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
54pub struct ServiceRef {
55    pub name: String,
56    pub namespace: String,
57}
58
59impl ServiceRef {
60    pub fn identity(&self) -> String {
61        format!("{}.{}", self.name, self.namespace)
62    }
63}
64
65// ---------- on-disk TOML shape ----------
66
67/// The schema version this CLI knows how to read.
68pub const CURRENT_SCHEMA: &str = "v1";
69pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
70
71/// Minimum `tonin` CLI version that can fully render all `tonin.toml`
72/// features exposed by this version of `tonin-plugin`.
73///
74/// The CLI checks this at `tonin k8s generate` / `tonin helm generate` time
75/// and emits a warning (never an error) when it is older. Services continue
76/// to work — the check is advisory so teams can upgrade at their own pace.
77///
78/// Bump this constant (in the same commit) whenever a new `tonin.toml`
79/// section or field is added that older CLI versions would silently ignore.
80pub const RECOMMENDED_CLI_MIN: &str = "0.5.6";
81
82#[derive(Debug, Deserialize)]
83struct RawConfig {
84    #[serde(default)]
85    schema: Option<String>,
86    service: RawService,
87    deploy: RawDeploy,
88    resources: RawResources,
89    #[serde(default)]
90    autoscale: Option<RawAutoscale>,
91    #[serde(default)]
92    depends_on: BTreeMap<String, String>,
93    #[serde(default)]
94    callers: RawCallers,
95    #[serde(default)]
96    database: Option<RawDatabase>,
97    #[serde(default)]
98    databases: std::collections::BTreeMap<String, RawDatabase>,
99    #[serde(default)]
100    cache: Option<RawCache>,
101    #[serde(default)]
102    caches: std::collections::BTreeMap<String, RawCache>,
103    #[serde(default)]
104    secrets: Option<RawSecrets>,
105    #[serde(default)]
106    migrations: Option<RawMigrations>,
107    #[serde(default)]
108    config: Option<RawConfigBlock>,
109    #[serde(default)]
110    client: Option<RawClientConfig>,
111}
112
113#[derive(Debug, Deserialize)]
114struct RawService {
115    name: String,
116    version: String,
117    #[serde(default)]
118    language: Option<String>,
119    #[serde(default, rename = "type")]
120    kind: Option<String>,
121    #[serde(default)]
122    web_mode: Option<String>,
123    #[serde(default)]
124    #[allow(dead_code)]
125    codec: Option<String>,
126    /// Explicit listen port. Optional — defaults per kind when unset
127    /// (web: 8080/3000 by mode, http: 8080, gRPC backend: 50051).
128    #[serde(default)]
129    port: Option<u32>,
130    /// HTTP health-probe config (`[service.health]`).
131    #[serde(default)]
132    health: Option<RawHealth>,
133    /// Additional HTTP endpoint (`[service.http]`). Lets a gRPC `backend` ALSO
134    /// serve HTTP (health/metrics/admin) — the two are not mutually exclusive.
135    #[serde(default)]
136    http: Option<RawHttpEndpoint>,
137}
138
139#[derive(Debug, Deserialize)]
140struct RawHealth {
141    #[serde(default)]
142    path: Option<String>,
143    #[serde(default)]
144    port: Option<u32>,
145}
146
147#[derive(Debug, Deserialize)]
148struct RawHttpEndpoint {
149    port: u32,
150    #[serde(default)]
151    health_path: Option<String>,
152}
153
154#[derive(Debug, Deserialize)]
155struct RawDeploy {
156    replicas: u32,
157    #[serde(default)]
158    mesh: Option<Mesh>,
159    #[serde(default = "default_true")]
160    mcp_sidecar: bool,
161    namespace: String,
162    #[serde(default)]
163    expose: Option<String>,
164    #[serde(default, flatten)]
165    envs: std::collections::BTreeMap<String, RawDeployEnv>,
166}
167
168#[derive(Debug, Deserialize, Default)]
169struct RawDeployEnv {
170    #[serde(default)]
171    replicas: Option<u32>,
172    #[serde(default)]
173    namespace: Option<String>,
174    #[serde(default)]
175    mesh: Option<Mesh>,
176    #[serde(default)]
177    mcp_sidecar: Option<bool>,
178    #[serde(default)]
179    expose: Option<String>,
180}
181
182#[derive(Debug, Deserialize)]
183struct RawResources {
184    cpu: String,
185    memory: String,
186}
187
188#[derive(Debug, Deserialize)]
189struct RawAutoscale {
190    max_replicas: u32,
191}
192
193fn default_true() -> bool {
194    true
195}
196
197#[derive(Debug, Default, Deserialize)]
198struct RawClientConfig {
199    #[serde(default = "default_true")]
200    coalesce: bool,
201    #[serde(default)]
202    cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
203}
204
205#[derive(Debug, Deserialize)]
206struct RawMethodCacheConfig {
207    ttl_ms: u64,
208    #[serde(default = "default_cache_capacity")]
209    capacity: usize,
210}
211
212fn default_cache_capacity() -> usize {
213    1_000
214}
215
216#[derive(Clone, Debug, Serialize)]
217pub struct MethodCacheSpec {
218    pub ttl_ms: u64,
219    pub capacity: usize,
220}
221
222#[derive(Clone, Debug, Serialize)]
223pub struct ClientSpec {
224    pub coalesce: bool,
225    pub caches: Vec<(String, MethodCacheSpec)>,
226}
227
228impl Default for ClientSpec {
229    fn default() -> Self {
230        Self {
231            coalesce: true,
232            caches: Vec::new(),
233        }
234    }
235}
236
237// ---------- normalized Plan ----------
238
239#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
240#[serde(rename_all = "lowercase")]
241pub enum ServiceKind {
242    Backend,
243    Web,
244    Http,
245}
246
247impl ServiceKind {
248    pub fn as_str(&self) -> &'static str {
249        match self {
250            ServiceKind::Backend => "backend",
251            ServiceKind::Web => "web",
252            ServiceKind::Http => "http",
253        }
254    }
255    pub fn is_web(&self) -> bool {
256        matches!(self, ServiceKind::Web)
257    }
258    pub fn is_http(&self) -> bool {
259        matches!(self, ServiceKind::Http)
260    }
261}
262
263/// Resolved HTTP health-probe configuration (`[service.health]`).
264#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
265pub struct HealthSpec {
266    pub path: String,
267    pub port: u32,
268}
269
270#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
271#[serde(rename_all = "lowercase")]
272pub enum WebMode {
273    Spa,
274    Bff,
275}
276
277impl WebMode {
278    pub fn as_str(&self) -> &'static str {
279        match self {
280            WebMode::Spa => "spa",
281            WebMode::Bff => "bff",
282        }
283    }
284    pub fn container_port(&self) -> u32 {
285        match self {
286            WebMode::Spa => 8080,
287            WebMode::Bff => 3000,
288        }
289    }
290}
291
292#[derive(Clone, Debug)]
293pub struct Plan {
294    pub name: String,
295    pub version: String,
296    pub language: String,
297    pub kind: ServiceKind,
298    pub web_mode: Option<WebMode>,
299    /// Effective primary container/listen port (`[service].port` or per-kind default).
300    pub port: u32,
301    /// Additional HTTP port, when a gRPC backend also serves HTTP (`[service.http]`).
302    pub http_port: Option<u32>,
303    /// HTTP health probe, when an HTTP surface exists (always for http; opt-in otherwise).
304    pub health: Option<HealthSpec>,
305    pub namespace: String,
306    pub mesh: Mesh,
307    pub replicas: u32,
308    pub max_replicas: u32,
309    pub mcp_sidecar: bool,
310    pub expose: Option<String>,
311    pub cpu: String,
312    pub memory: String,
313    pub image: String,
314    pub depends_on: Vec<ServiceRef>,
315    pub callers: Vec<ServiceRef>,
316    pub dir: PathBuf,
317    pub database: Option<DatabaseSpec>,
318    pub named_databases: Vec<(String, DatabaseSpec)>,
319    pub cache: Option<CacheSpec>,
320    pub named_caches: Vec<(String, CacheSpec)>,
321    pub secrets: Option<SecretsSpec>,
322    pub migrations: Option<MigrationsSpec>,
323    pub config: Option<ConfigSpec>,
324    pub emitted_env: EmittedEnv,
325    pub selected_env: String,
326    pub client: ClientSpec,
327}
328
329impl Plan {
330    pub fn load(toml_path: &Path) -> Result<Self, Error> {
331        Self::load_with_env(toml_path, &stateful::select_env(None))
332    }
333
334    pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
335        let raw_str = std::fs::read_to_string(toml_path)
336            .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
337        let raw: RawConfig =
338            toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
339
340        if let Some(v) = raw.schema.as_deref()
341            && !SUPPORTED_SCHEMAS.contains(&v)
342        {
343            return Err(Error::UnsupportedSchema {
344                path: toml_path.to_path_buf(),
345                found: v.to_string(),
346                supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
347                current: CURRENT_SCHEMA.to_string(),
348            });
349        }
350
351        let depends_on: Vec<ServiceRef> = raw
352            .depends_on
353            .into_iter()
354            .map(|(name, namespace)| ServiceRef { name, namespace })
355            .collect();
356
357        let explicit_callers = stateful::resolve_callers(&raw.callers, env);
358
359        let deploy_overlay = raw.deploy.envs.get(env);
360        let deploy_replicas = deploy_overlay
361            .and_then(|o| o.replicas)
362            .unwrap_or(raw.deploy.replicas);
363        let deploy_namespace = deploy_overlay
364            .and_then(|o| o.namespace.clone())
365            .unwrap_or(raw.deploy.namespace);
366        let deploy_mesh = deploy_overlay
367            .and_then(|o| o.mesh)
368            .or(raw.deploy.mesh)
369            .unwrap_or_default();
370        let deploy_mcp_sidecar = deploy_overlay
371            .and_then(|o| o.mcp_sidecar)
372            .unwrap_or(raw.deploy.mcp_sidecar);
373        let deploy_expose = deploy_overlay
374            .and_then(|o| o.expose.clone())
375            .or(raw.deploy.expose);
376
377        let max_replicas = raw
378            .autoscale
379            .as_ref()
380            .map(|a| a.max_replicas)
381            .unwrap_or(deploy_replicas);
382
383        let dir = toml_path
384            .parent()
385            .map(Path::to_path_buf)
386            .unwrap_or_else(|| PathBuf::from("."));
387
388        let image = std::env::var("TONIN_IMAGE_PREFIX")
389            .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
390            .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
391
392        let kind = match raw.service.kind.as_deref() {
393            Some("web") => ServiceKind::Web,
394            Some("http") => ServiceKind::Http,
395            _ => ServiceKind::Backend,
396        };
397        let web_mode = match (kind, raw.service.web_mode.as_deref()) {
398            (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
399            (ServiceKind::Web, _) => Some(WebMode::Spa),
400            _ => None,
401        };
402
403        // Effective listen port. Explicit `[service].port` wins; otherwise the
404        // per-kind default — preserving pre-port-field output for web/backend.
405        let port = raw.service.port.unwrap_or_else(|| match kind {
406            ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
407            ServiceKind::Http => 8080,
408            ServiceKind::Backend => 50051,
409        });
410
411        // Additional HTTP port for a gRPC backend that also serves HTTP
412        // (`[service.http]`). None for http-primary (its primary port is already
413        // HTTP) and for services with no secondary endpoint.
414        let http_port = match kind {
415            ServiceKind::Http => None,
416            _ => raw.service.http.as_ref().map(|h| h.port),
417        };
418
419        // Port an HTTP health probe targets: the http-primary port, else the
420        // secondary HTTP port. None ⇒ no HTTP surface ⇒ no httpGet probe.
421        let http_probe_port = match kind {
422            ServiceKind::Http => Some(port),
423            _ => http_port,
424        };
425
426        // HTTP health probe. Present when there is an HTTP surface (http kind or
427        // a `[service.http]` endpoint) or when `[service.health]` is declared.
428        // Path: `[service.health].path`, else `[service.http].health_path`, else
429        // `/health`. Backend/web without any of these get no probe, so existing
430        // manifests stay byte-identical.
431        let health = if raw.service.health.is_some() || http_probe_port.is_some() {
432            let declared = raw.service.health.as_ref();
433            let secondary = raw.service.http.as_ref();
434            Some(HealthSpec {
435                path: declared
436                    .and_then(|h| h.path.clone())
437                    .or_else(|| secondary.and_then(|h| h.health_path.clone()))
438                    .unwrap_or_else(|| "/health".into()),
439                port: declared
440                    .and_then(|h| h.port)
441                    .or(http_probe_port)
442                    .unwrap_or(port),
443            })
444        } else {
445            None
446        };
447
448        // MCP sidecar proxies to a gRPC server on :50051, so it cannot front an
449        // HTTP-primary service — force it off for kind = http.
450        let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
451
452        let svc_name = raw.service.name.clone();
453        let svc_ns = deploy_namespace.clone();
454        let database = raw
455            .database
456            .as_ref()
457            .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
458        let named_databases: Vec<(String, DatabaseSpec)> = raw
459            .databases
460            .iter()
461            .map(|(name, r)| {
462                (
463                    name.clone(),
464                    stateful::resolve_database(r, env, &svc_name, &svc_ns),
465                )
466            })
467            .collect();
468        let cache = raw
469            .cache
470            .as_ref()
471            .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
472        let named_caches: Vec<(String, CacheSpec)> = raw
473            .caches
474            .iter()
475            .map(|(name, r)| {
476                (
477                    name.clone(),
478                    stateful::resolve_cache(r, env, &svc_name, &svc_ns),
479                )
480            })
481            .collect();
482        let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
483        let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
484        let config = raw.config.as_ref().map(stateful::resolve_config);
485
486        let client = raw
487            .client
488            .map(|c| {
489                let mut caches: Vec<(String, MethodCacheSpec)> = c
490                    .cache
491                    .into_iter()
492                    .map(|(method, mc)| {
493                        (
494                            method,
495                            MethodCacheSpec {
496                                ttl_ms: mc.ttl_ms,
497                                capacity: mc.capacity,
498                            },
499                        )
500                    })
501                    .collect();
502                caches.sort_by(|a, b| a.0.cmp(&b.0));
503                ClientSpec {
504                    coalesce: c.coalesce,
505                    caches,
506                }
507            })
508            .unwrap_or_default();
509
510        let mut emitted_env = EmittedEnv::default();
511        if let Some(d) = &database {
512            emitted_env.extend_database(d, &svc_name);
513        }
514        for (name, d) in &named_databases {
515            let prefix = format!("{}_DATABASE", name.to_uppercase());
516            emitted_env.extend_database_named(&prefix, d, &svc_name);
517        }
518        if let Some(c) = &cache {
519            emitted_env.extend_cache(c);
520        }
521        for (name, c) in &named_caches {
522            let prefix = format!("{}_REDIS", name.to_uppercase());
523            emitted_env.extend_cache_named(&prefix, c);
524        }
525        if let Some(s) = &secrets {
526            emitted_env.extend_secrets(s);
527        }
528
529        Ok(Plan {
530            name: raw.service.name,
531            version: raw.service.version,
532            language: raw.service.language.unwrap_or_else(|| "rust".into()),
533            kind,
534            web_mode,
535            port,
536            http_port,
537            health,
538            namespace: deploy_namespace,
539            mesh: deploy_mesh,
540            replicas: deploy_replicas,
541            max_replicas,
542            mcp_sidecar,
543            expose: deploy_expose,
544            cpu: raw.resources.cpu,
545            memory: raw.resources.memory,
546            image,
547            depends_on,
548            callers: explicit_callers,
549            dir,
550            database,
551            named_databases,
552            cache,
553            named_caches,
554            secrets,
555            migrations,
556            config,
557            client,
558            emitted_env,
559            selected_env: env.to_string(),
560        })
561    }
562
563    pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
564        Self::load_workspace_with_env(root, &stateful::select_env(None))
565    }
566
567    pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
568        let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
569            .into_iter()
570            .filter_map(|e| e.ok())
571            .filter(|e| e.file_name() == "tonin.toml")
572            .map(|e| Plan::load_with_env(e.path(), env))
573            .collect::<Result<_, _>>()?;
574
575        let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
576            .iter()
577            .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
578            .collect();
579        for plan in plans.iter_mut() {
580            for (caller_name, caller_ns, deps) in &snapshot {
581                if deps
582                    .iter()
583                    .any(|d| d.name == plan.name && d.namespace == plan.namespace)
584                {
585                    plan.callers.push(ServiceRef {
586                        name: caller_name.clone(),
587                        namespace: caller_ns.clone(),
588                    });
589                }
590            }
591            plan.callers.sort();
592            plan.callers.dedup();
593        }
594
595        plans.sort_by(|a, b| a.name.cmp(&b.name));
596        Ok(plans)
597    }
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use std::sync::atomic::{AtomicU32, Ordering};
604
605    static COUNTER: AtomicU32 = AtomicU32::new(0);
606
607    const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
608
609    /// Write `<service block> + BASE` to a temp tonin.toml and load it.
610    fn load(service: &str) -> Plan {
611        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
612        let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
613        std::fs::create_dir_all(&dir).unwrap();
614        let path = dir.join("tonin.toml");
615        std::fs::write(&path, format!("{service}{BASE}")).unwrap();
616        let plan = Plan::load_with_env(&path, "prod").unwrap();
617        let _ = std::fs::remove_dir_all(&dir);
618        plan
619    }
620
621    #[test]
622    fn backend_defaults_unchanged() {
623        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
624        assert_eq!(p.kind, ServiceKind::Backend);
625        assert_eq!(p.port, 50051);
626        assert_eq!(p.http_port, None);
627        assert!(p.health.is_none());
628        assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
629    }
630
631    #[test]
632    fn backend_port_override() {
633        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
634        assert_eq!(p.port, 9090);
635    }
636
637    #[test]
638    fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
639        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
640        assert_eq!(p.kind, ServiceKind::Http);
641        assert_eq!(p.port, 8080);
642        let h = p.health.expect("http services get a default probe");
643        assert_eq!(h.path, "/health");
644        assert_eq!(h.port, 8080);
645        assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
646    }
647
648    #[test]
649    fn http_explicit_port_and_health_path() {
650        let p = load(
651            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
652        );
653        assert_eq!(p.port, 7001);
654        let h = p.health.unwrap();
655        assert_eq!(h.path, "/healthz");
656        assert_eq!(h.port, 7001);
657    }
658
659    #[test]
660    fn backend_with_http_exposes_both() {
661        let p = load(
662            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
663        );
664        assert_eq!(p.kind, ServiceKind::Backend);
665        assert_eq!(p.port, 50051, "gRPC primary port preserved");
666        assert_eq!(p.http_port, Some(8081));
667        let h = p.health.unwrap();
668        assert_eq!(h.path, "/healthz");
669        assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
670        assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
671    }
672}