ignition-core 1.6.8

Core library for ign: config, profiles, gateway client, actions, error taxonomy
Documentation
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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Config + Profile serde structs — the on-disk shape of `config.toml`.
//!
//! Secrets are REFERENCES (env var names, keyring user strings), never
//! values: no field on [`Profile`] or [`AuthRef`] ever holds a secret string
//! (CORE-02). No `deny_unknown_fields` anywhere (research Pitfall 7 —
//! forward compat; unknown keys warn at load time instead, see
//! [`super::load`]).

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// The whole `config.toml`. `profiles` is a `BTreeMap` so `profile list`
/// output — and therefore every golden — is deterministic by name.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Config {
    /// The profile commands operate on by default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub active: Option<String>,
    /// Named gateway profiles (`[profiles.NAME]` tables).
    #[serde(default)]
    pub profiles: BTreeMap<String, Profile>,
    /// Rig family defaults (`[rig]` table — just the default rig name
    /// today; 04-01).
    #[serde(default, skip_serializing_if = "rig_config_is_empty")]
    pub rig: RigConfig,
    /// Named compose rigs (`[rigs.NAME]` tables — 04-01). Omitted from
    /// serialization when empty so profile-only configs keep their exact
    /// on-disk shape (the save goldens).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub rigs: BTreeMap<String, RigEntry>,
    /// UI preferences (`[ui]` table — TUIX-05 plumbing). PLUMBING ONLY:
    /// `theme` is carried on the config, never rendered (rendering is
    /// Phase 12, TUIX-03/04). Omitted from serialization at defaults so
    /// legacy configs round-trip byte-identically; a wrong-shaped `[ui]`
    /// degrades to the default with a warning (see [`lenient_ui`]).
    #[serde(
        default,
        skip_serializing_if = "UiConfig::is_default",
        deserialize_with = "lenient_ui"
    )]
    pub ui: UiConfig,
}

/// The `[ui]` table: TUI preference keys (TUIX-05 plumbing).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UiConfig {
    /// Preferred UI theme name (e.g. `"dark"`). `None` = the built-in
    /// default. Carried only — nothing consumes it until Phase 12.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub theme: Option<String>,
}

impl UiConfig {
    /// True when nothing is set — keeps `[ui]` out of configs that never
    /// carried one (the `rig_config_is_empty` precedent).
    pub fn is_default(ui: &UiConfig) -> bool {
        *ui == UiConfig::default()
    }
}

/// Lenient `poll_interval_secs` extraction (TUIX-05): an integer loads
/// verbatim — INCLUDING 0, which the load-time clamp (see
/// [`super::load`]) refuses; anything else (string, float, negative,
/// table) warns and degrades to `None` (default cadence). A typo in the
/// NEW key never fails the load.
fn lenient_u64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
    let value = toml::Value::deserialize(d)?;
    match value {
        toml::Value::Integer(v) if v >= 0 => Ok(Some(v as u64)),
        other => {
            tracing::warn!(
                value = %other,
                "poll_interval_secs must be a non-negative integer — ignoring (default cadence in use)"
            );
            Ok(None)
        }
    }
}

/// Lenient `[ui]` extraction (TUIX-05): a well-shaped `[ui]` table loads;
/// anything else (a bare scalar, a mistyped member) warns and degrades to
/// the default. Unknown keys INSIDE a valid table are simply ignored (no
/// `deny_unknown_fields`, ever — Pitfall 7 forward compat).
fn lenient_ui<'de, D: serde::Deserializer<'de>>(d: D) -> Result<UiConfig, D::Error> {
    let value = toml::Value::deserialize(d)?;
    match UiConfig::deserialize(value) {
        Ok(ui) => Ok(ui),
        Err(_) => {
            tracing::warn!("[ui] is not a valid table — using defaults");
            Ok(UiConfig::default())
        }
    }
}

/// True when the `[rig]` block carries nothing worth serializing —
/// keeps `[rig]` out of profile-only configs entirely.
fn rig_config_is_empty(rig: &RigConfig) -> bool {
    rig.default.is_none()
}

