diff --git a/crates/tonin-plugin/src/lib.rs b/crates/tonin-plugin/src/lib.rs index 5dbaf39..e397d36 100644 --- a/crates/tonin-plugin/src/lib.rs +++ b/crates/tonin-plugin/src/lib.rs @@ -38,9 +38,9 @@ pub use config::ConfigLoader; // Plan loading + schema types pub use plan::{ - CURRENT_SCHEMA, ClientSpec, Error, HealthSpec, McpMode, Mesh, MethodCacheSpec, Plan, - RECOMMENDED_CLI_MIN, SUPPORTED_SCHEMAS, SecuritySection, ServiceKind, ServiceRef, WebMode, - wave_groups, + CURRENT_SCHEMA, ClientSpec, DEFAULT_DEPENDENCY_PORT, DependencyRef, Error, HealthSpec, McpMode, + Mesh, MethodCacheSpec, Plan, RECOMMENDED_CLI_MIN, SUPPORTED_SCHEMAS, SecuritySection, + ServiceKind, ServiceRef, WebMode, wave_groups, }; // Env resolution + resolved stateful types diff --git a/crates/tonin-plugin/src/plan.rs b/crates/tonin-plugin/src/plan.rs index f4d7e52..01bf944 100644 --- a/crates/tonin-plugin/src/plan.rs +++ b/crates/tonin-plugin/src/plan.rs @@ -34,6 +34,13 @@ pub enum Error { (only `{{env}}` is supported)" )] UnresolvedNamespace { context: String, value: String }, + #[error("[env].{key}: {reason}")] + InvalidEnvVar { key: String, reason: String }, + #[error( + "[env].{key} collides with the {origin} env var of the same name that \ + tonin already emits — rename the [env] key or drop the duplicate" + )] + ReservedEnvKey { key: String, origin: String }, } #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -69,10 +76,33 @@ impl ServiceRef { } } +/// Default egress port for a `[depends_on]` entry — the tonin gRPC listen +/// convention. Matches the port the generated network policy hardcoded before +/// ports were declarable, so bare-string dependencies render identically. +pub const DEFAULT_DEPENDENCY_PORT: u32 = 50051; + +fn default_dependency_port() -> u32 { + DEFAULT_DEPENDENCY_PORT +} + +/// One resolved `[depends_on]` entry: the downstream service, the namespace it +/// lives in for the selected environment, and the port egress is allowed on. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct DependencyRef { + pub name: String, + pub namespace: String, + /// Dependency's listen port (`port` in the table form). Defaults to + /// [`DEFAULT_DEPENDENCY_PORT`] so the bare-string shorthand keeps + /// rendering today's egress rule. + #[serde(default = "default_dependency_port")] + pub port: u32, +} + /// One `[depends_on]` entry parsed from TOML, before environment resolution. /// /// Both the shorthand (`name = ""`) and the table form -/// (`name = { namespace = "", = "", envs = [..] }`) land here. +/// (`name = { namespace = "", port =

