Skip to main content

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}