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.0";
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}
127
128#[derive(Debug, Deserialize)]
129struct RawDeploy {
130    replicas: u32,
131    #[serde(default)]
132    mesh: Option<Mesh>,
133    #[serde(default = "default_true")]
134    mcp_sidecar: bool,
135    namespace: String,
136    #[serde(default)]
137    expose: Option<String>,
138    #[serde(default, flatten)]
139    envs: std::collections::BTreeMap<String, RawDeployEnv>,
140}
141
142#[derive(Debug, Deserialize, Default)]
143struct RawDeployEnv {
144    #[serde(default)]
145    replicas: Option<u32>,
146    #[serde(default)]
147    namespace: Option<String>,
148    #[serde(default)]
149    mesh: Option<Mesh>,
150    #[serde(default)]
151    mcp_sidecar: Option<bool>,
152    #[serde(default)]
153    expose: Option<String>,
154}
155
156#[derive(Debug, Deserialize)]
157struct RawResources {
158    cpu: String,
159    memory: String,
160}
161
162#[derive(Debug, Deserialize)]
163struct RawAutoscale {
164    max_replicas: u32,
165}
166
167fn default_true() -> bool {
168    true
169}
170
171#[derive(Debug, Default, Deserialize)]
172struct RawClientConfig {
173    #[serde(default = "default_true")]
174    coalesce: bool,
175    #[serde(default)]
176    cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
177}
178
179#[derive(Debug, Deserialize)]
180struct RawMethodCacheConfig {
181    ttl_ms: u64,
182    #[serde(default = "default_cache_capacity")]
183    capacity: usize,
184}
185
186fn default_cache_capacity() -> usize {
187    1_000
188}
189
190#[derive(Clone, Debug, Serialize)]
191pub struct MethodCacheSpec {
192    pub ttl_ms: u64,
193    pub capacity: usize,
194}
195
196#[derive(Clone, Debug, Serialize)]
197pub struct ClientSpec {
198    pub coalesce: bool,
199    pub caches: Vec<(String, MethodCacheSpec)>,
200}
201
202impl Default for ClientSpec {
203    fn default() -> Self {
204        Self {
205            coalesce: true,
206            caches: Vec::new(),
207        }
208    }
209}
210
211// ---------- normalized Plan ----------
212
213#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
214#[serde(rename_all = "lowercase")]
215pub enum ServiceKind {
216    Backend,
217    Web,
218}
219
220impl ServiceKind {
221    pub fn as_str(&self) -> &'static str {
222        match self {
223            ServiceKind::Backend => "backend",
224            ServiceKind::Web => "web",
225        }
226    }
227    pub fn is_web(&self) -> bool {
228        matches!(self, ServiceKind::Web)
229    }
230}
231
232#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
233#[serde(rename_all = "lowercase")]
234pub enum WebMode {
235    Spa,
236    Bff,
237}
238
239impl WebMode {
240    pub fn as_str(&self) -> &'static str {
241        match self {
242            WebMode::Spa => "spa",
243            WebMode::Bff => "bff",
244        }
245    }
246    pub fn container_port(&self) -> u32 {
247        match self {
248            WebMode::Spa => 8080,
249            WebMode::Bff => 3000,
250        }
251    }
252}
253
254#[derive(Clone, Debug)]
255pub struct Plan {
256    pub name: String,
257    pub version: String,
258    pub language: String,
259    pub kind: ServiceKind,
260    pub web_mode: Option<WebMode>,
261    pub namespace: String,
262    pub mesh: Mesh,
263    pub replicas: u32,
264    pub max_replicas: u32,
265    pub mcp_sidecar: bool,
266    pub expose: Option<String>,
267    pub cpu: String,
268    pub memory: String,
269    pub image: String,
270    pub depends_on: Vec<ServiceRef>,
271    pub callers: Vec<ServiceRef>,
272    pub dir: PathBuf,
273    pub database: Option<DatabaseSpec>,
274    pub named_databases: Vec<(String, DatabaseSpec)>,
275    pub cache: Option<CacheSpec>,
276    pub named_caches: Vec<(String, CacheSpec)>,
277    pub secrets: Option<SecretsSpec>,
278    pub migrations: Option<MigrationsSpec>,
279    pub config: Option<ConfigSpec>,
280    pub emitted_env: EmittedEnv,
281    pub selected_env: String,
282    pub client: ClientSpec,
283}
284
285impl Plan {
286    pub fn load(toml_path: &Path) -> Result<Self, Error> {
287        Self::load_with_env(toml_path, &stateful::select_env(None))
288    }
289
290    pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
291        let raw_str = std::fs::read_to_string(toml_path)
292            .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
293        let raw: RawConfig =
294            toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
295
296        if let Some(v) = raw.schema.as_deref()
297            && !SUPPORTED_SCHEMAS.contains(&v)
298        {
299            return Err(Error::UnsupportedSchema {
300                path: toml_path.to_path_buf(),
301                found: v.to_string(),
302                supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
303                current: CURRENT_SCHEMA.to_string(),
304            });
305        }
306
307        let depends_on: Vec<ServiceRef> = raw
308            .depends_on
309            .into_iter()
310            .map(|(name, namespace)| ServiceRef { name, namespace })
311            .collect();
312
313        let explicit_callers = stateful::resolve_callers(&raw.callers, env);
314
315        let deploy_overlay = raw.deploy.envs.get(env);
316        let deploy_replicas = deploy_overlay
317            .and_then(|o| o.replicas)
318            .unwrap_or(raw.deploy.replicas);
319        let deploy_namespace = deploy_overlay
320            .and_then(|o| o.namespace.clone())
321            .unwrap_or(raw.deploy.namespace);
322        let deploy_mesh = deploy_overlay
323            .and_then(|o| o.mesh)
324            .or(raw.deploy.mesh)
325            .unwrap_or_default();
326        let deploy_mcp_sidecar = deploy_overlay
327            .and_then(|o| o.mcp_sidecar)
328            .unwrap_or(raw.deploy.mcp_sidecar);
329        let deploy_expose = deploy_overlay
330            .and_then(|o| o.expose.clone())
331            .or(raw.deploy.expose);
332
333        let max_replicas = raw
334            .autoscale
335            .as_ref()
336            .map(|a| a.max_replicas)
337            .unwrap_or(deploy_replicas);
338
339        let dir = toml_path
340            .parent()
341            .map(Path::to_path_buf)
342            .unwrap_or_else(|| PathBuf::from("."));
343
344        let image = std::env::var("TONIN_IMAGE_PREFIX")
345            .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
346            .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
347
348        let kind = match raw.service.kind.as_deref() {
349            Some("web") => ServiceKind::Web,
350            _ => ServiceKind::Backend,
351        };
352        let web_mode = match (kind, raw.service.web_mode.as_deref()) {
353            (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
354            (ServiceKind::Web, _) => Some(WebMode::Spa),
355            _ => None,
356        };
357
358        let svc_name = raw.service.name.clone();
359        let svc_ns = deploy_namespace.clone();
360        let database = raw
361            .database
362            .as_ref()
363            .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
364        let named_databases: Vec<(String, DatabaseSpec)> = raw
365            .databases
366            .iter()
367            .map(|(name, r)| {
368                (
369                    name.clone(),
370                    stateful::resolve_database(r, env, &svc_name, &svc_ns),
371                )
372            })
373            .collect();
374        let cache = raw
375            .cache
376            .as_ref()
377            .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
378        let named_caches: Vec<(String, CacheSpec)> = raw
379            .caches
380            .iter()
381            .map(|(name, r)| {
382                (
383                    name.clone(),
384                    stateful::resolve_cache(r, env, &svc_name, &svc_ns),
385                )
386            })
387            .collect();
388        let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
389        let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
390        let config = raw.config.as_ref().map(stateful::resolve_config);
391
392        let client = raw
393            .client
394            .map(|c| {
395                let mut caches: Vec<(String, MethodCacheSpec)> = c
396                    .cache
397                    .into_iter()
398                    .map(|(method, mc)| {
399                        (
400                            method,
401                            MethodCacheSpec {
402                                ttl_ms: mc.ttl_ms,
403                                capacity: mc.capacity,
404                            },
405                        )
406                    })
407                    .collect();
408                caches.sort_by(|a, b| a.0.cmp(&b.0));
409                ClientSpec {
410                    coalesce: c.coalesce,
411                    caches,
412                }
413            })
414            .unwrap_or_default();
415
416        let mut emitted_env = EmittedEnv::default();
417        if let Some(d) = &database {
418            emitted_env.extend_database(d, &svc_name);
419        }
420        for (name, d) in &named_databases {
421            let prefix = format!("{}_DATABASE", name.to_uppercase());
422            emitted_env.extend_database_named(&prefix, d, &svc_name);
423        }
424        if let Some(c) = &cache {
425            emitted_env.extend_cache(c);
426        }
427        for (name, c) in &named_caches {
428            let prefix = format!("{}_REDIS", name.to_uppercase());
429            emitted_env.extend_cache_named(&prefix, c);
430        }
431        if let Some(s) = &secrets {
432            emitted_env.extend_secrets(s);
433        }
434
435        Ok(Plan {
436            name: raw.service.name,
437            version: raw.service.version,
438            language: raw.service.language.unwrap_or_else(|| "rust".into()),
439            kind,
440            web_mode,
441            namespace: deploy_namespace,
442            mesh: deploy_mesh,
443            replicas: deploy_replicas,
444            max_replicas,
445            mcp_sidecar: deploy_mcp_sidecar,
446            expose: deploy_expose,
447            cpu: raw.resources.cpu,
448            memory: raw.resources.memory,
449            image,
450            depends_on,
451            callers: explicit_callers,
452            dir,
453            database,
454            named_databases,
455            cache,
456            named_caches,
457            secrets,
458            migrations,
459            config,
460            client,
461            emitted_env,
462            selected_env: env.to_string(),
463        })
464    }
465
466    pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
467        Self::load_workspace_with_env(root, &stateful::select_env(None))
468    }
469
470    pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
471        let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
472            .into_iter()
473            .filter_map(|e| e.ok())
474            .filter(|e| e.file_name() == "tonin.toml")
475            .map(|e| Plan::load_with_env(e.path(), env))
476            .collect::<Result<_, _>>()?;
477
478        let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
479            .iter()
480            .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
481            .collect();
482        for plan in plans.iter_mut() {
483            for (caller_name, caller_ns, deps) in &snapshot {
484                if deps
485                    .iter()
486                    .any(|d| d.name == plan.name && d.namespace == plan.namespace)
487                {
488                    plan.callers.push(ServiceRef {
489                        name: caller_name.clone(),
490                        namespace: caller_ns.clone(),
491                    });
492                }
493            }
494            plan.callers.sort();
495            plan.callers.dedup();
496        }
497
498        plans.sort_by(|a, b| a.name.cmp(&b.name));
499        Ok(plans)
500    }
501}