/// The `[rig]` table: rig-family defaults (04-01).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RigConfig {
    /// Name of the `[rigs.*]` entry `ign rig` targets when no
    /// `--rig`/`IGNITION_RIG` names one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
}

/// One named compose rig (`[rigs.NAME]`, 04-01). References only —
/// never secrets (the compose file itself owns those).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RigEntry {
    /// Path to the rig's compose file (`~` and `${VAR}` expanded at use
    /// time — manual expansion, no new dependency).
    pub compose_file: String,
    /// Explicit compose project name (`-p`). OPTIONAL — omit to honor
    /// the rig's own `.env` `COMPOSE_PROJECT_NAME` (the identity truth,
    /// research Pattern 1); set only to override it deliberately.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_name: Option<String>,
    /// Declared third-party modules to inject via the generated compose
    /// override (`[rigs.NAME.modules.*]`, Phase 16, D-01): a MAP, so a
    /// rig can declare any number of modules, each pinned to its own
    /// version. Empty by construction on every discovery path except
    /// [`super::super::rig::resolve_entry`] — the structural reason
    /// SC-1 (an undeclared rig behaves byte-identically to today) holds.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub modules: BTreeMap<String, ModuleDeclaration>,
    /// Escape hatch (D-12) overriding the derived gateway service when
    /// modules are declared: the DERIVED value (the first service
    /// publishing a port targeting 8088/443) is used when this is
    /// unset.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub module_service: Option<String>,
}

/// One declared module under `[rigs.NAME.modules.ID]` (Phase 16, D-01):
/// today just the pinned version, but its own table (not a bare string)
/// so a future key never forces a shape migration.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModuleDeclaration {
    /// The pinned version string (validated by
    /// [`crate::module::validate_version`] before use — this struct
    /// carries it verbatim, unvalidated, exactly like `compose_file`).
    pub version: String,
}

/// One gateway profile.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
    /// Gateway base URL.
    pub url: url::Url,
    /// Optional display label (CORE-01) — absent from TOML and JSON when
    /// unset so existing configs and goldens don't churn.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    /// Verify TLS certificates (self-signed dev rigs turn this off).
    #[serde(default = "default_ssl_verify")]
    pub ssl_verify: bool,
    /// HOW to find the credential — a reference, never a value.
    #[serde(default)]
    pub auth: AuthRef,
    /// The CLI-GENERATED webdev scriptExec shared secret (05-03) — the
    /// ONE deliberate value-carrying exception to the references-only
    /// rule: this secret is not a user credential but a token the CLI
    /// itself mints at deploy time, must round-trip verbatim (the
    /// deployed route compares it byte-for-byte), and cannot live in
    /// an env var another tool owns. It rides ONLY in this 0600 config
    /// store and the baked route zip member — never in any action
    /// result, log, or JSON envelope (the redaction discipline).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub webdev_secret: Option<String>,
    /// TUI background-refresh poll interval in seconds (TUIX-05
    /// plumbing). `None` = the default cadence. Sub-second values
    /// (`Some(0)`) are REFUSED by load-time validation on the normal CLI
    /// path (exit 3, `poll_interval_too_small`) and degraded to `None`
    /// on the TUI path ([`super::load_for_tui`]); a wrong-TYPED value
    /// degrades to `None` with a warning (see [`lenient_u64`]) — a typo
    /// in the new key never bricks the load.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "lenient_u64"
    )]
    pub poll_interval_secs: Option<u64>,
}

fn default_ssl_verify() -> bool {
    true
}

/// Credential reference — the three supported forms, untagged so the TOML
/// stays flat: `auth = { token_env = "X" }`, `auth = { keyring =
/// "profile:prod" }`, or `auth = { user_env = "U", password_env = "P" }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AuthRef {
    /// Token lives in this env var.
    TokenEnv {
        /// Env var NAME (never the token itself).
        token_env: String,
    },
    /// Token lives in the OS keyring (service `ignition-cli`) under this
    /// user string, e.g. `"profile:prod"`.
    Keyring {
        /// Keyring user string.
        keyring: String,
    },
    /// Basic pair from two env vars.
    Basic {
        /// Env var NAME for the user.
        user_env: String,
        /// Env var NAME for the password.
        password_env: String,
    },
}

