ignition_core/config/profile.rs
1//! Config + Profile serde structs — the on-disk shape of `config.toml`.
2//!
3//! Secrets are REFERENCES (env var names, keyring user strings), never
4//! values: no field on [`Profile`] or [`AuthRef`] ever holds a secret string
5//! (CORE-02). No `deny_unknown_fields` anywhere (research Pitfall 7 —
6//! forward compat; unknown keys warn at load time instead, see
7//! [`super::load`]).
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13/// The whole `config.toml`. `profiles` is a `BTreeMap` so `profile list`
14/// output — and therefore every golden — is deterministic by name.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct Config {
17 /// The profile commands operate on by default.
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub active: Option<String>,
20 /// Named gateway profiles (`[profiles.NAME]` tables).
21 #[serde(default)]
22 pub profiles: BTreeMap<String, Profile>,
23 /// Rig family defaults (`[rig]` table — just the default rig name
24 /// today; 04-01).
25 #[serde(default, skip_serializing_if = "rig_config_is_empty")]
26 pub rig: RigConfig,
27 /// Named compose rigs (`[rigs.NAME]` tables — 04-01). Omitted from
28 /// serialization when empty so profile-only configs keep their exact
29 /// on-disk shape (the save goldens).
30 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub rigs: BTreeMap<String, RigEntry>,
32 /// UI preferences (`[ui]` table — TUIX-05 plumbing). PLUMBING ONLY:
33 /// `theme` is carried on the config, never rendered (rendering is
34 /// Phase 12, TUIX-03/04). Omitted from serialization at defaults so
35 /// legacy configs round-trip byte-identically; a wrong-shaped `[ui]`
36 /// degrades to the default with a warning (see [`lenient_ui`]).
37 #[serde(
38 default,
39 skip_serializing_if = "UiConfig::is_default",
40 deserialize_with = "lenient_ui"
41 )]
42 pub ui: UiConfig,
43}
44
45/// The `[ui]` table: TUI preference keys (TUIX-05 plumbing).
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct UiConfig {
48 /// Preferred UI theme name (e.g. `"dark"`). `None` = the built-in
49 /// default. Carried only — nothing consumes it until Phase 12.
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub theme: Option<String>,
52}
53
54impl UiConfig {
55 /// True when nothing is set — keeps `[ui]` out of configs that never
56 /// carried one (the `rig_config_is_empty` precedent).
57 pub fn is_default(ui: &UiConfig) -> bool {
58 *ui == UiConfig::default()
59 }
60}
61
62/// Lenient `poll_interval_secs` extraction (TUIX-05): an integer loads
63/// verbatim — INCLUDING 0, which the load-time clamp (see
64/// [`super::load`]) refuses; anything else (string, float, negative,
65/// table) warns and degrades to `None` (default cadence). A typo in the
66/// NEW key never fails the load.
67fn lenient_u64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
68 let value = toml::Value::deserialize(d)?;
69 match value {
70 toml::Value::Integer(v) if v >= 0 => Ok(Some(v as u64)),
71 other => {
72 tracing::warn!(
73 value = %other,
74 "poll_interval_secs must be a non-negative integer — ignoring (default cadence in use)"
75 );
76 Ok(None)
77 }
78 }
79}
80
81/// Lenient `[ui]` extraction (TUIX-05): a well-shaped `[ui]` table loads;
82/// anything else (a bare scalar, a mistyped member) warns and degrades to
83/// the default. Unknown keys INSIDE a valid table are simply ignored (no
84/// `deny_unknown_fields`, ever — Pitfall 7 forward compat).
85fn lenient_ui<'de, D: serde::Deserializer<'de>>(d: D) -> Result<UiConfig, D::Error> {
86 let value = toml::Value::deserialize(d)?;
87 match UiConfig::deserialize(value) {
88 Ok(ui) => Ok(ui),
89 Err(_) => {
90 tracing::warn!("[ui] is not a valid table — using defaults");
91 Ok(UiConfig::default())
92 }
93 }
94}
95
96/// True when the `[rig]` block carries nothing worth serializing —
97/// keeps `[rig]` out of profile-only configs entirely.
98fn rig_config_is_empty(rig: &RigConfig) -> bool {
99 rig.default.is_none()
100}
101
102/// The `[rig]` table: rig-family defaults (04-01).
103#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
104pub struct RigConfig {
105 /// Name of the `[rigs.*]` entry `ign rig` targets when no
106 /// `--rig`/`IGNITION_RIG` names one.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub default: Option<String>,
109}
110
111/// One named compose rig (`[rigs.NAME]`, 04-01). References only —
112/// never secrets (the compose file itself owns those).
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct RigEntry {
115 /// Path to the rig's compose file (`~` and `${VAR}` expanded at use
116 /// time — manual expansion, no new dependency).
117 pub compose_file: String,
118 /// Explicit compose project name (`-p`). OPTIONAL — omit to honor
119 /// the rig's own `.env` `COMPOSE_PROJECT_NAME` (the identity truth,
120 /// research Pattern 1); set only to override it deliberately.
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub project_name: Option<String>,
123 /// Declared third-party modules to inject via the generated compose
124 /// override (`[rigs.NAME.modules.*]`, Phase 16, D-01): a MAP, so a
125 /// rig can declare any number of modules, each pinned to its own
126 /// version. Empty by construction on every discovery path except
127 /// [`super::super::rig::resolve_entry`] — the structural reason
128 /// SC-1 (an undeclared rig behaves byte-identically to today) holds.
129 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130 pub modules: BTreeMap<String, ModuleDeclaration>,
131 /// Escape hatch (D-12) overriding the derived gateway service when
132 /// modules are declared: the DERIVED value (the first service
133 /// publishing a port targeting 8088/443) is used when this is
134 /// unset.
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub module_service: Option<String>,
137}
138
139/// One declared module under `[rigs.NAME.modules.ID]` (Phase 16, D-01):
140/// today just the pinned version, but its own table (not a bare string)
141/// so a future key never forces a shape migration.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct ModuleDeclaration {
144 /// The pinned version string (validated by
145 /// [`crate::module::validate_version`] before use — this struct
146 /// carries it verbatim, unvalidated, exactly like `compose_file`).
147 pub version: String,
148}
149
150/// One gateway profile.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct Profile {
153 /// Gateway base URL.
154 pub url: url::Url,
155 /// Optional display label (CORE-01) — absent from TOML and JSON when
156 /// unset so existing configs and goldens don't churn.
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub label: Option<String>,
159 /// Verify TLS certificates (self-signed dev rigs turn this off).
160 #[serde(default = "default_ssl_verify")]
161 pub ssl_verify: bool,
162 /// HOW to find the credential — a reference, never a value.
163 #[serde(default)]
164 pub auth: AuthRef,
165 /// The CLI-GENERATED webdev scriptExec shared secret (05-03) — the
166 /// ONE deliberate value-carrying exception to the references-only
167 /// rule: this secret is not a user credential but a token the CLI
168 /// itself mints at deploy time, must round-trip verbatim (the
169 /// deployed route compares it byte-for-byte), and cannot live in
170 /// an env var another tool owns. It rides ONLY in this 0600 config
171 /// store and the baked route zip member — never in any action
172 /// result, log, or JSON envelope (the redaction discipline).
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub webdev_secret: Option<String>,
175 /// TUI background-refresh poll interval in seconds (TUIX-05
176 /// plumbing). `None` = the default cadence. Sub-second values
177 /// (`Some(0)`) are REFUSED by load-time validation on the normal CLI
178 /// path (exit 3, `poll_interval_too_small`) and degraded to `None`
179 /// on the TUI path ([`super::load_for_tui`]); a wrong-TYPED value
180 /// degrades to `None` with a warning (see [`lenient_u64`]) — a typo
181 /// in the new key never bricks the load.
182 #[serde(
183 default,
184 skip_serializing_if = "Option::is_none",
185 deserialize_with = "lenient_u64"
186 )]
187 pub poll_interval_secs: Option<u64>,
188}
189
190fn default_ssl_verify() -> bool {
191 true
192}
193
194/// Credential reference — the three supported forms, untagged so the TOML
195/// stays flat: `auth = { token_env = "X" }`, `auth = { keyring =
196/// "profile:prod" }`, or `auth = { user_env = "U", password_env = "P" }`.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(untagged)]
199pub enum AuthRef {
200 /// Token lives in this env var.
201 TokenEnv {
202 /// Env var NAME (never the token itself).
203 token_env: String,
204 },
205 /// Token lives in the OS keyring (service `ignition-cli`) under this
206 /// user string, e.g. `"profile:prod"`.
207 Keyring {
208 /// Keyring user string.
209 keyring: String,
210 },
211 /// Basic pair from two env vars.
212 Basic {
213 /// Env var NAME for the user.
214 user_env: String,
215 /// Env var NAME for the password.
216 password_env: String,
217 },
218}
219
220impl AuthRef {
221 /// Safe kind string for output models — never a secret or env value.
222 pub fn kind(&self) -> &'static str {
223 match self {
224 Self::TokenEnv { .. } => "token_env",
225 Self::Keyring { .. } => "keyring",
226 Self::Basic { .. } => "basic",
227 }
228 }
229}
230
231impl Default for AuthRef {
232 /// A profile without an `auth` key resolves through the generic env
233 /// token path (`IGNITION_TOKEN`) — the last env-token step of the LOCKED
234 /// resolution order.
235 fn default() -> Self {
236 Self::TokenEnv {
237 token_env: "IGNITION_TOKEN".to_string(),
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::{AuthRef, Config};
245
246 /// AuthRef untagged round-trip across all three reference forms, and
247 /// `kind()` never leaks a value.
248 #[test]
249 fn auth_ref_untagged_round_trip() {
250 let toml = r#"
251active = "dev"
252
253[profiles.dev]
254url = "http://localhost:9088/"
255label = "Dev rig"
256auth = { token_env = "IGNITION_TOKEN" }
257
258[profiles.prod]
259url = "https://gw.example.com:8443/"
260auth = { keyring = "profile:prod" }
261
262[profiles.rig]
263url = "http://10.0.0.5:9088/"
264ssl_verify = false
265auth = { user_env = "IGNITION_USER", password_env = "IGNITION_PASSWORD" }
266"#;
267 let config: Config = toml::from_str(toml).expect("parse");
268 assert_eq!(config.active.as_deref(), Some("dev"));
269 assert_eq!(config.profiles.len(), 3);
270
271 let dev = &config.profiles["dev"];
272 assert_eq!(dev.label.as_deref(), Some("Dev rig"));
273 assert!(dev.ssl_verify, "ssl_verify defaults true");
274 assert_eq!(
275 dev.auth,
276 AuthRef::TokenEnv {
277 token_env: "IGNITION_TOKEN".into()
278 }
279 );
280 assert_eq!(dev.auth.kind(), "token_env");
281
282 let prod = &config.profiles["prod"];
283 assert_eq!(prod.label, None, "label absent when unset");
284 assert_eq!(
285 prod.auth,
286 AuthRef::Keyring {
287 keyring: "profile:prod".into()
288 }
289 );
290 assert_eq!(prod.auth.kind(), "keyring");
291
292 let rig = &config.profiles["rig"];
293 assert!(!rig.ssl_verify, "ssl_verify = false honored");
294 assert_eq!(
295 rig.auth,
296 AuthRef::Basic {
297 user_env: "IGNITION_USER".into(),
298 password_env: "IGNITION_PASSWORD".into(),
299 }
300 );
301 assert_eq!(rig.auth.kind(), "basic");
302
303 // Serialize round-trip: label omitted when None, ssl_verify omitted
304 // when default (serde defaults skip nothing here except via attrs).
305 let reserialized = toml::to_string(&config).expect("serialize");
306 assert!(reserialized.contains("label = \"Dev rig\""));
307 assert!(!reserialized.contains("[profiles.prod]\nlabel"));
308 let back: Config = toml::from_str(&reserialized).expect("re-parse");
309 assert_eq!(back, config);
310 }
311
312 /// Missing `auth` falls back to the generic env token reference.
313 #[test]
314 fn auth_defaults_to_generic_token_env() {
315 let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
316 let config: Config = toml::from_str(toml).expect("parse");
317 assert_eq!(
318 config.profiles["dev"].auth,
319 AuthRef::TokenEnv {
320 token_env: "IGNITION_TOKEN".into()
321 }
322 );
323 }
324
325 /// New-schema round trip (TUIX-05): `[ui]` + `poll_interval_secs`
326 /// survive load → serialize → load losslessly.
327 #[test]
328 fn ui_and_poll_interval_round_trip() {
329 let toml = r#"
330active = "dev"
331
332[ui]
333theme = "dark"
334
335[profiles.dev]
336url = "http://localhost:9088/"
337poll_interval_secs = 10
338"#;
339 let config: Config = toml::from_str(toml).expect("parse");
340 assert_eq!(config.ui.theme.as_deref(), Some("dark"));
341 assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
342
343 let reserialized = toml::to_string(&config).expect("serialize");
344 let back: Config = toml::from_str(&reserialized).expect("re-parse");
345 assert_eq!(back, config, "round trip must be lossless");
346 }
347
348 /// Legacy-shape pin: a config without the new keys serializes WITHOUT
349 /// them — existing configs keep their exact on-disk shape (goldens).
350 #[test]
351 fn legacy_config_serializes_without_new_keys() {
352 let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
353 let config: Config = toml::from_str(toml).expect("parse");
354 let out = toml::to_string(&config).expect("serialize");
355 assert!(
356 !out.contains("ui") && !out.contains("poll_interval_secs"),
357 "new keys must stay off legacy-shaped configs: {out}"
358 );
359 }
360
361 /// Lenient degrade (TUIX-05): a wrong-TYPED value in the NEW surface
362 /// never fails the load — it warns and falls back to defaults.
363 #[test]
364 fn lenient_degrade_on_bad_typed_new_keys() {
365 let toml = r#"
366[ui]
367theme = "dark"
368
369[profiles.dev]
370url = "http://localhost:9088/"
371poll_interval_secs = "banana"
372"#;
373 let config: Config = toml::from_str(toml).expect("typo'd new key must not fail the load");
374 assert_eq!(
375 config.profiles["dev"].poll_interval_secs, None,
376 "bad poll_interval_secs degrades to None"
377 );
378
379 let toml = "ui = 42\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
380 let config: Config = toml::from_str(toml).expect("bad [ui] shape must not fail the load");
381 assert_eq!(
382 config.ui,
383 super::UiConfig::default(),
384 "non-table [ui] degrades to the default"
385 );
386 }
387
388 /// A well-shaped `[ui]` table with FUTURE keys inside still loads —
389 /// unknown keys inside are ignored, never denied (Pitfall 7).
390 #[test]
391 fn ui_table_with_unknown_keys_still_loads() {
392 let toml = r#"
393[ui]
394theme = "dark"
395future_key = "whatever"
396
397[profiles.dev]
398url = "http://localhost:9088/"
399"#;
400 let config: Config = toml::from_str(toml).expect("parse");
401 assert_eq!(config.ui.theme.as_deref(), Some("dark"));
402 }
403
404 /// SC-1's config-layer half (D-01/D-12): a `[rigs.NAME]` table with
405 /// no `[rigs.NAME.modules.*]` sub-table round-trips byte-identically
406 /// — `modules`/`module_service` never appear in the re-serialized
407 /// TOML when unset.
408 #[test]
409 fn rig_entry_without_modules_round_trips_byte_identically() {
410 let toml = "[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n";
411 let config: Config = toml::from_str(toml).expect("parse");
412 assert!(config.rigs["dev"].modules.is_empty());
413 assert_eq!(config.rigs["dev"].module_service, None);
414 // Compared against the FULL serialized config, not the input
415 // fragment: `toml::to_string` of a `Config` always emits the
416 // `[profiles]` table, so a fragment compare fails regardless of
417 // anything this phase changed. The exact-output form is the
418 // stronger assertion anyway — it fails the moment `modules`,
419 // `module_service`, or any other new key appears.
420 let reserialized = toml::to_string(&config).expect("serialize");
421 assert_eq!(
422 reserialized, "[profiles]\n\n[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n",
423 "a rig with no modules table must serialize byte-identically"
424 );
425 }
426
427 /// The documented `[rigs.NAME.modules.ID]` shape parses (D-01): a
428 /// `[rigs.dev]` table carrying `compose_file`, followed by a
429 /// `[rigs.dev.modules.git]` table carrying `version`.
430 #[test]
431 fn rig_entry_modules_table_parses() {
432 let toml = r#"
433[rigs.dev]
434compose_file = "docker/compose.yml"
435
436[rigs.dev.modules.git]
437version = "2.3.4"
438"#;
439 let config: Config = toml::from_str(toml).expect("parse");
440 let dev = &config.rigs["dev"];
441 assert_eq!(dev.modules.len(), 1);
442 assert_eq!(dev.modules["git"].version, "2.3.4");
443 assert_eq!(dev.module_service, None);
444
445 let reserialized = toml::to_string(&config).expect("serialize");
446 let back: Config = toml::from_str(&reserialized).expect("re-parse");
447 assert_eq!(back, config, "round trip must be lossless");
448 }
449
450 /// The clamp's floor belongs to validation, not serde: 0 parses fine
451 /// here (permissive deserializer) and is refused by `load` (tested in
452 /// `config::tests`).
453 #[test]
454 fn poll_interval_zero_parses_leniently() {
455 let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n";
456 let config: Config = toml::from_str(toml).expect("parse");
457 assert_eq!(config.profiles["dev"].poll_interval_secs, Some(0));
458 }
459}