, = "", envs = [..] }`) +/// land here. struct DepSpec { /// Default namespace pattern (may contain `{env}`), if declared. namespace: Option, @@ -80,6 +110,8 @@ struct DepSpec { env_overrides: BTreeMap, /// Restrict the dependency to these envs; `None` ⇒ every env. envs: Option>, + /// Declared egress port; `None` ⇒ [`DEFAULT_DEPENDENCY_PORT`]. + port: Option, } /// Substitute the `{env}` placeholder in a namespace pattern. @@ -110,11 +142,13 @@ fn parse_dependency(name: &str, value: toml::Value) -> Result { namespace: Some(s), env_overrides: BTreeMap::new(), envs: None, + port: None, }), toml::Value::Table(table) => { let mut namespace = None; let mut envs = None; let mut env_overrides = BTreeMap::new(); + let mut port = None; for (key, val) in table { match key.as_str() { "namespace" => { @@ -124,6 +158,15 @@ fn parse_dependency(name: &str, value: toml::Value) -> Result { .to_string(), ); } + "port" => { + let p = val + .as_integer() + .ok_or_else(|| invalid("`port` must be an integer".into()))?; + if !(1..=65535).contains(&p) { + return Err(invalid(format!("`port` {p} is outside 1-65535"))); + } + port = Some(p as u32); + } "envs" => { let arr = val .as_array() @@ -153,6 +196,7 @@ fn parse_dependency(name: &str, value: toml::Value) -> Result { namespace, env_overrides, envs, + port, }) } other => Err(invalid(format!( @@ -175,7 +219,7 @@ fn parse_dependency(name: &str, value: toml::Value) -> Result { fn resolve_depends_on( raw: BTreeMap, env: &str, -) -> Result, Error> { +) -> Result, Error> { let mut out = Vec::new(); for (name, value) in raw { let spec = parse_dependency(&name, value)?; @@ -204,14 +248,70 @@ fn resolve_depends_on( reason: format!("namespace for env '{env}' is empty"), }); } - out.push(ServiceRef { + out.push(DependencyRef { name, namespace: resolved, + port: spec.port.unwrap_or(DEFAULT_DEPENDENCY_PORT), }); } Ok(out) } +/// Resolve the optional `[env]` table (plain, non-secret runtime env vars) +/// for one environment. +/// +/// - `KEY = "value"` string entries form the base set. +/// - `[env.]` sub-tables override the base for that environment only. +/// - `{env}` in values is substituted with the environment being rendered +/// (keys are taken literally). Other `{...}` tokens pass through verbatim, +/// since env values may legitimately contain braces. +/// +/// Returns a sorted list so rendered output is deterministic. +fn resolve_env_vars( + raw: &BTreeMap, + env: &str, +) -> Result, Error> { + let mut merged: BTreeMap = BTreeMap::new(); + // Base entries first ... + for (key, value) in raw { + match value { + toml::Value::String(s) => { + merged.insert(key.clone(), apply_env(s, env)); + } + toml::Value::Table(_) => {} // per-env overlay, handled below + other => { + return Err(Error::InvalidEnvVar { + key: key.clone(), + reason: format!( + "must be a string (write PORT = \"8080\") or a per-env \ + override table, got {}", + other.type_str() + ), + }); + } + } + } + // ... then the overlay for the selected env wins. Inactive env tables are + // still type-checked so a typo fails in every environment, not just one. + for (table_key, value) in raw { + let toml::Value::Table(table) = value else { + continue; + }; + for (key, val) in table { + let Some(s) = val.as_str() else { + return Err(Error::InvalidEnvVar { + key: format!("{table_key}.{key}"), + reason: format!("must be a string, got {}", val.type_str()), + }); + }; + if table_key == env { + merged.insert(key.clone(), apply_env(s, env)); + } + } + } + Ok(merged.into_iter().collect()) +} + // ---------- on-disk TOML shape ---------- /// The schema version this CLI knows how to read. @@ -231,7 +331,10 @@ pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"]; /// 0.6.0: per-environment namespaces and dependencies (`{env}` placeholders /// and the Cargo-style `[depends_on]` table form) — a CLI older than this /// can't render them. -pub const RECOMMENDED_CLI_MIN: &str = "0.6.0"; +/// +/// 0.14.0: `[env]` / `[env.]` plain env-var tables (older CLIs silently +/// ignore them) and the `port` field in the `[depends_on]` table form. +pub const RECOMMENDED_CLI_MIN: &str = "0.14.0"; #[derive(Debug, Deserialize)] struct RawImage { @@ -270,10 +373,14 @@ struct RawConfig { #[serde(default)] autoscale: Option, // String shorthand (`name = ""`) or table form - // (`name = { namespace = "", = "", envs = [..] }`). + // (`name = { namespace = "", port =

, = "", envs = [..] }`). // Parsed as raw values and interpreted by `resolve_depends_on`. #[serde(default)] depends_on: BTreeMap, + // Plain (non-secret) runtime env vars: `KEY = "value"` string entries plus + // `[env.]` per-env override tables. Interpreted by `resolve_env_vars`. + #[serde(default)] + env: BTreeMap, #[serde(default)] callers: RawCallers, #[serde(default)] @@ -563,7 +670,7 @@ pub struct Plan { pub image: String, pub image_registry: Option, pub security: Option, - pub depends_on: Vec, + pub depends_on: Vec, pub callers: Vec, pub dir: PathBuf, pub database: Option, @@ -574,6 +681,9 @@ pub struct Plan { pub migrations: Option, pub config: Option, pub emitted_env: EmittedEnv, + /// Plain env vars from `[env]` (+ `[env.]` overlay), resolved for + /// `selected_env` and sorted by key. Rendered into the chart's `env:` map. + pub env_vars: Vec<(String, String)>, pub selected_env: String, pub client: ClientSpec, pub path_rules: Vec, @@ -602,6 +712,7 @@ impl Plan { } let depends_on = resolve_depends_on(raw.depends_on, env)?; + let env_vars = resolve_env_vars(&raw.env, env)?; let explicit_callers = stateful::resolve_callers(&raw.callers, env); @@ -811,6 +922,26 @@ impl Plan { emitted_env.extend_secrets(s); } + // Reserved-name guard: `[env]` must not shadow env vars tonin already + // emits (stateful literals like DATABASE_URL / REDIS_URL, or + // secret-sourced keys). Duplicate container env names are + // last-one-wins in Kubernetes — silently overriding a resolved URL is + // exactly the drift tonin.toml exists to prevent, so hard-error. + for (key, _) in &env_vars { + if emitted_env.literals.iter().any(|(k, _)| k == key) { + return Err(Error::ReservedEnvKey { + key: key.clone(), + origin: "stateful ([database]/[cache])".into(), + }); + } + if emitted_env.from_secret.iter().any(|k| k == key) { + return Err(Error::ReservedEnvKey { + key: key.clone(), + origin: "secret-sourced ([secrets])".into(), + }); + } + } + Ok(Plan { name: raw.service.name, version: raw.service.version, @@ -844,6 +975,7 @@ impl Plan { config, client, emitted_env, + env_vars, selected_env: env.to_string(), path_rules: raw .path_rules @@ -871,7 +1003,7 @@ impl Plan { .map(|e| Plan::load_with_env(e.path(), env)) .collect::>()?; - let snapshot: Vec<(String, String, Vec)> = plans + let snapshot: Vec<(String, String, Vec)> = plans .iter() .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone())) .collect(); @@ -1173,6 +1305,77 @@ mod tests { ); } + #[test] + fn depends_on_bare_string_defaults_port_50051() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"platform-{env}\"\n", + ] + .concat(); + let p = try_load_env(&body, "prod").unwrap(); + let d = p.depends_on.iter().find(|d| d.name == "identity").unwrap(); + assert_eq!(d.port, DEFAULT_DEPENDENCY_PORT, "shorthand keeps 50051"); + } + + #[test] + fn depends_on_table_port_is_used() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar-platform = { namespace = \"zradar-{env}\", port = 4317 }\ngateway = { namespace = \"edge-{env}\", port = 7001, prod = \"edge\" }\n", + ] + .concat(); + let p = try_load_env(&body, "prod").unwrap(); + let zr = p + .depends_on + .iter() + .find(|d| d.name == "zradar-platform") + .unwrap(); + assert_eq!(zr.namespace, "zradar-prod"); + assert_eq!(zr.port, 4317); + let gw = p.depends_on.iter().find(|d| d.name == "gateway").unwrap(); + assert_eq!(gw.namespace, "edge", "per-env override still wins"); + assert_eq!(gw.port, 7001, "port applies alongside env overrides"); + } + + #[test] + fn depends_on_table_without_port_defaults_to_50051() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"billing-{env}\" }\n", + ] + .concat(); + let p = try_load_env(&body, "dev").unwrap(); + assert_eq!(p.depends_on[0].port, DEFAULT_DEPENDENCY_PORT); + } + + #[test] + fn depends_on_non_integer_port_is_error() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { namespace = \"demo\", port = \"grpc\" }\n", + ] + .concat(); + let err = try_load_env(&body, "prod").unwrap_err(); + assert!( + matches!(err, Error::InvalidDependency { .. }), + "got {err:?}" + ); + } + + #[test] + fn depends_on_out_of_range_port_is_error() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { namespace = \"demo\", port = 70000 }\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 = [ @@ -1187,6 +1390,128 @@ mod tests { ); } + // ---- [env] plain env vars --------------------------------------------- + + fn env_var(plan: &Plan, key: &str) -> Option { + plan.env_vars + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + } + + #[test] + fn env_base_entries_resolve_with_env_placeholder() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[env]\nIDENTITY_GRPC_URL = \"http://identity.zvectorlabs-{env}.svc.cluster.local:50051\"\nLOG_FORMAT = \"json\"\n", + ] + .concat(); + let dev = try_load_env(&body, "dev").unwrap(); + assert_eq!( + env_var(&dev, "IDENTITY_GRPC_URL").as_deref(), + Some("http://identity.zvectorlabs-dev.svc.cluster.local:50051") + ); + let prod = try_load_env(&body, "prod").unwrap(); + assert_eq!( + env_var(&prod, "IDENTITY_GRPC_URL").as_deref(), + Some("http://identity.zvectorlabs-prod.svc.cluster.local:50051") + ); + assert_eq!(env_var(&prod, "LOG_FORMAT").as_deref(), Some("json")); + } + + #[test] + fn env_per_env_overlay_merges_over_base() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[env]\nLOG_FORMAT = \"json\"\nFEATURE_X = \"off\"\n[env.dev]\nLOG_FORMAT = \"pretty\"\nDEV_ONLY = \"1\"\n", + ] + .concat(); + let dev = try_load_env(&body, "dev").unwrap(); + assert_eq!( + env_var(&dev, "LOG_FORMAT").as_deref(), + Some("pretty"), + "overlay wins" + ); + assert_eq!( + env_var(&dev, "FEATURE_X").as_deref(), + Some("off"), + "base keys survive the merge" + ); + assert_eq!(env_var(&dev, "DEV_ONLY").as_deref(), Some("1")); + let prod = try_load_env(&body, "prod").unwrap(); + assert_eq!(env_var(&prod, "LOG_FORMAT").as_deref(), Some("json")); + assert!( + env_var(&prod, "DEV_ONLY").is_none(), + "dev-only stays in dev" + ); + } + + #[test] + fn env_non_string_value_is_error() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[env]\nPORT = 8080\n", + ] + .concat(); + let err = try_load_env(&body, "prod").unwrap_err(); + assert!(matches!(err, Error::InvalidEnvVar { .. }), "got {err:?}"); + } + + #[test] + fn env_non_string_value_in_overlay_is_error_even_when_inactive() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[env.dev]\nPORT = 8080\n", + ] + .concat(); + // Rendering prod, but the broken dev overlay must still fail loudly. + let err = try_load_env(&body, "prod").unwrap_err(); + assert!(matches!(err, Error::InvalidEnvVar { .. }), "got {err:?}"); + } + + #[test] + fn env_braces_other_than_env_pass_through() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[env]\nLOG_PATTERN = \"{level} {msg}\"\n", + ] + .concat(); + let p = try_load_env(&body, "prod").unwrap(); + assert_eq!( + env_var(&p, "LOG_PATTERN").as_deref(), + Some("{level} {msg}"), + "env values may contain braces; only {{env}} is substituted" + ); + } + + #[test] + fn env_key_colliding_with_stateful_env_is_error() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[database]\nengine = \"postgres\"\n[env]\nDATABASE_URL = \"postgres://elsewhere/db\"\n", + ] + .concat(); + let err = try_load_env(&body, "prod").unwrap_err(); + assert!(matches!(err, Error::ReservedEnvKey { .. }), "got {err:?}"); + } + + #[test] + fn env_key_colliding_with_secret_key_is_error() { + let body = [ + SVC, + "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[secrets]\nrequired = [\"JWT_SIGNING_KEY\"]\n[env]\nJWT_SIGNING_KEY = \"plaintext-oops\"\n", + ] + .concat(); + let err = try_load_env(&body, "prod").unwrap_err(); + assert!(matches!(err, Error::ReservedEnvKey { .. }), "got {err:?}"); + } + + #[test] + fn no_env_section_yields_empty_env_vars() { + let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\""); + assert!(p.env_vars.is_empty()); + } + #[test] fn deploy_namespace_unresolved_placeholder_is_error() { let body = [ @@ -1399,9 +1724,10 @@ allowPrivilegeEscalation = false security: None, depends_on: depends_on .iter() - .map(|d| ServiceRef { + .map(|d| DependencyRef { name: d.to_string(), namespace: "demo".to_string(), + port: DEFAULT_DEPENDENCY_PORT, }) .collect(), callers: Vec::new(), @@ -1414,6 +1740,7 @@ allowPrivilegeEscalation = false migrations: None, config: None, emitted_env: Default::default(), + env_vars: Vec::new(), selected_env: "prod".to_string(), client: ClientSpec::default(), path_rules: Vec::new(), diff --git a/crates/tonin/src/commands/affected.rs b/crates/tonin/src/commands/affected.rs index 3aa6040..d05122c 100644 --- a/crates/tonin/src/commands/affected.rs +++ b/crates/tonin/src/commands/affected.rs @@ -447,7 +447,10 @@ fn print_packages_table(pkgs: &[AffectedPackage]) { #[cfg(test)] mod tests { use super::*; - use tonin_plugin::plan::{ClientSpec, McpMode, Mesh, PathRule, ServiceKind, ServiceRef}; + use tonin_plugin::plan::{ + ClientSpec, DEFAULT_DEPENDENCY_PORT, DependencyRef, McpMode, Mesh, PathRule, ServiceKind, + ServiceRef, + }; fn make_mock_plan(name: &str, depends_on: &[&str], path_rules: Vec) -> Plan { Plan { @@ -473,9 +476,10 @@ mod tests { security: None, depends_on: depends_on .iter() - .map(|d| ServiceRef { + .map(|d| DependencyRef { name: d.to_string(), namespace: "demo".to_string(), + port: DEFAULT_DEPENDENCY_PORT, }) .collect(), callers: Vec::new(), @@ -488,6 +492,7 @@ mod tests { migrations: None, config: None, emitted_env: Default::default(), + env_vars: Vec::new(), selected_env: "staging".to_string(), client: ClientSpec::default(), path_rules, diff --git a/crates/tonin/src/commands/helm/generate.rs b/crates/tonin/src/commands/helm/generate.rs index fd327d3..cce7556 100644 --- a/crates/tonin/src/commands/helm/generate.rs +++ b/crates/tonin/src/commands/helm/generate.rs @@ -281,6 +281,11 @@ fn build_context(plan: &Plan) -> tera::Context { ctx.insert("stateful_env_literals", &plan.emitted_env.literals); ctx.insert("secret_keys", &plan.emitted_env.from_secret); + // ---- Plain env vars from [env] (+ [env.] overlay), resolved for + // this env. Rendered into the chart's `env:` map, which the + // deployment template already injects into the server container. + ctx.insert("env_vars", &plan.env_vars); + // ---- Migrations. `enabled` when tonin.toml asks for a managed migration // step (run_on = init-container). The chart renders either an // initContainer (default, dev / owned DB) or a pre-upgrade hook Job @@ -1113,6 +1118,140 @@ audit = { namespace = "security-{env}", envs = ["prod"] } assert!(!dev.contains("name: audit"), "audit is prod-only: {dev}"); } + #[test] + fn env_tables_render_into_values_env_map() { + let svc = tempfile::tempdir().unwrap(); + write_service( + svc.path(), + r#" +schema = "v1" +[service] +name = "gateway" +version = "0.1.0" +type = "http" +port = 7001 +[deploy] +replicas = 1 +mesh = "cilium" +namespace = "zvectorlabs-{env}" +[resources] +cpu = "100m" +memory = "128Mi" +[env] +IDENTITY_GRPC_URL = "http://identity.zvectorlabs-{env}.svc.cluster.local:50051" +LOG_FORMAT = "json" +[env.dev] +LOG_FORMAT = "pretty" +"#, + ); + let out = svc.path().join("chart"); + run(GenerateArgs { + path: Some(svc.path().to_path_buf()), + out: Some(out.clone()), + envs: vec!["dev".into(), "prod".into()], + }) + .unwrap(); + + // {env} resolves per environment; the dev overlay wins in dev only. + let dev = read(&out.join("values-dev.yaml")); + assert!( + dev.contains( + "IDENTITY_GRPC_URL: \"http://identity.zvectorlabs-dev.svc.cluster.local:50051\"" + ), + "{dev}" + ); + assert!(dev.contains("LOG_FORMAT: \"pretty\""), "{dev}"); + let prod = read(&out.join("values-prod.yaml")); + assert!( + prod.contains( + "IDENTITY_GRPC_URL: \"http://identity.zvectorlabs-prod.svc.cluster.local:50051\"" + ), + "{prod}" + ); + assert!(prod.contains("LOG_FORMAT: \"json\""), "{prod}"); + // Base values.yaml carries the map too, and the deployment template + // injects .Values.env into the server container. + let base = read(&out.join("values.yaml")); + assert!(base.contains("IDENTITY_GRPC_URL:"), "{base}"); + let deployment = read(&out.join("templates").join("deployment.yaml")); + assert!(deployment.contains(".Values.env"), "{deployment}"); + } + + #[test] + fn no_env_section_renders_empty_env_map() { + let svc = tempfile::tempdir().unwrap(); + write_service(svc.path(), RICH_TOML); + let out = svc.path().join("chart"); + run(GenerateArgs { + path: Some(svc.path().to_path_buf()), + out: Some(out.clone()), + envs: vec!["prod".into()], + }) + .unwrap(); + let values = read(&out.join("values.yaml")); + // Column-0 anchor: distinguishes from the nested `migrations.env: {}`. + assert!(values.contains("\nenv: {}"), "{values}"); + } + + #[test] + fn depends_on_port_renders_into_values_and_policy_template() { + let svc = tempfile::tempdir().unwrap(); + write_service( + svc.path(), + r#" +schema = "v1" +[service] +name = "zvectorlabs-api" +version = "0.1.0" +type = "http" +port = 7001 +[deploy] +namespace = "zvectorlabs-{env}" +replicas = 1 +mesh = "cilium" +[resources] +cpu = "100m" +memory = "256Mi" +[depends_on] +identity = "zvectorlabs-{env}" +zradar-platform = { namespace = "zradar-{env}", port = 4317 } +"#, + ); + let out = svc.path().join("chart"); + run(GenerateArgs { + path: Some(svc.path().to_path_buf()), + out: Some(out.clone()), + envs: vec!["prod".into()], + }) + .unwrap(); + + let prod = read(&out.join("values-prod.yaml")); + // Bare-string form keeps the historical 50051 default. + assert!( + prod.contains("- name: identity\n namespace: zvectorlabs-prod\n port: 50051"), + "{prod}" + ); + // Table form carries its declared port. + assert!( + prod.contains( + "- name: zradar-platform\n namespace: zradar-prod\n port: 4317" + ), + "{prod}" + ); + + let policy = read(&out.join("templates").join("networkpolicy.yaml")); + assert!( + policy.contains(r#"port: "{{ .port | default 50051 }}""#), + "egress uses the per-dependency port: {policy}" + ); + assert!( + !policy.contains(r#"- { port: "50051", protocol: TCP }"#), + "hardcoded dependency egress port must be gone: {policy}" + ); + // DNS egress ships alongside — default-deny would break discovery. + assert!(policy.contains("k8s-app: kube-dns"), "{policy}"); + } + #[test] fn python_service_with_depends_on_gets_proxy_sidecar_mcp() { const PYTHON_TOML: &str = r#" diff --git a/crates/tonin/src/commands/run.rs b/crates/tonin/src/commands/run.rs index 87e3b73..eb2abe9 100644 --- a/crates/tonin/src/commands/run.rs +++ b/crates/tonin/src/commands/run.rs @@ -297,6 +297,7 @@ mod tests { migrations: None, config: None, emitted_env: Default::default(), + env_vars: vec![], selected_env: "dev".to_string(), client: Default::default(), path_rules: vec![], @@ -358,7 +359,7 @@ mod tests { /// Helper to create a mock Plan for testing. fn mock_plan(name: &str, deps: Vec<&str>) -> Plan { - use tonin_plugin::ServiceRef; + use tonin_plugin::{DEFAULT_DEPENDENCY_PORT, DependencyRef}; Plan { name: name.to_string(), @@ -383,9 +384,10 @@ mod tests { security: None, depends_on: deps .into_iter() - .map(|d| ServiceRef { + .map(|d| DependencyRef { name: d.to_string(), namespace: "default".to_string(), + port: DEFAULT_DEPENDENCY_PORT, }) .collect(), callers: vec![], @@ -398,6 +400,7 @@ mod tests { migrations: None, config: None, emitted_env: Default::default(), + env_vars: vec![], selected_env: "dev".to_string(), client: Default::default(), path_rules: vec![], diff --git a/crates/tonin/templates/helm/chart-templates/networkpolicy.yaml b/crates/tonin/templates/helm/chart-templates/networkpolicy.yaml index ce1f2b3..8000424 100644 --- a/crates/tonin/templates/helm/chart-templates/networkpolicy.yaml +++ b/crates/tonin/templates/helm/chart-templates/networkpolicy.yaml @@ -43,6 +43,16 @@ spec: - { port: "{{ .Values.service.mcpPort }}", protocol: TCP } {{- end }} egress: + # DNS (kube-dns). Any egress rule makes this policy default-deny for the + # pod, which would otherwise silently break service discovery. + - toEndpoints: + - matchLabels: + k8s:io.kubernetes.pod.namespace: kube-system + k8s-app: kube-dns + toPorts: + - ports: + - { port: "53", protocol: UDP } + - { port: "53", protocol: TCP } - toEndpoints: - matchLabels: k8s:io.kubernetes.pod.namespace: observability @@ -56,7 +66,8 @@ spec: service.identity: {{ .name }}.{{ .namespace }} toPorts: - ports: - - { port: "50051", protocol: TCP } + # Per-dependency port from tonin.toml [depends_on]; 50051 when unset. + - { port: "{{ .port | default 50051 }}", protocol: TCP } {{- end }} {{- if .Values.database.enabled }} # Database egress (shared or owned). diff --git a/crates/tonin/templates/helm/values-env.yaml.tmpl b/crates/tonin/templates/helm/values-env.yaml.tmpl index 2ff4209..8610466 100644 --- a/crates/tonin/templates/helm/values-env.yaml.tmpl +++ b/crates/tonin/templates/helm/values-env.yaml.tmpl @@ -21,6 +21,13 @@ statefulEnv:{% if stateful_env_literals | length == 0 %} {}{% endif %} {{ kv.0 }}: "{{ kv.1 }}" {%- endfor %} +# Plain env vars from tonin.toml [env], merged with the [env.{{ env_name }}] +# overlay and with {env} substituted for this environment. +env:{% if env_vars | length == 0 %} {}{% endif %} +{%- for kv in env_vars %} + {{ kv.0 }}: "{{ kv.1 }}" +{%- endfor %} + database: enabled: {{ db_enabled }} shared: {% if db_shared %}true{% else %}false{% endif %} @@ -68,4 +75,5 @@ networkPolicy: {%- for d in depends_on %} - name: {{ d.name }} namespace: {{ d.namespace }} + port: {{ d.port }} {%- endfor %} diff --git a/crates/tonin/templates/helm/values.yaml.tmpl b/crates/tonin/templates/helm/values.yaml.tmpl index 256eeb8..f811fcb 100644 --- a/crates/tonin/templates/helm/values.yaml.tmpl +++ b/crates/tonin/templates/helm/values.yaml.tmpl @@ -82,8 +82,12 @@ command: {% if is_rust %}["/usr/local/bin/{{ name }}"]{% else %}[]{% endif %} # Gate RUST_LOG injection: only meaningful for Rust binaries. rustService: {% if is_rust %}true{% else %}false{% endif %} -# Extra env vars injected verbatim into the container. -env: {} +# Plain (non-secret) runtime env vars from tonin.toml [env] (+ [env.] +# overlays, {env} substituted). Injected verbatim into the server container. +env:{% if env_vars | length == 0 %} {}{% endif %} +{%- for kv in env_vars %} + {{ kv.0 }}: "{{ kv.1 }}" +{%- endfor %} # Pod annotations. Mesh-derived entries (e.g. cilium transparent encryption) are # generated here; add your own at deploy time via --set podAnnotations.key=value. @@ -174,4 +178,5 @@ networkPolicy: {%- for d in depends_on %} - name: {{ d.name }} namespace: {{ d.namespace }} + port: {{ d.port }} {%- endfor %} diff --git a/docs/12-kubernetes-deploy.md b/docs/12-kubernetes-deploy.md index 8a25c23..96ab33d 100644 --- a/docs/12-kubernetes-deploy.md +++ b/docs/12-kubernetes-deploy.md @@ -244,6 +244,7 @@ users-service = "myapp-{env}" inventory-service = { namespace = "inventory-{env}", prod = "inventory-shared" } # prod overrides the convention audit-sink = { namespace = "security-{env}", envs = ["prod"] } # only egresses in prod billing = { namespace = "@inherit" } # namespace set at deploy time; omitted from the chart +otel-gateway = { namespace = "observability-{env}", port = 4317 } # non-gRPC / non-default listen port ``` Resolution rules for an environment `E`: @@ -252,9 +253,42 @@ Resolution rules for an environment `E`: - `envs = [...]` restricts the dependency to those environments; elsewhere it is dropped. - `@inherit` omits the entry from the rendered policy — supply it at deploy time (`--set-json`) or via GitOps. - If an active dependency has no namespace for `E`, or a `{...}` placeholder is left unresolved, generation **fails** — there is no silent fallback to a base value, which is what previously let a dev namespace leak into a prod chart. +- `port` declares the dependency's listen port; the generated CiliumNetworkPolicy egress rule allows exactly that port. Omitted (and for the shorthand string form) it defaults to `50051`, the tonin gRPC convention — so existing files render the same egress rule as before. Both the shorthand string form and literal namespaces (no `{env}`) keep working unchanged. +### Plain environment variables (`[env]`) + +Runtime configuration that is neither stateful (`[database]`/`[cache]` emit +`DATABASE_URL`/`REDIS_URL` for you) nor secret (`[secrets]` emits +`secretKeyRef` env) goes in the optional `[env]` table — plain string env vars +injected verbatim into the server container: + +```toml +[env] +IDENTITY_GRPC_URL = "http://identity.myapp-{env}.svc.cluster.local:50051" +LOG_FORMAT = "json" + +[env.dev] +LOG_FORMAT = "pretty" # merged over the base for dev only +``` + +- `[env.]` overlays merge over the base entries for that environment + (overlay wins per key; other base keys survive). +- `{env}` in values substitutes the environment being rendered, same as + namespaces. Other `{...}` tokens pass through verbatim — env values may + legitimately contain braces. +- Values must be strings (`PORT = "8080"`, not `PORT = 8080`). +- Keys that collide with env vars tonin already emits (stateful literals like + `DATABASE_URL`, or secret-sourced keys) fail generation — duplicate + container env names are last-one-wins in Kubernetes, so a collision would + silently override the resolved value. + +The table renders into the chart's `env:` map in `values.yaml` and each +`values-.yaml`, so it survives regeneration — unlike hand-edits to the +`extraEnv: []` deploy-time escape hatch, which `tonin helm generate` +intentionally leaves empty. + ## How it fits together ```mermaid