Skip to main content

kanade_shared/
config.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use serde::Deserialize;
5
6// ─── Agent config ────────────────────────────────────────────────────
7
8#[derive(Deserialize, Debug, Clone)]
9pub struct AgentConfig {
10    pub agent: AgentSection,
11    pub log: LogSection,
12}
13
14#[derive(Deserialize, Debug, Clone)]
15pub struct AgentSection {
16    pub id: String,
17    pub nats_url: String,
18    /// DEPRECATED in Sprint 5: group membership is now server-managed
19    /// via the `agent_groups` KV bucket. Use
20    /// `kanade agent groups set <pc_id> <group> [<group> ...]` to
21    /// declare membership. Still parsed for back-compat; the value
22    /// is logged-and-ignored at startup. Field removal is scheduled
23    /// for v0.4.0.
24    #[serde(default)]
25    pub groups: Vec<String>,
26}
27
28#[derive(Deserialize, Debug, Clone)]
29pub struct LogSection {
30    pub path: String,
31    pub level: String,
32    /// Number of rotated daily files (incl. today's) to retain.
33    /// Defaults to 14 — covers two weeks of incidents without
34    /// blowing up disk. Set to 0 to disable on-disk logging
35    /// (stdout only).
36    #[serde(default = "default_keep_days")]
37    pub keep_days: usize,
38}
39
40fn default_keep_days() -> usize {
41    14
42}
43
44// ─── Backend config ──────────────────────────────────────────────────
45
46#[derive(Deserialize, Debug, Clone)]
47pub struct BackendConfig {
48    pub server: ServerSection,
49    pub nats: NatsSection,
50    pub db: DbSection,
51    pub log: LogSection,
52    /// Outbound SMTP relay for compliance-alert + generic email.
53    /// Absent ⇒ email features are no-ops (the in-app/NATS notification
54    /// path is unaffected), so an existing deploy keeps working with no
55    /// config change. Holds only the *non-secret* connection settings —
56    /// the SMTP password is NOT here, it comes from the `MailPassword`
57    /// registry secret (or `$KANADE_MAIL_PASSWORD`), the same way
58    /// `JwtSecret` / `StaticToken` are resolved (kanade keeps secrets out
59    /// of the un-ACL'd `backend.toml`).
60    #[serde(default)]
61    pub mail: Option<MailSection>,
62}
63
64#[derive(Deserialize, Debug, Clone)]
65pub struct MailSection {
66    /// SMTP relay host (e.g. an internal mail relay).
67    pub host: String,
68    /// SMTP port — 587 (STARTTLS), 465 (implicit TLS), or 25 (plain).
69    pub port: u16,
70    #[serde(default)]
71    pub encryption: MailEncryption,
72    /// Envelope/`From` address every kanade email is sent as.
73    pub from: String,
74    /// SMTP AUTH username. Omit for an unauthenticated internal relay;
75    /// when set, pair it with the `MailPassword` secret.
76    #[serde(default)]
77    pub username: Option<String>,
78}
79
80/// Transport security for the SMTP connection.
81#[derive(Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
82#[serde(rename_all = "lowercase")]
83pub enum MailEncryption {
84    /// Upgrade a plaintext connection via STARTTLS (port 587). Default.
85    #[default]
86    Starttls,
87    /// Implicit TLS from the first byte (port 465).
88    Tls,
89    /// No transport security (port 25 on a trusted internal segment).
90    None,
91}
92
93#[derive(Deserialize, Debug, Clone)]
94pub struct ServerSection {
95    pub bind: String,
96}
97
98#[derive(Deserialize, Debug, Clone)]
99pub struct NatsSection {
100    pub url: String,
101}
102
103#[derive(Deserialize, Debug, Clone)]
104pub struct DbSection {
105    pub sqlite_path: String,
106}
107
108// ─── Loader ──────────────────────────────────────────────────────────
109
110fn load_typed<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
111    let mut engine = teravars::Engine::new();
112    let ctx = teravars::system_context();
113    let paths: Vec<PathBuf> = vec![path.to_path_buf()];
114    let merged = teravars::load_merged(&paths, &mut engine, &ctx)
115        .with_context(|| format!("teravars load_merged: {path:?}"))?;
116    let cfg: T = toml::Value::Table(merged.config)
117        .try_into()
118        .with_context(|| format!("decode config from {path:?}"))?;
119    Ok(cfg)
120}
121
122pub fn load_agent_config(path: &Path) -> Result<AgentConfig> {
123    load_typed(path)
124}
125
126pub fn load_backend_config(path: &Path) -> Result<BackendConfig> {
127    load_typed(path)
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    /// Smoke test the dev-fleet flow against `agent.dev.toml`:
135    ///   1. When `KANADE_DEV_AGENT_ID` is set, the teravars template
136    ///      resolves `vars.pc_id` to that value and propagates it
137    ///      into `agent.id` + `log.path`. Also exercises a `[vars]`
138    ///      self-reference (`pc_id` falls back to `vars.hostname`),
139    ///      which `load_merged` resolves via its internal
140    ///      fixed-point pass.
141    ///   2. Without the env, the template falls back to `system.host`
142    ///      so vanilla `cargo make agent-dev` still works.
143    ///
144    /// Both halves live in a single `#[test]` so they execute
145    /// sequentially within the cargo test runtime — splitting them
146    /// across two tests races on `KANADE_DEV_AGENT_ID` (macOS CI
147    /// turned the race up enough to fail consistently).
148    #[test]
149    fn agent_dev_toml_renders_pc_id_from_env_or_system_host() {
150        // The dev config lives at the workspace root; CARGO_MANIFEST_DIR
151        // resolves to crates/kanade-shared/, so hop up two.
152        let cfg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
153            .join("..")
154            .join("..")
155            .join("configs")
156            .join("agent.dev.toml");
157
158        // (1) env set → pc_id == env value
159        // SAFETY: env mutation is process-global; this single test
160        // body owns set + remove so no sibling test can race us.
161        unsafe {
162            std::env::set_var("KANADE_DEV_AGENT_ID", "dev-pc-render-test");
163        }
164        let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env set)");
165        assert_eq!(cfg.agent.id, "dev-pc-render-test");
166        assert!(
167            cfg.log.path.contains("dev-pc-render-test"),
168            "log path should embed pc_id, got {}",
169            cfg.log.path,
170        );
171
172        // (2) env removed → pc_id falls back to vars.hostname
173        // = system.host. The host string varies by box; just assert
174        // it's non-empty and not the literal template that would mean
175        // teravars failed to render.
176        unsafe {
177            std::env::remove_var("KANADE_DEV_AGENT_ID");
178        }
179        let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env unset)");
180        assert!(
181            !cfg.agent.id.is_empty(),
182            "pc_id should fall back to system.host"
183        );
184        assert_ne!(
185            cfg.agent.id, "{{ system.host }}",
186            "template should render, not leak"
187        );
188    }
189}