1use 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
65pub const CURRENT_SCHEMA: &str = "v1";
69pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
70
71pub 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#[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}