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 /// The CLI-GENERATED secret of the tagDelete route (26-17, D-13): the
176 /// same rule as [`Self::webdev_secret`] (CLI-minted at
177 /// `ign webdev deploy --with-tag-delete`, stored only in this 0600
178 /// config store and the baked route member, never output), and
179 /// independent of it: rotating one never changes the other.
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub webdev_tag_delete_secret: Option<String>,
182 /// TUI background-refresh poll interval in seconds (TUIX-05
183 /// plumbing). `None` = the default cadence. Sub-second values
184 /// (`Some(0)`) are REFUSED by load-time validation on the normal CLI
185 /// path (exit 3, `poll_interval_too_small`) and degraded to `None`
186 /// on the TUI path ([`super::load_for_tui`]); a wrong-TYPED value
187 /// degrades to `None` with a warning (see [`lenient_u64`]) — a typo
188 /// in the new key never bricks the load.
189 #[serde(
190 default,
191 skip_serializing_if = "Option::is_none",
192 deserialize_with = "lenient_u64"
193 )]
194 pub poll_interval_secs: Option<u64>,
195}
196
197fn default_ssl_verify() -> bool {
198 true
199}
200
201/// Credential reference — the three supported forms, untagged so the TOML
202/// stays flat: `auth = { token_env = "X" }`, `auth = { keyring =
203/// "profile:prod" }`, or `auth = { user_env = "U", password_env = "P" }`.
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(untagged)]
206pub enum AuthRef {
207 /// Token lives in this env var.
208 TokenEnv {
209 /// Env var NAME (never the token itself).
210 token_env: String,
211 },
212 /// Token lives in the OS keyring (service `ignition-cli`) under this
213 /// user string, e.g. `"profile:prod"`.
214 Keyring {
215 /// Keyring user string.
216 keyring: String,
217 },
218 /// Basic pair from two env vars.
219 Basic {
220 /// Env var NAME for the user.
221 user_env: String,
222 /// Env var NAME for the password.
223 password_env: String,
224 },
225}
226
227impl AuthRef {
228 /// Safe kind string for output models — never a secret or env value.
229 pub fn kind(&self) -> &'static str {
230 match self {
231 Self::TokenEnv { .. } => "token_env",
232 Self::Keyring { .. } => "keyring",
233 Self::Basic { .. } => "basic",
234 }
235 }
236}
237
238impl Default for AuthRef {
239 /// A profile without an `auth` key resolves through the generic env
240 /// token path (`IGNITION_TOKEN`) — the last env-token step of the LOCKED
241 /// resolution order.
242 fn default() -> Self {
243 Self::TokenEnv {
244 token_env: "IGNITION_TOKEN".to_string(),
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::{AuthRef, Config};
252
253 /// AuthRef untagged round-trip across all three reference forms, and
254 /// `kind()` never leaks a value.
255 #[test]
256 fn auth_ref_untagged_round_trip() {
257 let toml = r#"
258active = "dev"
259
260[profiles.dev]
261url = "http://localhost:9088/"
262label = "Dev rig"
263auth = { token_env = "IGNITION_TOKEN" }
264
265[profiles.prod]
266url = "https://gw.example.com:8443/"
267auth = { keyring = "profile:prod" }
268
269[profiles.rig]
270url = "http://192.0.2.5:9088/"
271ssl_verify = false
272auth = { user_env = "IGNITION_USER", password_env = "IGNITION_PASSWORD" }
273"#;
274 let config: Config = toml::from_str(toml).expect("parse");
275 assert_eq!(config.active.as_deref(), Some("dev"));
276 assert_eq!(config.profiles.len(), 3);
277
278 let dev = &config.profiles["dev"];
279 assert_eq!(dev.label.as_deref(), Some("Dev rig"));
280 assert!(dev.ssl_verify, "ssl_verify defaults true");
281 assert_eq!(
282 dev.auth,
283 AuthRef::TokenEnv {
284 token_env: "IGNITION_TOKEN".into()
285 }
286 );
287 assert_eq!(dev.auth.kind(), "token_env");
288
289 let prod = &config.profiles["prod"];
290 assert_eq!(prod.label, None, "label absent when unset");
291 assert_eq!(
292 prod.auth,
293 AuthRef::Keyring {
294 keyring: "profile:prod".into()
295 }
296 );
297 assert_eq!(prod.auth.kind(), "keyring");
298
299 let rig = &config.profiles["rig"];
300 assert!(!rig.ssl_verify, "ssl_verify = false honored");
301 assert_eq!(
302 rig.auth,
303 AuthRef::Basic {
304 user_env: "IGNITION_USER".into(),
305 password_env: "IGNITION_PASSWORD".into(),
306 }
307 );
308 assert_eq!(rig.auth.kind(), "basic");
309
310 // Serialize round-trip: label omitted when None, ssl_verify omitted
311 // when default (serde defaults skip nothing here except via attrs).
312 let reserialized = toml::to_string(&config).expect("serialize");
313 assert!(reserialized.contains("label = \"Dev rig\""));
314 assert!(!reserialized.contains("[profiles.prod]\nlabel"));
315 let back: Config = toml::from_str(&reserialized).expect("re-parse");
316 assert_eq!(back, config);
317 }
318
319 /// Missing `auth` falls back to the generic env token reference.
320 #[test]
321 fn auth_defaults_to_generic_token_env() {
322 let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
323 let config: Config = toml::from_str(toml).expect("parse");
324 assert_eq!(
325 config.profiles["dev"].auth,
326 AuthRef::TokenEnv {
327 token_env: "IGNITION_TOKEN".into()
328 }
329 );
330 }
331
332 /// New-schema round trip (TUIX-05): `[ui]` + `poll_interval_secs`
333 /// survive load → serialize → load losslessly.
334 #[test]
335 fn ui_and_poll_interval_round_trip() {
336 let toml = r#"
337active = "dev"
338
339[ui]
340theme = "dark"
341
342[profiles.dev]
343url = "http://localhost:9088/"
344poll_interval_secs = 10
345"#;
346 let config: Config = toml::from_str(toml).expect("parse");
347 assert_eq!(config.ui.theme.as_deref(), Some("dark"));
348 assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
349
350 let reserialized = toml::to_string(&config).expect("serialize");
351 let back: Config = toml::from_str(&reserialized).expect("re-parse");
352 assert_eq!(back, config, "round trip must be lossless");
353 }
354
355 /// Legacy-shape pin: a config without the new keys serializes WITHOUT
356 /// them — existing configs keep their exact on-disk shape (goldens).
357 #[test]
358 fn legacy_config_serializes_without_new_keys() {
359 let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
360 let config: Config = toml::from_str(toml).expect("parse");
361 let out = toml::to_string(&config).expect("serialize");
362 assert!(
363 !out.contains("ui") && !out.contains("poll_interval_secs"),
364 "new keys must stay off legacy-shaped configs: {out}"
365 );
366 }
367
368 /// Lenient degrade (TUIX-05): a wrong-TYPED value in the NEW surface
369 /// never fails the load — it warns and falls back to defaults.
370 #[test]
371 fn lenient_degrade_on_bad_typed_new_keys() {
372 let toml = r#"
373[ui]
374theme = "dark"
375
376[profiles.dev]
377url = "http://localhost:9088/"
378poll_interval_secs = "banana"
379"#;
380 let config: Config = toml::from_str(toml).expect("typo'd new key must not fail the load");
381 assert_eq!(
382 config.profiles["dev"].poll_interval_secs, None,
383 "bad poll_interval_secs degrades to None"
384 );
385
386 let toml = "ui = 42\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
387 let config: Config = toml::from_str(toml).expect("bad [ui] shape must not fail the load");
388 assert_eq!(
389 config.ui,
390 super::UiConfig::default(),
391 "non-table [ui] degrades to the default"
392 );
393 }
394
395 /// A well-shaped `[ui]` table with FUTURE keys inside still loads —
396 /// unknown keys inside are ignored, never denied (Pitfall 7).
397 #[test]
398 fn ui_table_with_unknown_keys_still_loads() {
399 let toml = r#"
400[ui]
401theme = "dark"
402future_key = "whatever"
403
404[profiles.dev]
405url = "http://localhost:9088/"
406"#;
407 let config: Config = toml::from_str(toml).expect("parse");
408 assert_eq!(config.ui.theme.as_deref(), Some("dark"));
409 }
410
411 /// SC-1's config-layer half (D-01/D-12): a `[rigs.NAME]` table with
412 /// no `[rigs.NAME.modules.*]` sub-table round-trips byte-identically
413 /// — `modules`/`module_service` never appear in the re-serialized
414 /// TOML when unset.
415 #[test]
416 fn rig_entry_without_modules_round_trips_byte_identically() {
417 let toml = "[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n";
418 let config: Config = toml::from_str(toml).expect("parse");
419 assert!(config.rigs["dev"].modules.is_empty());
420 assert_eq!(config.rigs["dev"].module_service, None);
421 // Compared against the FULL serialized config, not the input
422 // fragment: `toml::to_string` of a `Config` always emits the
423 // `[profiles]` table, so a fragment compare fails regardless of
424 // anything this phase changed. The exact-output form is the
425 // stronger assertion anyway — it fails the moment `modules`,
426 // `module_service`, or any other new key appears.
427 let reserialized = toml::to_string(&config).expect("serialize");
428 assert_eq!(
429 reserialized, "[profiles]\n\n[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n",
430 "a rig with no modules table must serialize byte-identically"
431 );
432 }
433
434 /// The documented `[rigs.NAME.modules.ID]` shape parses (D-01): a
435 /// `[rigs.dev]` table carrying `compose_file`, followed by a
436 /// `[rigs.dev.modules.git]` table carrying `version`.
437 #[test]
438 fn rig_entry_modules_table_parses() {
439 let toml = r#"
440[rigs.dev]
441compose_file = "docker/compose.yml"
442
443[rigs.dev.modules.git]
444version = "2.3.4"
445"#;
446 let config: Config = toml::from_str(toml).expect("parse");
447 let dev = &config.rigs["dev"];
448 assert_eq!(dev.modules.len(), 1);
449 assert_eq!(dev.modules["git"].version, "2.3.4");
450 assert_eq!(dev.module_service, None);
451
452 let reserialized = toml::to_string(&config).expect("serialize");
453 let back: Config = toml::from_str(&reserialized).expect("re-parse");
454 assert_eq!(back, config, "round trip must be lossless");
455 }
456
457 /// The clamp's floor belongs to validation, not serde: 0 parses fine
458 /// here (permissive deserializer) and is refused by `load` (tested in
459 /// `config::tests`).
460 #[test]
461 fn poll_interval_zero_parses_leniently() {
462 let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n";
463 let config: Config = toml::from_str(toml).expect("parse");
464 assert_eq!(config.profiles["dev"].poll_interval_secs, Some(0));
465 }
466}