impl AuthRef {
    /// Safe kind string for output models — never a secret or env value.
    pub fn kind(&self) -> &'static str {
        match self {
            Self::TokenEnv { .. } => "token_env",
            Self::Keyring { .. } => "keyring",
            Self::Basic { .. } => "basic",
        }
    }
}

impl Default for AuthRef {
    /// A profile without an `auth` key resolves through the generic env
    /// token path (`IGNITION_TOKEN`) — the last env-token step of the LOCKED
    /// resolution order.
    fn default() -> Self {
        Self::TokenEnv {
            token_env: "IGNITION_TOKEN".to_string(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{AuthRef, Config};

    /// AuthRef untagged round-trip across all three reference forms, and
    /// `kind()` never leaks a value.
    #[test]
    fn auth_ref_untagged_round_trip() {
        let toml = r#"
active = "dev"

[profiles.dev]
url = "http://localhost:9088/"
label = "Dev rig"
auth = { token_env = "IGNITION_TOKEN" }

[profiles.prod]
url = "https://gw.example.com:8443/"
auth = { keyring = "profile:prod" }

[profiles.rig]
url = "http://192.0.2.5:9088/"
ssl_verify = false
auth = { user_env = "IGNITION_USER", password_env = "IGNITION_PASSWORD" }
"#;
        let config: Config = toml::from_str(toml).expect("parse");
        assert_eq!(config.active.as_deref(), Some("dev"));
        assert_eq!(config.profiles.len(), 3);

        let dev = &config.profiles["dev"];
        assert_eq!(dev.label.as_deref(), Some("Dev rig"));
        assert!(dev.ssl_verify, "ssl_verify defaults true");
        assert_eq!(
            dev.auth,
            AuthRef::TokenEnv {
                token_env: "IGNITION_TOKEN".into()
            }
        );
        assert_eq!(dev.auth.kind(), "token_env");

        let prod = &config.profiles["prod"];
        assert_eq!(prod.label, None, "label absent when unset");
        assert_eq!(
            prod.auth,
            AuthRef::Keyring {
                keyring: "profile:prod".into()
            }
        );
        assert_eq!(prod.auth.kind(), "keyring");

        let rig = &config.profiles["rig"];
        assert!(!rig.ssl_verify, "ssl_verify = false honored");
        assert_eq!(
            rig.auth,
            AuthRef::Basic {
                user_env: "IGNITION_USER".into(),
                password_env: "IGNITION_PASSWORD".into(),
            }
        );
        assert_eq!(rig.auth.kind(), "basic");

        // Serialize round-trip: label omitted when None, ssl_verify omitted
        // when default (serde defaults skip nothing here except via attrs).
        let reserialized = toml::to_string(&config).expect("serialize");
        assert!(reserialized.contains("label = \"Dev rig\""));
        assert!(!reserialized.contains("[profiles.prod]\nlabel"));
        let back: Config = toml::from_str(&reserialized).expect("re-parse");
        assert_eq!(back, config);
    }

    /// Missing `auth` falls back to the generic env token reference.
    #[test]
    fn auth_defaults_to_generic_token_env() {
        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
        let config: Config = toml::from_str(toml).expect("parse");
        assert_eq!(
            config.profiles["dev"].auth,
            AuthRef::TokenEnv {
                token_env: "IGNITION_TOKEN".into()
            }
        );
    }

    /// New-schema round trip (TUIX-05): `[ui]` + `poll_interval_secs`
    /// survive load → serialize → load losslessly.
    #[test]
    fn ui_and_poll_interval_round_trip() {
        let toml = r#"
active = "dev"

[ui]
theme = "dark"

[profiles.dev]
url = "http://localhost:9088/"
poll_interval_secs = 10
"#;
        let config: Config = toml::from_str(toml).expect("parse");
        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));

        let reserialized = toml::to_string(&config).expect("serialize");
        let back: Config = toml::from_str(&reserialized).expect("re-parse");
        assert_eq!(back, config, "round trip must be lossless");
    }

    /// Legacy-shape pin: a config without the new keys serializes WITHOUT
    /// them — existing configs keep their exact on-disk shape (goldens).
    #[test]
    fn legacy_config_serializes_without_new_keys() {
        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
        let config: Config = toml::from_str(toml).expect("parse");
        let out = toml::to_string(&config).expect("serialize");
        assert!(
            !out.contains("ui") && !out.contains("poll_interval_secs"),
            "new keys must stay off legacy-shaped configs: {out}"
        );
    }

    /// Lenient degrade (TUIX-05): a wrong-TYPED value in the NEW surface
    /// never fails the load — it warns and falls back to defaults.
    #[test]
    fn lenient_degrade_on_bad_typed_new_keys() {
        let toml = r#"
[ui]
theme = "dark"

[profiles.dev]
url = "http://localhost:9088/"
poll_interval_secs = "banana"
"#;
        let config: Config = toml::from_str(toml).expect("typo'd new key must not fail the load");
        assert_eq!(
            config.profiles["dev"].poll_interval_secs, None,
            "bad poll_interval_secs degrades to None"
        );

        let toml = "ui = 42\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
        let config: Config = toml::from_str(toml).expect("bad [ui] shape must not fail the load");
        assert_eq!(
            config.ui,
            super::UiConfig::default(),
            "non-table [ui] degrades to the default"
        );
    }

    /// A well-shaped `[ui]` table with FUTURE keys inside still loads —
    /// unknown keys inside are ignored, never denied (Pitfall 7).
    #[test]
    fn ui_table_with_unknown_keys_still_loads() {
        let toml = r#"
[ui]
theme = "dark"
future_key = "whatever"

[profiles.dev]
url = "http://localhost:9088/"
"#;
        let config: Config = toml::from_str(toml).expect("parse");
        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
    }

    /// SC-1's config-layer half (D-01/D-12): a `[rigs.NAME]` table with
    /// no `[rigs.NAME.modules.*]` sub-table round-trips byte-identically
    /// — `modules`/`module_service` never appear in the re-serialized
    /// TOML when unset.
    #[test]
    fn rig_entry_without_modules_round_trips_byte_identically() {
        let toml = "[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n";
        let config: Config = toml::from_str(toml).expect("parse");
        assert!(config.rigs["dev"].modules.is_empty());
        assert_eq!(config.rigs["dev"].module_service, None);
        // Compared against the FULL serialized config, not the input
        // fragment: `toml::to_string` of a `Config` always emits the
        // `[profiles]` table, so a fragment compare fails regardless of
        // anything this phase changed. The exact-output form is the
        // stronger assertion anyway — it fails the moment `modules`,
        // `module_service`, or any other new key appears.
        let reserialized = toml::to_string(&config).expect("serialize");
        assert_eq!(
            reserialized, "[profiles]\n\n[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n",
            "a rig with no modules table must serialize byte-identically"
        );
    }

    /// The documented `[rigs.NAME.modules.ID]` shape parses (D-01): a
    /// `[rigs.dev]` table carrying `compose_file`, followed by a
    /// `[rigs.dev.modules.git]` table carrying `version`.
    #[test]
    fn rig_entry_modules_table_parses() {
        let toml = r#"
[rigs.dev]
compose_file = "docker/compose.yml"

[rigs.dev.modules.git]
version = "2.3.4"
"#;
        let config: Config = toml::from_str(toml).expect("parse");
        let dev = &config.rigs["dev"];
        assert_eq!(dev.modules.len(), 1);
        assert_eq!(dev.modules["git"].version, "2.3.4");
        assert_eq!(dev.module_service, None);

        let reserialized = toml::to_string(&config).expect("serialize");
        let back: Config = toml::from_str(&reserialized).expect("re-parse");
        assert_eq!(back, config, "round trip must be lossless");
    }

    /// The clamp's floor belongs to validation, not serde: 0 parses fine
    /// here (permissive deserializer) and is refused by `load` (tested in
    /// `config::tests`).
    #[test]
    fn poll_interval_zero_parses_leniently() {
        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n";
        let config: Config = toml::from_str(toml).expect("parse");
        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(0));
    }
}