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.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 #[serde(default)]
129 port: Option<u32>,
130 #[serde(default)]
132 health: Option<RawHealth>,
133 #[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#[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#[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 pub port: u32,
301 pub http_port: Option<u32>,
303 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 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 let http_port = match kind {
415 ServiceKind::Http => None,
416 _ => raw.service.http.as_ref().map(|h| h.port),
417 };
418
419 let http_probe_port = match kind {
422 ServiceKind::Http => Some(port),
423 _ => http_port,
424 };
425
426 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 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 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}