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.5.6";
231
232#[derive(Debug, Deserialize)]
233struct RawConfig {
234 #[serde(default)]
235 schema: Option<String>,
236 service: RawService,
237 deploy: RawDeploy,
238 resources: RawResources,
239 #[serde(default)]
240 autoscale: Option<RawAutoscale>,
241 #[serde(default)]
245 depends_on: BTreeMap<String, toml::Value>,
246 #[serde(default)]
247 callers: RawCallers,
248 #[serde(default)]
249 database: Option<RawDatabase>,
250 #[serde(default)]
251 databases: std::collections::BTreeMap<String, RawDatabase>,
252 #[serde(default)]
253 cache: Option<RawCache>,
254 #[serde(default)]
255 caches: std::collections::BTreeMap<String, RawCache>,
256 #[serde(default)]
257 secrets: Option<RawSecrets>,
258 #[serde(default)]
259 migrations: Option<RawMigrations>,
260 #[serde(default)]
261 config: Option<RawConfigBlock>,
262 #[serde(default)]
263 client: Option<RawClientConfig>,
264}
265
266#[derive(Debug, Deserialize)]
267struct RawService {
268 name: String,
269 version: String,
270 #[serde(default)]
271 language: Option<String>,
272 #[serde(default, rename = "type")]
273 kind: Option<String>,
274 #[serde(default)]
275 web_mode: Option<String>,
276 #[serde(default)]
277 #[allow(dead_code)]
278 codec: Option<String>,
279 #[serde(default)]
282 port: Option<u32>,
283 #[serde(default)]
285 health: Option<RawHealth>,
286 #[serde(default)]
289 http: Option<RawHttpEndpoint>,
290}
291
292#[derive(Debug, Deserialize)]
293struct RawHealth {
294 #[serde(default)]
295 path: Option<String>,
296 #[serde(default)]
297 port: Option<u32>,
298}
299
300#[derive(Debug, Deserialize)]
301struct RawHttpEndpoint {
302 port: u32,
303 #[serde(default)]
304 health_path: Option<String>,
305}
306
307#[derive(Debug, Deserialize)]
308struct RawDeploy {
309 replicas: u32,
310 #[serde(default)]
311 mesh: Option<Mesh>,
312 #[serde(default = "default_true")]
313 mcp_sidecar: bool,
314 namespace: String,
315 #[serde(default)]
316 expose: Option<String>,
317 #[serde(default, flatten)]
318 envs: std::collections::BTreeMap<String, RawDeployEnv>,
319}
320
321#[derive(Debug, Deserialize, Default)]
322struct RawDeployEnv {
323 #[serde(default)]
324 replicas: Option<u32>,
325 #[serde(default)]
326 namespace: Option<String>,
327 #[serde(default)]
328 mesh: Option<Mesh>,
329 #[serde(default)]
330 mcp_sidecar: Option<bool>,
331 #[serde(default)]
332 expose: Option<String>,
333}
334
335#[derive(Debug, Deserialize)]
336struct RawResources {
337 cpu: String,
338 memory: String,
339}
340
341#[derive(Debug, Deserialize)]
342struct RawAutoscale {
343 max_replicas: u32,
344}
345
346fn default_true() -> bool {
347 true
348}
349
350#[derive(Debug, Default, Deserialize)]
351struct RawClientConfig {
352 #[serde(default = "default_true")]
353 coalesce: bool,
354 #[serde(default)]
355 cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
356}
357
358#[derive(Debug, Deserialize)]
359struct RawMethodCacheConfig {
360 ttl_ms: u64,
361 #[serde(default = "default_cache_capacity")]
362 capacity: usize,
363}
364
365fn default_cache_capacity() -> usize {
366 1_000
367}
368
369#[derive(Clone, Debug, Serialize)]
370pub struct MethodCacheSpec {
371 pub ttl_ms: u64,
372 pub capacity: usize,
373}
374
375#[derive(Clone, Debug, Serialize)]
376pub struct ClientSpec {
377 pub coalesce: bool,
378 pub caches: Vec<(String, MethodCacheSpec)>,
379}
380
381impl Default for ClientSpec {
382 fn default() -> Self {
383 Self {
384 coalesce: true,
385 caches: Vec::new(),
386 }
387 }
388}
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
393#[serde(rename_all = "lowercase")]
394pub enum ServiceKind {
395 Backend,
396 Web,
397 Http,
398}
399
400impl ServiceKind {
401 pub fn as_str(&self) -> &'static str {
402 match self {
403 ServiceKind::Backend => "backend",
404 ServiceKind::Web => "web",
405 ServiceKind::Http => "http",
406 }
407 }
408 pub fn is_web(&self) -> bool {
409 matches!(self, ServiceKind::Web)
410 }
411 pub fn is_http(&self) -> bool {
412 matches!(self, ServiceKind::Http)
413 }
414}
415
416#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
418pub struct HealthSpec {
419 pub path: String,
420 pub port: u32,
421}
422
423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
424#[serde(rename_all = "lowercase")]
425pub enum WebMode {
426 Spa,
427 Bff,
428}
429
430impl WebMode {
431 pub fn as_str(&self) -> &'static str {
432 match self {
433 WebMode::Spa => "spa",
434 WebMode::Bff => "bff",
435 }
436 }
437 pub fn container_port(&self) -> u32 {
438 match self {
439 WebMode::Spa => 8080,
440 WebMode::Bff => 3000,
441 }
442 }
443}
444
445#[derive(Clone, Debug)]
446pub struct Plan {
447 pub name: String,
448 pub version: String,
449 pub language: String,
450 pub kind: ServiceKind,
451 pub web_mode: Option<WebMode>,
452 pub port: u32,
454 pub http_port: Option<u32>,
456 pub health: Option<HealthSpec>,
458 pub namespace: String,
459 pub mesh: Mesh,
460 pub replicas: u32,
461 pub max_replicas: u32,
462 pub mcp_sidecar: bool,
463 pub expose: Option<String>,
464 pub cpu: String,
465 pub memory: String,
466 pub image: String,
467 pub depends_on: Vec<ServiceRef>,
468 pub callers: Vec<ServiceRef>,
469 pub dir: PathBuf,
470 pub database: Option<DatabaseSpec>,
471 pub named_databases: Vec<(String, DatabaseSpec)>,
472 pub cache: Option<CacheSpec>,
473 pub named_caches: Vec<(String, CacheSpec)>,
474 pub secrets: Option<SecretsSpec>,
475 pub migrations: Option<MigrationsSpec>,
476 pub config: Option<ConfigSpec>,
477 pub emitted_env: EmittedEnv,
478 pub selected_env: String,
479 pub client: ClientSpec,
480}
481
482impl Plan {
483 pub fn load(toml_path: &Path) -> Result<Self, Error> {
484 Self::load_with_env(toml_path, &stateful::select_env(None))
485 }
486
487 pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
488 let raw_str = std::fs::read_to_string(toml_path)
489 .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
490 let raw: RawConfig =
491 toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
492
493 if let Some(v) = raw.schema.as_deref()
494 && !SUPPORTED_SCHEMAS.contains(&v)
495 {
496 return Err(Error::UnsupportedSchema {
497 path: toml_path.to_path_buf(),
498 found: v.to_string(),
499 supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
500 current: CURRENT_SCHEMA.to_string(),
501 });
502 }
503
504 let depends_on = resolve_depends_on(raw.depends_on, env)?;
505
506 let explicit_callers = stateful::resolve_callers(&raw.callers, env);
507
508 let deploy_overlay = raw.deploy.envs.get(env);
509 let deploy_replicas = deploy_overlay
510 .and_then(|o| o.replicas)
511 .unwrap_or(raw.deploy.replicas);
512 let deploy_namespace = {
513 let raw_ns = deploy_overlay
514 .and_then(|o| o.namespace.clone())
515 .unwrap_or(raw.deploy.namespace);
516 let ns = apply_env(&raw_ns, env);
517 ensure_resolved("deploy.namespace", &ns)?;
518 ns
519 };
520 let deploy_mesh = deploy_overlay
521 .and_then(|o| o.mesh)
522 .or(raw.deploy.mesh)
523 .unwrap_or_default();
524 let deploy_mcp_sidecar = deploy_overlay
525 .and_then(|o| o.mcp_sidecar)
526 .unwrap_or(raw.deploy.mcp_sidecar);
527 let deploy_expose = deploy_overlay
528 .and_then(|o| o.expose.clone())
529 .or(raw.deploy.expose);
530
531 let max_replicas = raw
532 .autoscale
533 .as_ref()
534 .map(|a| a.max_replicas)
535 .unwrap_or(deploy_replicas);
536
537 let dir = toml_path
538 .parent()
539 .map(Path::to_path_buf)
540 .unwrap_or_else(|| PathBuf::from("."));
541
542 let image = std::env::var("TONIN_IMAGE_PREFIX")
543 .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
544 .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
545
546 let kind = match raw.service.kind.as_deref() {
547 Some("web") => ServiceKind::Web,
548 Some("http") => ServiceKind::Http,
549 _ => ServiceKind::Backend,
550 };
551 let web_mode = match (kind, raw.service.web_mode.as_deref()) {
552 (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
553 (ServiceKind::Web, _) => Some(WebMode::Spa),
554 _ => None,
555 };
556
557 let port = raw.service.port.unwrap_or_else(|| match kind {
560 ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
561 ServiceKind::Http => 8080,
562 ServiceKind::Backend => 50051,
563 });
564
565 let http_port = match kind {
569 ServiceKind::Http => None,
570 _ => raw.service.http.as_ref().map(|h| h.port),
571 };
572
573 let http_probe_port = match kind {
576 ServiceKind::Http => Some(port),
577 _ => http_port,
578 };
579
580 let health = if raw.service.health.is_some() || http_probe_port.is_some() {
586 let declared = raw.service.health.as_ref();
587 let secondary = raw.service.http.as_ref();
588 Some(HealthSpec {
589 path: declared
590 .and_then(|h| h.path.clone())
591 .or_else(|| secondary.and_then(|h| h.health_path.clone()))
592 .unwrap_or_else(|| "/health".into()),
593 port: declared
594 .and_then(|h| h.port)
595 .or(http_probe_port)
596 .unwrap_or(port),
597 })
598 } else {
599 None
600 };
601
602 let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
605
606 let svc_name = raw.service.name.clone();
607 let svc_ns = deploy_namespace.clone();
608 let database = raw
609 .database
610 .as_ref()
611 .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
612 let named_databases: Vec<(String, DatabaseSpec)> = raw
613 .databases
614 .iter()
615 .map(|(name, r)| {
616 (
617 name.clone(),
618 stateful::resolve_database(r, env, &svc_name, &svc_ns),
619 )
620 })
621 .collect();
622 let cache = raw
623 .cache
624 .as_ref()
625 .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
626 let named_caches: Vec<(String, CacheSpec)> = raw
627 .caches
628 .iter()
629 .map(|(name, r)| {
630 (
631 name.clone(),
632 stateful::resolve_cache(r, env, &svc_name, &svc_ns),
633 )
634 })
635 .collect();
636 let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
637 let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
638 let config = raw.config.as_ref().map(stateful::resolve_config);
639
640 let client = raw
641 .client
642 .map(|c| {
643 let mut caches: Vec<(String, MethodCacheSpec)> = c
644 .cache
645 .into_iter()
646 .map(|(method, mc)| {
647 (
648 method,
649 MethodCacheSpec {
650 ttl_ms: mc.ttl_ms,
651 capacity: mc.capacity,
652 },
653 )
654 })
655 .collect();
656 caches.sort_by(|a, b| a.0.cmp(&b.0));
657 ClientSpec {
658 coalesce: c.coalesce,
659 caches,
660 }
661 })
662 .unwrap_or_default();
663
664 let mut emitted_env = EmittedEnv::default();
665 if let Some(d) = &database {
666 emitted_env.extend_database(d, &svc_name);
667 }
668 for (name, d) in &named_databases {
669 let prefix = format!("{}_DATABASE", name.to_uppercase());
670 emitted_env.extend_database_named(&prefix, d, &svc_name);
671 }
672 if let Some(c) = &cache {
673 emitted_env.extend_cache(c);
674 }
675 for (name, c) in &named_caches {
676 let prefix = format!("{}_REDIS", name.to_uppercase());
677 emitted_env.extend_cache_named(&prefix, c);
678 }
679 if let Some(s) = &secrets {
680 emitted_env.extend_secrets(s);
681 }
682
683 Ok(Plan {
684 name: raw.service.name,
685 version: raw.service.version,
686 language: raw.service.language.unwrap_or_else(|| "rust".into()),
687 kind,
688 web_mode,
689 port,
690 http_port,
691 health,
692 namespace: deploy_namespace,
693 mesh: deploy_mesh,
694 replicas: deploy_replicas,
695 max_replicas,
696 mcp_sidecar,
697 expose: deploy_expose,
698 cpu: raw.resources.cpu,
699 memory: raw.resources.memory,
700 image,
701 depends_on,
702 callers: explicit_callers,
703 dir,
704 database,
705 named_databases,
706 cache,
707 named_caches,
708 secrets,
709 migrations,
710 config,
711 client,
712 emitted_env,
713 selected_env: env.to_string(),
714 })
715 }
716
717 pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
718 Self::load_workspace_with_env(root, &stateful::select_env(None))
719 }
720
721 pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
722 let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
723 .into_iter()
724 .filter_map(|e| e.ok())
725 .filter(|e| e.file_name() == "tonin.toml")
726 .map(|e| Plan::load_with_env(e.path(), env))
727 .collect::<Result<_, _>>()?;
728
729 let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
730 .iter()
731 .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
732 .collect();
733 for plan in plans.iter_mut() {
734 for (caller_name, caller_ns, deps) in &snapshot {
735 if deps
736 .iter()
737 .any(|d| d.name == plan.name && d.namespace == plan.namespace)
738 {
739 plan.callers.push(ServiceRef {
740 name: caller_name.clone(),
741 namespace: caller_ns.clone(),
742 });
743 }
744 }
745 plan.callers.sort();
746 plan.callers.dedup();
747 }
748
749 plans.sort_by(|a, b| a.name.cmp(&b.name));
750 Ok(plans)
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use std::sync::atomic::{AtomicU32, Ordering};
758
759 static COUNTER: AtomicU32 = AtomicU32::new(0);
760
761 const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
762
763 fn load(service: &str) -> Plan {
765 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
766 let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
767 std::fs::create_dir_all(&dir).unwrap();
768 let path = dir.join("tonin.toml");
769 std::fs::write(&path, format!("{service}{BASE}")).unwrap();
770 let plan = Plan::load_with_env(&path, "prod").unwrap();
771 let _ = std::fs::remove_dir_all(&dir);
772 plan
773 }
774
775 #[test]
776 fn backend_defaults_unchanged() {
777 let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
778 assert_eq!(p.kind, ServiceKind::Backend);
779 assert_eq!(p.port, 50051);
780 assert_eq!(p.http_port, None);
781 assert!(p.health.is_none());
782 assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
783 }
784
785 #[test]
786 fn backend_port_override() {
787 let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
788 assert_eq!(p.port, 9090);
789 }
790
791 #[test]
792 fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
793 let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
794 assert_eq!(p.kind, ServiceKind::Http);
795 assert_eq!(p.port, 8080);
796 let h = p.health.expect("http services get a default probe");
797 assert_eq!(h.path, "/health");
798 assert_eq!(h.port, 8080);
799 assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
800 }
801
802 #[test]
803 fn http_explicit_port_and_health_path() {
804 let p = load(
805 "[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
806 );
807 assert_eq!(p.port, 7001);
808 let h = p.health.unwrap();
809 assert_eq!(h.path, "/healthz");
810 assert_eq!(h.port, 7001);
811 }
812
813 #[test]
814 fn backend_with_http_exposes_both() {
815 let p = load(
816 "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
817 );
818 assert_eq!(p.kind, ServiceKind::Backend);
819 assert_eq!(p.port, 50051, "gRPC primary port preserved");
820 assert_eq!(p.http_port, Some(8081));
821 let h = p.health.unwrap();
822 assert_eq!(h.path, "/healthz");
823 assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
824 assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
825 }
826
827 const SVC: &str = "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
833
834 fn try_load_env(body: &str, env: &str) -> Result<Plan, Error> {
835 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
836 let dir = std::env::temp_dir().join(format!("tonin-plan-dep-{}-{n}", std::process::id()));
837 std::fs::create_dir_all(&dir).unwrap();
838 let path = dir.join("tonin.toml");
839 std::fs::write(&path, body).unwrap();
840 let plan = Plan::load_with_env(&path, env);
841 let _ = std::fs::remove_dir_all(&dir);
842 plan
843 }
844
845 fn dep(plan: &Plan, name: &str) -> Option<String> {
846 plan.depends_on
847 .iter()
848 .find(|d| d.name == name)
849 .map(|d| d.namespace.clone())
850 }
851
852 #[test]
853 fn depends_on_literal_is_backward_compatible() {
854 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-dev\"\n"].concat();
855 let p = try_load_env(&body, "prod").unwrap();
856 assert_eq!(dep(&p, "identity").as_deref(), Some("agnitiv-dev"));
858 }
859
860 #[test]
861 fn depends_on_env_placeholder_resolves_per_env() {
862 let body = [
863 SVC,
864 "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{env}\"\n[depends_on]\nidentity = \"agnitiv-{env}\"\n",
865 ]
866 .concat();
867 let dev = try_load_env(&body, "dev").unwrap();
868 assert_eq!(dev.namespace, "agnitiv-dev");
869 assert_eq!(dep(&dev, "identity").as_deref(), Some("agnitiv-dev"));
870 let prod = try_load_env(&body, "prod").unwrap();
871 assert_eq!(prod.namespace, "agnitiv-prod");
872 assert_eq!(dep(&prod, "identity").as_deref(), Some("agnitiv-prod"));
873 }
874
875 #[test]
876 fn depends_on_table_per_env_override_wins() {
877 let body = [
878 SVC,
879 "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar = { namespace = \"zradar-{env}\", prod = \"zradar-shared\" }\n",
880 ]
881 .concat();
882 assert_eq!(
883 dep(&try_load_env(&body, "dev").unwrap(), "zradar").as_deref(),
884 Some("zradar-dev")
885 );
886 assert_eq!(
887 dep(&try_load_env(&body, "prod").unwrap(), "zradar").as_deref(),
888 Some("zradar-shared")
889 );
890 }
891
892 #[test]
893 fn depends_on_envs_whitelist_scopes_dependency() {
894 let body = [
895 SVC,
896 "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\naudit = { namespace = \"security-{env}\", envs = [\"prod\"] }\n",
897 ]
898 .concat();
899 assert!(
900 dep(&try_load_env(&body, "dev").unwrap(), "audit").is_none(),
901 "absent in dev"
902 );
903 assert_eq!(
904 dep(&try_load_env(&body, "prod").unwrap(), "audit").as_deref(),
905 Some("security-prod")
906 );
907 }
908
909 #[test]
910 fn depends_on_inherit_is_omitted_from_output() {
911 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"@inherit\" }\n"].concat();
912 assert!(dep(&try_load_env(&body, "prod").unwrap(), "billing").is_none());
913 }
914
915 #[test]
916 fn depends_on_unresolved_placeholder_is_error() {
917 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-{environment}\"\n"].concat();
918 let err = try_load_env(&body, "prod").unwrap_err();
919 assert!(
920 matches!(err, Error::UnresolvedNamespace { .. }),
921 "got {err:?}"
922 );
923 }
924
925 #[test]
926 fn depends_on_missing_namespace_for_env_is_error() {
927 let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { dev = \"agnitiv-dev\" }\n"].concat();
930 let err = try_load_env(&body, "prod").unwrap_err();
931 assert!(
932 matches!(err, Error::InvalidDependency { .. }),
933 "got {err:?}"
934 );
935 }
936
937 #[test]
938 fn depends_on_bad_type_is_error() {
939 let body = [
940 SVC,
941 "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = 123\n",
942 ]
943 .concat();
944 let err = try_load_env(&body, "prod").unwrap_err();
945 assert!(
946 matches!(err, Error::InvalidDependency { .. }),
947 "got {err:?}"
948 );
949 }
950
951 #[test]
952 fn deploy_namespace_unresolved_placeholder_is_error() {
953 let body = [
954 SVC,
955 "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{cluster}\"\n",
956 ]
957 .concat();
958 let err = try_load_env(&body, "prod").unwrap_err();
959 assert!(
960 matches!(err, Error::UnresolvedNamespace { .. }),
961 "got {err:?}"
962 );
963 }
964}