1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
// ─── Agent config ────────────────────────────────────────────────────
#[derive(Deserialize, Debug, Clone)]
pub struct AgentConfig {
pub agent: AgentSection,
pub log: LogSection,
}
#[derive(Deserialize, Debug, Clone)]
pub struct AgentSection {
pub id: String,
pub nats_url: String,
/// DEPRECATED in Sprint 5: group membership is now server-managed
/// via the `agent_groups` KV bucket. Use
/// `kanade agent groups set <pc_id> <group> [<group> ...]` to
/// declare membership. Still parsed for back-compat; the value
/// is logged-and-ignored at startup. Field removal is scheduled
/// for v0.4.0.
#[serde(default)]
pub groups: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LogSection {
pub path: String,
pub level: String,
/// Number of rotated daily files (incl. today's) to retain.
/// Defaults to 14 — covers two weeks of incidents without
/// blowing up disk. Set to 0 to disable on-disk logging
/// (stdout only).
#[serde(default = "default_keep_days")]
pub keep_days: usize,
}
fn default_keep_days() -> usize {
14
}
// ─── Backend config ──────────────────────────────────────────────────
#[derive(Deserialize, Debug, Clone)]
pub struct BackendConfig {
pub server: ServerSection,
pub nats: NatsSection,
pub db: DbSection,
pub log: LogSection,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ServerSection {
pub bind: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct NatsSection {
pub url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct DbSection {
pub sqlite_path: String,
}
// ─── Loader ──────────────────────────────────────────────────────────
fn load_typed<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T> {
let mut engine = teravars::Engine::new();
let ctx = teravars::system_context();
let paths: Vec<PathBuf> = vec![path.to_path_buf()];
let merged = teravars::load_merged(&paths, &mut engine, &ctx)
.with_context(|| format!("teravars load_merged: {path:?}"))?;
let cfg: T = toml::Value::Table(merged.config)
.try_into()
.with_context(|| format!("decode config from {path:?}"))?;
Ok(cfg)
}
pub fn load_agent_config(path: &Path) -> Result<AgentConfig> {
load_typed(path)
}
pub fn load_backend_config(path: &Path) -> Result<BackendConfig> {
load_typed(path)
}
#[cfg(test)]
mod tests {
use super::*;
/// Smoke test the dev-fleet flow against `agent.dev.toml`:
/// 1. When `KANADE_DEV_AGENT_ID` is set, the teravars template
/// resolves `vars.pc_id` to that value and propagates it
/// into `agent.id` + `log.path`. Also exercises a `[vars]`
/// self-reference (`pc_id` falls back to `vars.hostname`),
/// which `load_merged` resolves via its internal
/// fixed-point pass.
/// 2. Without the env, the template falls back to `system.host`
/// so vanilla `cargo make agent-dev` still works.
///
/// Both halves live in a single `#[test]` so they execute
/// sequentially within the cargo test runtime — splitting them
/// across two tests races on `KANADE_DEV_AGENT_ID` (macOS CI
/// turned the race up enough to fail consistently).
#[test]
fn agent_dev_toml_renders_pc_id_from_env_or_system_host() {
// The dev config lives at the workspace root; CARGO_MANIFEST_DIR
// resolves to crates/kanade-shared/, so hop up two.
let cfg_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("configs")
.join("agent.dev.toml");
// (1) env set → pc_id == env value
// SAFETY: env mutation is process-global; this single test
// body owns set + remove so no sibling test can race us.
unsafe {
std::env::set_var("KANADE_DEV_AGENT_ID", "dev-pc-render-test");
}
let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env set)");
assert_eq!(cfg.agent.id, "dev-pc-render-test");
assert!(
cfg.log.path.contains("dev-pc-render-test"),
"log path should embed pc_id, got {}",
cfg.log.path,
);
// (2) env removed → pc_id falls back to vars.hostname
// = system.host. The host string varies by box; just assert
// it's non-empty and not the literal template that would mean
// teravars failed to render.
unsafe {
std::env::remove_var("KANADE_DEV_AGENT_ID");
}
let cfg = load_agent_config(&cfg_path).expect("load agent.dev.toml (env unset)");
assert!(
!cfg.agent.id.is_empty(),
"pc_id should fall back to system.host"
);
assert_ne!(
cfg.agent.id, "{{ system.host }}",
"template should render, not leak"
);
}
}