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
85fn 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}
426
427#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
428#[serde(rename_all = "lowercase")]
429pub enum WebMode {
430 Spa,
431 Bff,
432}
433
434impl WebMode {
435 pub fn as_str(&self) -> &'static str {
436 match self {
437 WebMode::Spa => "spa",
438 WebMode::Bff => "bff",
439 }
440 }
441 pub fn container_port(&self) -> u32 {
442 match self {
443 WebMode::Spa => 8080,
444 WebMode::Bff => 3000,
445 }
446 }
447}
448
449#[derive(Clone, Debug)]
450pub struct Plan {
451 pub name: String,
452 pub version: String,
453 pub language: String,
454 pub kind: ServiceKind,
455 pub web_mode: Option<WebMode>,
456 pub port: u32,
458 pub http_port: Option<u32>,
460 pub health: Option<HealthSpec>,
462 pub namespace: String,
463 pub mesh: Mesh,
464 pub replicas: u32,
465 pub max_replicas: u32,
466 pub mcp_sidecar: bool,
467 pub expose: Option<String>,
468 pub cpu: String,
469 pub memory: String,
470 pub image: String,
471 pub depends_on: Vec<ServiceRef>,
472 pub callers: Vec<ServiceRef>,
473 pub dir: PathBuf,
474 pub database: Option<DatabaseSpec>,
475 pub named_databases: Vec<(String, DatabaseSpec)>,
476 pub cache: Option<CacheSpec>,
477 pub named_caches: Vec<(String, CacheSpec)>,
478 pub secrets: Option<SecretsSpec>,
479 pub migrations: Option<MigrationsSpec>,
480 pub config: Option<ConfigSpec>,
481 pub emitted_env: EmittedEnv,
482 pub selected_env: String,
483 pub client: ClientSpec,
484}
485
486impl Plan {
487 pub fn load(toml_path: &Path) -> Result<Self, Error> {
488 Self::load_with_env(toml_path, &stateful::select_env(None))
489 }
490
491 pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
492 let raw_str = std::fs::read_to_string(toml_path)
493 .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
494 let raw: RawConfig =
495 toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
496
497 if let Some(v) = raw.schema.as_deref()
498 && !SUPPORTED_SCHEMAS.contains(&v)
499 {
500 return Err(Error::UnsupportedSchema {
501 path: toml_path.to_path_buf(),
502 found: v.to_string(),
503 supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
504 current: CURRENT_SCHEMA.to_string(),
505 });
506 }
507
508 let depends_on = resolve_depends_on(raw.depends_on, env)?;
509
510 let explicit_callers = stateful::resolve_callers(&raw.callers, env);
511
512 let deploy_overlay = raw.deploy.envs.get(env);
513 let deploy_replicas = deploy_overlay
514 .and_then(|o| o.replicas)
515 .unwrap_or(raw.deploy.replicas);
516 let deploy_namespace = {
517 let raw_ns = deploy_overlay
518 .and_then(|o| o.namespace.clone())
519 .unwrap_or(raw.deploy.namespace);
520 let ns = apply_env(&raw_ns, env);
521 ensure_resolved("deploy.namespace", &ns)?;
522 ns
523 };
524 let deploy_mesh = deploy_overlay
525 .and_then(|o| o.mesh)
526 .or(raw.deploy.mesh)
527 .unwrap_or_default();
528 let deploy_mcp_sidecar = deploy_overlay
529 .and_then(|o| o.mcp_sidecar)
530 .unwrap_or(raw.deploy.mcp_sidecar);
531 let deploy_expose = deploy_overlay
532 .and_then(|o| o.expose.clone())
533 .or(raw.deploy.expose);
534
535 let max_replicas = raw
536 .autoscale
537 .as_ref()
538 .map(|a| a.max_replicas)
539 .unwrap_or(deploy_replicas);
540
541 let dir = toml_path
542 .parent()
543 .map(Path::to_path_buf)
544 .unwrap_or_else(|| PathBuf::from("."));
545
546 let image = std::env::var("TONIN_IMAGE_PREFIX")
547 .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
548 .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
549
550 let kind = match raw.service.kind.as_deref() {
551 Some("web") => ServiceKind::Web,
552 Some("http") => ServiceKind::Http,
553 _ => ServiceKind::Backend,
554 };
555 let web_mode = match (kind, raw.service.web_mode.as_deref()) {
556 (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
557 (ServiceKind::Web, _) => Some(WebMode::Spa),
558 _ => None,
559 };
560
561 let port = raw.service.port.unwrap_or_else(|| match kind {
564 ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
565 ServiceKind::Http => 8080,
566 ServiceKind::Backend => 50051,
567 });
568
569 let http_port = match kind {
573 ServiceKind::Http => None,
574 _ => raw.service.http.as_ref().map(|h| h.port),
575 };
576
577 let http_probe_port = match kind {
580 ServiceKind::Http => Some(port),
581 _ => http_port,
582 };
583
584 let health = if raw.service.health.is_some() || http_probe_port.is_some() {
590 let declared = raw.service.health.as_ref();
591 let secondary = raw.service.http.as_ref();
592 Some(HealthSpec {
593 path: declared
594 .and_then(|h| h.path.clone())
595 .or_else(|| secondary.and_then(|h| h.health_path.clone()))
596 .unwrap_or_else(|| "/health".into()),
597 port: declared
598 .and_then(|h| h.port)
599 .or(http_probe_port)
600 .unwrap_or(port),
601 })
602 } else {
603 None
604 };
605
606 let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
609
610 let svc_name = raw.service.name.clone();
611 let svc_ns = deploy_namespace.clone();
612 let database = raw
613 .database
614 .as_ref()
615 .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
616 let named_databases: Vec<(String, DatabaseSpec)> = raw
617 .databases
618 .iter()
619 .map(|(name, r)| {
620 (
621 name.clone(),
622 stateful::resolve_database(r, env, &svc_name, &svc_ns),
623 )
624 })
625 .collect();
626 let cache = raw
627 .cache
628 .as_ref()
629 .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
630 let named_caches: Vec<(String, CacheSpec)> = raw
631 .caches
632 .iter()
633 .map(|(name, r)| {
634 (
635 name.clone(),
636 stateful::resolve_cache(r, env, &svc_name, &svc_ns),
637 )
638 })
639 .collect();
640 let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
641 let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
642 let config = raw.config.as_ref().map(stateful::resolve_config);
643
644 let client = raw
645 .client
646 .map(|c| {
647 let mut caches: Vec<(String, MethodCacheSpec)> = c
648 .cache
649 .into_iter()
650 .map(|(method, mc)| {
651 (
652 method,
653 MethodCacheSpec {
654 ttl_ms: mc.ttl_ms,
655 capacity: mc.capacity,
656 },
657 )
658 })
659 .collect();
660 caches.sort_by(|a, b| a.0.cmp(&b.0));
661 ClientSpec {
662 coalesce: c.coalesce,
663 caches,
664 }
665 })
666 .unwrap_or_default();
667
668 let mut emitted_env = EmittedEnv::default();
669 if let Some(d) = &database {
670 emitted_env.extend_database(d, &svc_name);
671 }
672 for (name, d) in &named_databases {
673 let prefix = format!("{}_DATABASE", name.to_uppercase());
674 emitted_env.extend_database_named(&prefix, d, &svc_name);
675 }
676 if let Some(c) = &cache {
677 emitted_env.extend_cache(c);
678 }
679 for (name, c) in &named_caches {
680 let prefix = format!("{}_REDIS", name.to_uppercase());
681 emitted_env.extend_cache_named(&prefix, c);
682 }
683 if let Some(s) = &secrets {
684 emitted_env.extend_secrets(s);
685 }
686
687 Ok(Plan {
688 name: raw.service.name,
689 version: raw.service.version,
690 language: raw.service.language.unwrap_or_else(|| "rust".into()),
691 kind,
692 web_mode,
693 port,
694 http_port,
695 health,
696 namespace: deploy_namespace,
697 mesh: deploy_mesh,
698 replicas: deploy_replicas,
699 max_replicas,
700 mcp_sidecar,
701 expose: deploy_expose,
702 cpu: raw.resources.cpu,
703 memory: raw.resources.memory,
704 image,
705 depends_on,
706 callers: explicit_callers,
707 dir,
708 database,
709 named_databases,
710 cache,
711 named_caches,
712 secrets,
713 migrations,
714 config,
715 client,
716 emitted_env,
717 selected_env: env.to_string(),
718 })
719 }
720
721 pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
722 Self::load_workspace_with_env(root, &stateful::select_env(None))
723 }
724
725 pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
726 let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
727 .into_iter()
728 .filter_map(|e| e.ok())
729 .filter(|e| e.file_name() == "tonin.toml")
730 .map(|e| Plan::load_with_env(e.path(), env))
731 .collect::<Result<_, _>>()?;
732
733 let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
734 .iter()
735 .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
736 .collect();
737 for plan in plans.iter_mut() {
738 for (caller_name, caller_ns, deps) in &snapshot {
739 if deps
740 .iter()
741 .any(|d| d.name == plan.name && d.namespace == plan.namespace)
742 {
743 plan.callers.push(ServiceRef {
744 name: caller_name.clone(),
745 namespace: caller_ns.clone(),
746 });
747 }
748 }
749 plan.callers.sort();
750 plan.callers.dedup();
751 }
752
753 plans.sort_by(|a, b| a.name.cmp(&b.name));
754 Ok(plans)
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use std::sync::atomic::{AtomicU32, Ordering};
762
763 static COUNTER: AtomicU32 = AtomicU32::new(0);
764
765 const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
766
767 fn load(service: &str) -> Plan {
769 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
770 let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
771 std::fs::create_dir_all(&dir).unwrap();
772 let path = dir.join("tonin.toml");
773 std::fs::write(&path, format!("{service}{BASE}")).unwrap();
774 let plan = Plan::load_with_env(&path, "prod").unwrap();
775 let _ = std::fs::remove_dir_all(&dir);
776 plan
777 }
778
779 #[test]
780 fn backend_defaults_unchanged() {
781 let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
782 assert_eq!(p.kind, ServiceKind::Backend);
783 assert_eq!(p.port, 50051);
784 assert_eq!(p.http_port, None);
785 assert!(p.health.is_none());
786 assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
787 }
788
789 #[test]
790 fn backend_port_override() {
791 let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
792 assert_eq!(p.port, 9090);
793 }
794
795 #[test]
796 fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
797 let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
798 assert_eq!(p.kind, ServiceKind::Http);
799 assert_eq!(p.port, 8080);
800 let h = p.health.expect("http services get a default probe");
801 assert_eq!(h.path, "/health");
802 assert_eq!(h.port, 8080);
803 assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
804 }
805
806 #[test]
807 fn http_explicit_port_and_health_path() {
808 let p = load(
809 "[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
810 );
811 assert_eq!(p.port, 7001);
812 let h = p.health.unwrap();
813 assert_eq!(h.path, "/healthz");
814 assert_eq!(h.port, 7001);
815 }
816
817 #[test]
818 fn backend_with_http_exposes_both() {
819 let p = load(
820 "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
821 );
822 assert_eq!(p.kind, ServiceKind::Backend);
823 assert_eq!(p.port, 50051, "gRPC primary port preserved");
824 assert_eq!(p.http_port, Some(8081));
825 let h = p.health.unwrap();
826 assert_eq!(h.path, "/healthz");
827 assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
828 assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
829 }
830
831 const SVC: &str = "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
837
838 fn try_load_env(body: &str, env: &str) -> Result<Plan, Error> {
839 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
840 let dir = std::env::temp_dir().join(format!("tonin-plan-dep-{}-{n}", std::process::id()));
841 std::fs::create_dir_all(&dir).unwrap();
842 let path = dir.join("tonin.toml");
843 std::fs::write(&path, body).unwrap();
844 let plan = Plan::load_with_env(&path, env);
845 let _ = std::fs::remove_dir_all(&dir);
846 plan
847 }
848
849 fn dep(plan: &Plan, name: &str) -> Option<String> {
850 plan.depends_on
851 .iter()
852 .find(|d| d.name == name)
853 .map(|d| d.namespace.clone())
854 }
855
856 #[test]
857 fn depends_on_literal_is_backward_compatible() {
858 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-dev\"\n"].concat();
859 let p = try_load_env(&body, "prod").unwrap();
860 assert_eq!(dep(&p, "identity").as_deref(), Some("agnitiv-dev"));
862 }
863
864 #[test]
865 fn depends_on_env_placeholder_resolves_per_env() {
866 let body = [
867 SVC,
868 "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{env}\"\n[depends_on]\nidentity = \"agnitiv-{env}\"\n",
869 ]
870 .concat();
871 let dev = try_load_env(&body, "dev").unwrap();
872 assert_eq!(dev.namespace, "agnitiv-dev");
873 assert_eq!(dep(&dev, "identity").as_deref(), Some("agnitiv-dev"));
874 let prod = try_load_env(&body, "prod").unwrap();
875 assert_eq!(prod.namespace, "agnitiv-prod");
876 assert_eq!(dep(&prod, "identity").as_deref(), Some("agnitiv-prod"));
877 }
878
879 #[test]
880 fn depends_on_table_per_env_override_wins() {
881 let body = [
882 SVC,
883 "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar = { namespace = \"zradar-{env}\", prod = \"zradar-shared\" }\n",
884 ]
885 .concat();
886 assert_eq!(
887 dep(&try_load_env(&body, "dev").unwrap(), "zradar").as_deref(),
888 Some("zradar-dev")
889 );
890 assert_eq!(
891 dep(&try_load_env(&body, "prod").unwrap(), "zradar").as_deref(),
892 Some("zradar-shared")
893 );
894 }
895
896 #[test]
897 fn depends_on_envs_whitelist_scopes_dependency() {
898 let body = [
899 SVC,
900 "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\naudit = { namespace = \"security-{env}\", envs = [\"prod\"] }\n",
901 ]
902 .concat();
903 assert!(
904 dep(&try_load_env(&body, "dev").unwrap(), "audit").is_none(),
905 "absent in dev"
906 );
907 assert_eq!(
908 dep(&try_load_env(&body, "prod").unwrap(), "audit").as_deref(),
909 Some("security-prod")
910 );
911 }
912
913 #[test]
914 fn depends_on_inherit_is_omitted_from_output() {
915 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"@inherit\" }\n"].concat();
916 assert!(dep(&try_load_env(&body, "prod").unwrap(), "billing").is_none());
917 }
918
919 #[test]
920 fn depends_on_unresolved_placeholder_is_error() {
921 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-{environment}\"\n"].concat();
922 let err = try_load_env(&body, "prod").unwrap_err();
923 assert!(
924 matches!(err, Error::UnresolvedNamespace { .. }),
925 "got {err:?}"
926 );
927 }
928
929 #[test]
930 fn depends_on_missing_namespace_for_env_is_error() {
931 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { dev = \"agnitiv-dev\" }\n"].concat();
934 let err = try_load_env(&body, "prod").unwrap_err();
935 assert!(
936 matches!(err, Error::InvalidDependency { .. }),
937 "got {err:?}"
938 );
939 }
940
941 #[test]
942 fn depends_on_bad_type_is_error() {
943 let body = [
944 SVC,
945 "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = 123\n",
946 ]
947 .concat();
948 let err = try_load_env(&body, "prod").unwrap_err();
949 assert!(
950 matches!(err, Error::InvalidDependency { .. }),
951 "got {err:?}"
952 );
953 }
954
955 #[test]
956 fn deploy_namespace_unresolved_placeholder_is_error() {
957 let body = [
958 SVC,
959 "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{cluster}\"\n",
960 ]
961 .concat();
962 let err = try_load_env(&body, "prod").unwrap_err();
963 assert!(
964 matches!(err, Error::UnresolvedNamespace { .. }),
965 "got {err:?}"
966 );
967 }
968}