use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::stateful::{
self, CacheSpec, ConfigSpec, DatabaseSpec, EmittedEnv, MigrationsSpec, RawCache, RawCallers,
RawConfigBlock, RawDatabase, RawMigrations, RawSecrets, SecretsSpec,
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("reading {0}: {1}")]
Io(PathBuf, #[source] std::io::Error),
#[error("parsing {0}: {1}")]
Toml(PathBuf, #[source] toml::de::Error),
#[error(
"{path}: schema = {found:?} is not supported by this CLI. \
Supported schemas: {supported:?}. \
Upgrade the CLI, or set `schema = \"{current}\"` at the top of tonin.toml."
)]
UnsupportedSchema {
path: PathBuf,
found: String,
supported: Vec<String>,
current: String,
},
#[error("depends_on.{name}: {reason}")]
InvalidDependency { name: String, reason: String },
#[error(
"{context}: namespace {value:?} has an unresolved placeholder \
(only `{{env}}` is supported)"
)]
UnresolvedNamespace { context: String, value: String },
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Mesh {
#[default]
Cilium,
Istio,
Linkerd,
None,
}
impl Mesh {
pub fn as_str(&self) -> &'static str {
match self {
Mesh::Cilium => "cilium",
Mesh::Istio => "istio",
Mesh::Linkerd => "linkerd",
Mesh::None => "none",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ServiceRef {
pub name: String,
pub namespace: String,
}
impl ServiceRef {
pub fn identity(&self) -> String {
format!("{}.{}", self.name, self.namespace)
}
}
struct DepSpec {
namespace: Option<String>,
env_overrides: BTreeMap<String, String>,
envs: Option<Vec<String>>,
}
pub(crate) fn apply_env(pattern: &str, env: &str) -> String {
pattern.replace("{env}", env)
}
fn ensure_resolved(context: &str, value: &str) -> Result<(), Error> {
if value.contains('{') || value.contains('}') {
return Err(Error::UnresolvedNamespace {
context: context.to_string(),
value: value.to_string(),
});
}
Ok(())
}
fn parse_dependency(name: &str, value: toml::Value) -> Result<DepSpec, Error> {
let invalid = |reason: String| Error::InvalidDependency {
name: name.to_string(),
reason,
};
match value {
toml::Value::String(s) => Ok(DepSpec {
namespace: Some(s),
env_overrides: BTreeMap::new(),
envs: None,
}),
toml::Value::Table(table) => {
let mut namespace = None;
let mut envs = None;
let mut env_overrides = BTreeMap::new();
for (key, val) in table {
match key.as_str() {
"namespace" => {
namespace = Some(
val.as_str()
.ok_or_else(|| invalid("`namespace` must be a string".into()))?
.to_string(),
);
}
"envs" => {
let arr = val
.as_array()
.ok_or_else(|| invalid("`envs` must be an array of strings".into()))?;
let mut list = Vec::with_capacity(arr.len());
for item in arr {
list.push(
item.as_str()
.ok_or_else(|| {
invalid("`envs` must be an array of strings".into())
})?
.to_string(),
);
}
envs = Some(list);
}
other => {
let ns = val.as_str().ok_or_else(|| {
invalid(format!("override `{other}` must be a namespace string"))
})?;
env_overrides.insert(other.to_string(), ns.to_string());
}
}
}
Ok(DepSpec {
namespace,
env_overrides,
envs,
})
}
other => Err(invalid(format!(
"expected a namespace string or a table, got {}",
other.type_str()
))),
}
}
fn resolve_depends_on(
raw: BTreeMap<String, toml::Value>,
env: &str,
) -> Result<Vec<ServiceRef>, Error> {
let mut out = Vec::new();
for (name, value) in raw {
let spec = parse_dependency(&name, value)?;
if let Some(envs) = &spec.envs
&& !envs.iter().any(|e| e == env)
{
continue; }
let Some(pattern) = spec.env_overrides.get(env).or(spec.namespace.as_ref()) else {
return Err(Error::InvalidDependency {
name: name.clone(),
reason: format!(
"has no namespace for env '{env}' \
(set {name}.{env}, use \"{{env}}\", or mark \"@inherit\")"
),
});
};
let resolved = apply_env(pattern, env);
if resolved == "@inherit" {
continue; }
ensure_resolved(&format!("depends_on.{name}"), &resolved)?;
if resolved.is_empty() {
return Err(Error::InvalidDependency {
name: name.clone(),
reason: format!("namespace for env '{env}' is empty"),
});
}
out.push(ServiceRef {
name,
namespace: resolved,
});
}
Ok(out)
}
pub const CURRENT_SCHEMA: &str = "v1";
pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
pub const RECOMMENDED_CLI_MIN: &str = "0.6.0";
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
schema: Option<String>,
service: RawService,
deploy: RawDeploy,
resources: RawResources,
#[serde(default)]
autoscale: Option<RawAutoscale>,
#[serde(default)]
depends_on: BTreeMap<String, toml::Value>,
#[serde(default)]
callers: RawCallers,
#[serde(default)]
database: Option<RawDatabase>,
#[serde(default)]
databases: std::collections::BTreeMap<String, RawDatabase>,
#[serde(default)]
cache: Option<RawCache>,
#[serde(default)]
caches: std::collections::BTreeMap<String, RawCache>,
#[serde(default)]
secrets: Option<RawSecrets>,
#[serde(default)]
migrations: Option<RawMigrations>,
#[serde(default)]
config: Option<RawConfigBlock>,
#[serde(default)]
client: Option<RawClientConfig>,
}
#[derive(Debug, Deserialize)]
struct RawService {
name: String,
version: String,
#[serde(default)]
language: Option<String>,
#[serde(default, rename = "type")]
kind: Option<String>,
#[serde(default)]
web_mode: Option<String>,
#[serde(default)]
#[allow(dead_code)]
codec: Option<String>,
#[serde(default)]
port: Option<u32>,
#[serde(default)]
health: Option<RawHealth>,
#[serde(default)]
http: Option<RawHttpEndpoint>,
}
#[derive(Debug, Deserialize)]
struct RawHealth {
#[serde(default)]
path: Option<String>,
#[serde(default)]
port: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawHttpEndpoint {
port: u32,
#[serde(default)]
health_path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawDeploy {
replicas: u32,
#[serde(default)]
mesh: Option<Mesh>,
#[serde(default = "default_true")]
mcp_sidecar: bool,
namespace: String,
#[serde(default)]
expose: Option<String>,
#[serde(default, flatten)]
envs: std::collections::BTreeMap<String, RawDeployEnv>,
}
#[derive(Debug, Deserialize, Default)]
struct RawDeployEnv {
#[serde(default)]
replicas: Option<u32>,
#[serde(default)]
namespace: Option<String>,
#[serde(default)]
mesh: Option<Mesh>,
#[serde(default)]
mcp_sidecar: Option<bool>,
#[serde(default)]
expose: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawResources {
cpu: String,
memory: String,
}
#[derive(Debug, Deserialize)]
struct RawAutoscale {
max_replicas: u32,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Default, Deserialize)]
struct RawClientConfig {
#[serde(default = "default_true")]
coalesce: bool,
#[serde(default)]
cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
}
#[derive(Debug, Deserialize)]
struct RawMethodCacheConfig {
ttl_ms: u64,
#[serde(default = "default_cache_capacity")]
capacity: usize,
}
fn default_cache_capacity() -> usize {
1_000
}
#[derive(Clone, Debug, Serialize)]
pub struct MethodCacheSpec {
pub ttl_ms: u64,
pub capacity: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct ClientSpec {
pub coalesce: bool,
pub caches: Vec<(String, MethodCacheSpec)>,
}
impl Default for ClientSpec {
fn default() -> Self {
Self {
coalesce: true,
caches: Vec::new(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceKind {
Backend,
Web,
Http,
}
impl ServiceKind {
pub fn as_str(&self) -> &'static str {
match self {
ServiceKind::Backend => "backend",
ServiceKind::Web => "web",
ServiceKind::Http => "http",
}
}
pub fn is_web(&self) -> bool {
matches!(self, ServiceKind::Web)
}
pub fn is_http(&self) -> bool {
matches!(self, ServiceKind::Http)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct HealthSpec {
pub path: String,
pub port: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum WebMode {
Spa,
Bff,
}
impl WebMode {
pub fn as_str(&self) -> &'static str {
match self {
WebMode::Spa => "spa",
WebMode::Bff => "bff",
}
}
pub fn container_port(&self) -> u32 {
match self {
WebMode::Spa => 8080,
WebMode::Bff => 3000,
}
}
}
#[derive(Clone, Debug)]
pub struct Plan {
pub name: String,
pub version: String,
pub language: String,
pub kind: ServiceKind,
pub web_mode: Option<WebMode>,
pub port: u32,
pub http_port: Option<u32>,
pub health: Option<HealthSpec>,
pub namespace: String,
pub mesh: Mesh,
pub replicas: u32,
pub max_replicas: u32,
pub mcp_sidecar: bool,
pub expose: Option<String>,
pub cpu: String,
pub memory: String,
pub image: String,
pub depends_on: Vec<ServiceRef>,
pub callers: Vec<ServiceRef>,
pub dir: PathBuf,
pub database: Option<DatabaseSpec>,
pub named_databases: Vec<(String, DatabaseSpec)>,
pub cache: Option<CacheSpec>,
pub named_caches: Vec<(String, CacheSpec)>,
pub secrets: Option<SecretsSpec>,
pub migrations: Option<MigrationsSpec>,
pub config: Option<ConfigSpec>,
pub emitted_env: EmittedEnv,
pub selected_env: String,
pub client: ClientSpec,
}
impl Plan {
pub fn load(toml_path: &Path) -> Result<Self, Error> {
Self::load_with_env(toml_path, &stateful::select_env(None))
}
pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
let raw_str = std::fs::read_to_string(toml_path)
.map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
let raw: RawConfig =
toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
if let Some(v) = raw.schema.as_deref()
&& !SUPPORTED_SCHEMAS.contains(&v)
{
return Err(Error::UnsupportedSchema {
path: toml_path.to_path_buf(),
found: v.to_string(),
supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
current: CURRENT_SCHEMA.to_string(),
});
}
let depends_on = resolve_depends_on(raw.depends_on, env)?;
let explicit_callers = stateful::resolve_callers(&raw.callers, env);
let deploy_overlay = raw.deploy.envs.get(env);
let deploy_replicas = deploy_overlay
.and_then(|o| o.replicas)
.unwrap_or(raw.deploy.replicas);
let deploy_namespace = {
let raw_ns = deploy_overlay
.and_then(|o| o.namespace.clone())
.unwrap_or(raw.deploy.namespace);
let ns = apply_env(&raw_ns, env);
ensure_resolved("deploy.namespace", &ns)?;
ns
};
let deploy_mesh = deploy_overlay
.and_then(|o| o.mesh)
.or(raw.deploy.mesh)
.unwrap_or_default();
let deploy_mcp_sidecar = deploy_overlay
.and_then(|o| o.mcp_sidecar)
.unwrap_or(raw.deploy.mcp_sidecar);
let deploy_expose = deploy_overlay
.and_then(|o| o.expose.clone())
.or(raw.deploy.expose);
let max_replicas = raw
.autoscale
.as_ref()
.map(|a| a.max_replicas)
.unwrap_or(deploy_replicas);
let dir = toml_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let image = std::env::var("TONIN_IMAGE_PREFIX")
.map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
.unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
let kind = match raw.service.kind.as_deref() {
Some("web") => ServiceKind::Web,
Some("http") => ServiceKind::Http,
_ => ServiceKind::Backend,
};
let web_mode = match (kind, raw.service.web_mode.as_deref()) {
(ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
(ServiceKind::Web, _) => Some(WebMode::Spa),
_ => None,
};
let port = raw.service.port.unwrap_or_else(|| match kind {
ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
ServiceKind::Http => 8080,
ServiceKind::Backend => 50051,
});
let http_port = match kind {
ServiceKind::Http => None,
_ => raw.service.http.as_ref().map(|h| h.port),
};
let http_probe_port = match kind {
ServiceKind::Http => Some(port),
_ => http_port,
};
let health = if raw.service.health.is_some() || http_probe_port.is_some() {
let declared = raw.service.health.as_ref();
let secondary = raw.service.http.as_ref();
Some(HealthSpec {
path: declared
.and_then(|h| h.path.clone())
.or_else(|| secondary.and_then(|h| h.health_path.clone()))
.unwrap_or_else(|| "/health".into()),
port: declared
.and_then(|h| h.port)
.or(http_probe_port)
.unwrap_or(port),
})
} else {
None
};
let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
let svc_name = raw.service.name.clone();
let svc_ns = deploy_namespace.clone();
let database = raw
.database
.as_ref()
.map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
let named_databases: Vec<(String, DatabaseSpec)> = raw
.databases
.iter()
.map(|(name, r)| {
(
name.clone(),
stateful::resolve_database(r, env, &svc_name, &svc_ns),
)
})
.collect();
let cache = raw
.cache
.as_ref()
.map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
let named_caches: Vec<(String, CacheSpec)> = raw
.caches
.iter()
.map(|(name, r)| {
(
name.clone(),
stateful::resolve_cache(r, env, &svc_name, &svc_ns),
)
})
.collect();
let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
let config = raw.config.as_ref().map(stateful::resolve_config);
let client = raw
.client
.map(|c| {
let mut caches: Vec<(String, MethodCacheSpec)> = c
.cache
.into_iter()
.map(|(method, mc)| {
(
method,
MethodCacheSpec {
ttl_ms: mc.ttl_ms,
capacity: mc.capacity,
},
)
})
.collect();
caches.sort_by(|a, b| a.0.cmp(&b.0));
ClientSpec {
coalesce: c.coalesce,
caches,
}
})
.unwrap_or_default();
let mut emitted_env = EmittedEnv::default();
if let Some(d) = &database {
emitted_env.extend_database(d, &svc_name);
}
for (name, d) in &named_databases {
let prefix = format!("{}_DATABASE", name.to_uppercase());
emitted_env.extend_database_named(&prefix, d, &svc_name);
}
if let Some(c) = &cache {
emitted_env.extend_cache(c);
}
for (name, c) in &named_caches {
let prefix = format!("{}_REDIS", name.to_uppercase());
emitted_env.extend_cache_named(&prefix, c);
}
if let Some(s) = &secrets {
emitted_env.extend_secrets(s);
}
Ok(Plan {
name: raw.service.name,
version: raw.service.version,
language: raw.service.language.unwrap_or_else(|| "rust".into()),
kind,
web_mode,
port,
http_port,
health,
namespace: deploy_namespace,
mesh: deploy_mesh,
replicas: deploy_replicas,
max_replicas,
mcp_sidecar,
expose: deploy_expose,
cpu: raw.resources.cpu,
memory: raw.resources.memory,
image,
depends_on,
callers: explicit_callers,
dir,
database,
named_databases,
cache,
named_caches,
secrets,
migrations,
config,
client,
emitted_env,
selected_env: env.to_string(),
})
}
pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
Self::load_workspace_with_env(root, &stateful::select_env(None))
}
pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_name() == "tonin.toml")
.map(|e| Plan::load_with_env(e.path(), env))
.collect::<Result<_, _>>()?;
let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
.iter()
.map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
.collect();
for plan in plans.iter_mut() {
for (caller_name, caller_ns, deps) in &snapshot {
if deps
.iter()
.any(|d| d.name == plan.name && d.namespace == plan.namespace)
{
plan.callers.push(ServiceRef {
name: caller_name.clone(),
namespace: caller_ns.clone(),
});
}
}
plan.callers.sort();
plan.callers.dedup();
}
plans.sort_by(|a, b| a.name.cmp(&b.name));
Ok(plans)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
fn load(service: &str) -> Plan {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tonin.toml");
std::fs::write(&path, format!("{service}{BASE}")).unwrap();
let plan = Plan::load_with_env(&path, "prod").unwrap();
let _ = std::fs::remove_dir_all(&dir);
plan
}
#[test]
fn backend_defaults_unchanged() {
let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
assert_eq!(p.kind, ServiceKind::Backend);
assert_eq!(p.port, 50051);
assert_eq!(p.http_port, None);
assert!(p.health.is_none());
assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
}
#[test]
fn backend_port_override() {
let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
assert_eq!(p.port, 9090);
}
#[test]
fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
assert_eq!(p.kind, ServiceKind::Http);
assert_eq!(p.port, 8080);
let h = p.health.expect("http services get a default probe");
assert_eq!(h.path, "/health");
assert_eq!(h.port, 8080);
assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
}
#[test]
fn http_explicit_port_and_health_path() {
let p = load(
"[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
);
assert_eq!(p.port, 7001);
let h = p.health.unwrap();
assert_eq!(h.path, "/healthz");
assert_eq!(h.port, 7001);
}
#[test]
fn backend_with_http_exposes_both() {
let p = load(
"[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
);
assert_eq!(p.kind, ServiceKind::Backend);
assert_eq!(p.port, 50051, "gRPC primary port preserved");
assert_eq!(p.http_port, Some(8081));
let h = p.health.unwrap();
assert_eq!(h.path, "/healthz");
assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
}
const SVC: &str = "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
fn try_load_env(body: &str, env: &str) -> Result<Plan, Error> {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("tonin-plan-dep-{}-{n}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("tonin.toml");
std::fs::write(&path, body).unwrap();
let plan = Plan::load_with_env(&path, env);
let _ = std::fs::remove_dir_all(&dir);
plan
}
fn dep(plan: &Plan, name: &str) -> Option<String> {
plan.depends_on
.iter()
.find(|d| d.name == name)
.map(|d| d.namespace.clone())
}
#[test]
fn depends_on_literal_is_backward_compatible() {
let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-dev\"\n"].concat();
let p = try_load_env(&body, "prod").unwrap();
assert_eq!(dep(&p, "identity").as_deref(), Some("agnitiv-dev"));
}
#[test]
fn depends_on_env_placeholder_resolves_per_env() {
let body = [
SVC,
"[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{env}\"\n[depends_on]\nidentity = \"agnitiv-{env}\"\n",
]
.concat();
let dev = try_load_env(&body, "dev").unwrap();
assert_eq!(dev.namespace, "agnitiv-dev");
assert_eq!(dep(&dev, "identity").as_deref(), Some("agnitiv-dev"));
let prod = try_load_env(&body, "prod").unwrap();
assert_eq!(prod.namespace, "agnitiv-prod");
assert_eq!(dep(&prod, "identity").as_deref(), Some("agnitiv-prod"));
}
#[test]
fn depends_on_table_per_env_override_wins() {
let body = [
SVC,
"[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar = { namespace = \"zradar-{env}\", prod = \"zradar-shared\" }\n",
]
.concat();
assert_eq!(
dep(&try_load_env(&body, "dev").unwrap(), "zradar").as_deref(),
Some("zradar-dev")
);
assert_eq!(
dep(&try_load_env(&body, "prod").unwrap(), "zradar").as_deref(),
Some("zradar-shared")
);
}
#[test]
fn depends_on_envs_whitelist_scopes_dependency() {
let body = [
SVC,
"[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\naudit = { namespace = \"security-{env}\", envs = [\"prod\"] }\n",
]
.concat();
assert!(
dep(&try_load_env(&body, "dev").unwrap(), "audit").is_none(),
"absent in dev"
);
assert_eq!(
dep(&try_load_env(&body, "prod").unwrap(), "audit").as_deref(),
Some("security-prod")
);
}
#[test]
fn depends_on_inherit_is_omitted_from_output() {
let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"@inherit\" }\n"].concat();
assert!(dep(&try_load_env(&body, "prod").unwrap(), "billing").is_none());
}
#[test]
fn depends_on_unresolved_placeholder_is_error() {
let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-{environment}\"\n"].concat();
let err = try_load_env(&body, "prod").unwrap_err();
assert!(
matches!(err, Error::UnresolvedNamespace { .. }),
"got {err:?}"
);
}
#[test]
fn depends_on_missing_namespace_for_env_is_error() {
let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { dev = \"agnitiv-dev\" }\n"].concat();
let err = try_load_env(&body, "prod").unwrap_err();
assert!(
matches!(err, Error::InvalidDependency { .. }),
"got {err:?}"
);
}
#[test]
fn depends_on_bad_type_is_error() {
let body = [
SVC,
"[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = 123\n",
]
.concat();
let err = try_load_env(&body, "prod").unwrap_err();
assert!(
matches!(err, Error::InvalidDependency { .. }),
"got {err:?}"
);
}
#[test]
fn deploy_namespace_unresolved_placeholder_is_error() {
let body = [
SVC,
"[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{cluster}\"\n",
]
.concat();
let err = try_load_env(&body, "prod").unwrap_err();
assert!(
matches!(err, Error::UnresolvedNamespace { .. }),
"got {err:?}"
);
}
}