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 #[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
72struct DepSpec {
77 namespace: Option<String>,
79 env_overrides: BTreeMap<String, String>,
81 envs: Option<Vec<String>>,
83}
84
85pub(crate) fn apply_env(pattern: &str, env: &str) -> String {
87 pattern.replace("{env}", env)
88}
89
90fn 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
102fn 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 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
165fn 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; }
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; }
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
215pub const CURRENT_SCHEMA: &str = "v1";
219pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
220
221pub 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 #[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 #[serde(default)]
286 port: Option<u32>,
287 #[serde(default)]
289 health: Option<RawHealth>,
290 #[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
422pub struct HealthSpec {
423 pub path: String,
424 pub port: u32,
425 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 pub port: u32,
461 pub http_port: Option<u32>,
463 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 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 let http_port = match kind {
576 ServiceKind::Http => None,
577 _ => raw.service.http.as_ref().map(|h| h.port),
578 };
579
580 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 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 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 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 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 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 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}