Skip to main content

greentic_deployer/cli/
config.rs

1//! `gtc op config {show,set}` (`A3`).
2//!
3//! `show` is the inspection surface — host_config, per-slot answers (each
4//! redacted), and runtime values are folded into one structured envelope.
5//! `set` mutates `host_config` only; setup answers go through
6//! `op env-packs add/update` and runtime is intentionally read-only per
7//! `plans/next-gen-deployment.md` §P1b.
8
9use chrono::Utc;
10use greentic_deploy_spec::{CapabilitySlot, EnvId};
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13
14use crate::environment::{EnvironmentStore, FieldUpdate, LocalFsStore, UpdateEnvironmentPayload};
15
16use super::{
17    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
18};
19
20const NOUN: &str = "config";
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ConfigShowFilter {
24    pub environment_id: String,
25    /// When set, return only this slot's answers under `pack_answers`.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub slot: Option<CapabilitySlot>,
28}
29
30/// `op config show`. Returns host_config, per-slot answer-presence map,
31/// and runtime discovery values. Secrets are NEVER inlined — slots that
32/// hold secret-shaped answers are reported as presence-only.
33pub fn show(
34    store: &LocalFsStore,
35    flags: &OpFlags,
36    payload: Option<ConfigShowFilter>,
37) -> Result<OpOutcome, OpError> {
38    if flags.schema_only {
39        return Ok(OpOutcome::new(NOUN, "show", show_schema()));
40    }
41    let payload = resolve_payload::<ConfigShowFilter>(flags, payload)?;
42    let env_id = parse_env_id(&payload.environment_id)?;
43    let env = store.load(&env_id)?;
44    let runtime = store.load_runtime(&env_id)?;
45    let mut pack_answers = serde_json::Map::new();
46    let slots: Vec<CapabilitySlot> = match payload.slot {
47        Some(s) => vec![s],
48        None => env.packs.iter().map(|b| b.slot).collect(),
49    };
50    for slot in slots {
51        let value = store.load_pack_answers(&env_id, slot)?;
52        pack_answers.insert(
53            slot.to_string(),
54            match value {
55                Some(v) => json!({
56                    "present": true,
57                    "keys": collect_top_keys(&v),
58                }),
59                None => json!({"present": false}),
60            },
61        );
62    }
63    Ok(OpOutcome::new(
64        NOUN,
65        "show",
66        json!({
67            "environment_id": env_id.as_str(),
68            "host_config": env.host_config,
69            "credentials_ref": env.credentials_ref,
70            "packs": env.packs,
71            "pack_answers": pack_answers,
72            "runtime": runtime,
73            "snapshot_at": Utc::now(),
74        }),
75    ))
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConfigSetPayload {
80    pub environment_id: String,
81    pub name: Option<String>,
82    pub region: Option<String>,
83    pub tenant_org_id: Option<String>,
84    /// Bind address for the runtime's local HTTP listener. Accepted as a
85    /// free-form `SocketAddr` string (e.g. `127.0.0.1:8080`, `0.0.0.0:9090`,
86    /// `[::1]:8443`). Parsed and validated at apply time so a malformed value
87    /// is rejected before the env is touched. `None` leaves the existing
88    /// value unchanged — matches `region`/`tenant_org_id` semantics; there's
89    /// no clear-to-`None` flow today.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub listen_addr: Option<String>,
92    /// Persistent public base URL the runtime exposes (`https://host[:port]`,
93    /// origin only). Validated up front via
94    /// [`validate_public_base_url`](greentic_deploy_spec::validate_public_base_url)
95    /// before any state is touched. `None` leaves the existing value
96    /// unchanged — same semantics as `region`/`tenant_org_id`.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub public_base_url: Option<String>,
99    /// Whether the runtime serves the built-in webchat GUI. `None` leaves the
100    /// existing value unchanged — same semantics as `region`/`tenant_org_id`.
101    /// `Some(b)` is an explicit choice; the env-id default (on for `local`) is
102    /// resolved by [`EnvironmentHostConfig::resolved_gui_enabled`](greentic_deploy_spec::EnvironmentHostConfig::resolved_gui_enabled)
103    /// only when this is unset.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub gui_enabled: Option<bool>,
106}
107
108/// `op config set`. Mutates `host_config` fields (region, tenant_org_id,
109/// listen_addr, gui_enabled) and the env's display `name`. To change anything
110/// inside an env-pack's answers, use `op env-packs update`.
111pub fn set(
112    store: &LocalFsStore,
113    flags: &OpFlags,
114    payload: Option<ConfigSetPayload>,
115) -> Result<OpOutcome, OpError> {
116    if flags.schema_only {
117        return Ok(OpOutcome::new(NOUN, "set", set_schema()));
118    }
119    let payload = resolve_payload::<ConfigSetPayload>(flags, payload)?;
120    let env_id = parse_env_id(&payload.environment_id)?;
121    // Parse the bind address up front so a malformed value is rejected before
122    // we touch the env store, the audit log, or even the transaction lock.
123    // `{raw:?}` debug-formats the input — escaping newlines/quotes — so a
124    // hostile payload can't break the structured audit log it lands in.
125    let parsed_listen_addr = payload
126        .listen_addr
127        .as_deref()
128        .map(|raw| {
129            raw.parse::<std::net::SocketAddr>().map_err(|e| {
130                OpError::InvalidArgument(format!(
131                    "listen_addr {raw:?} is not a valid socket address: {e}"
132                ))
133            })
134        })
135        .transpose()?;
136    let parsed_public_base_url =
137        super::env::parse_optional_public_base_url(&payload.public_base_url)?;
138    let mut fields = Vec::new();
139    if payload.name.is_some() {
140        fields.push("name");
141    }
142    if payload.region.is_some() {
143        fields.push("region");
144    }
145    if payload.tenant_org_id.is_some() {
146        fields.push("tenant_org_id");
147    }
148    if parsed_listen_addr.is_some() {
149        fields.push("listen_addr");
150    }
151    if parsed_public_base_url.is_some() {
152        fields.push("public_base_url");
153    }
154    if payload.gui_enabled.is_some() {
155        fields.push("gui_enabled");
156    }
157    let ctx = AuditCtx {
158        env_id: env_id.clone(),
159        noun: NOUN,
160        verb: "set",
161        target: json!({"fields": fields}),
162        idempotency_key: None,
163    };
164    audit_and_record(store, ctx, |_committed| {
165        let env = store
166            .update_environment(
167                &env_id,
168                UpdateEnvironmentPayload {
169                    name: payload.name,
170                    region: FieldUpdate::from_option(payload.region),
171                    tenant_org_id: FieldUpdate::from_option(payload.tenant_org_id),
172                    listen_addr: FieldUpdate::from_option(parsed_listen_addr),
173                    public_base_url: FieldUpdate::from_option(parsed_public_base_url),
174                    gui_enabled: FieldUpdate::from_option(payload.gui_enabled),
175                },
176            )
177            .map_err(map_store_err_preserving_noun)?;
178        let outcome = OpOutcome::new(
179            NOUN,
180            "set",
181            json!({
182                "environment_id": env_id.as_str(),
183                "host_config": env.host_config,
184                "name": env.name,
185            }),
186        );
187        Ok((outcome, super::AuditGens::NONE))
188    })
189}
190
191// --- internals -----------------------------------------------------------
192
193fn resolve_payload<T: serde::de::DeserializeOwned>(
194    flags: &OpFlags,
195    payload: Option<T>,
196) -> Result<T, OpError> {
197    if let Some(p) = payload {
198        return Ok(p);
199    }
200    if let Some(path) = &flags.answers {
201        return super::load_answers::<T>(path);
202    }
203    Err(OpError::InvalidArgument(
204        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
205    ))
206}
207
208fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
209    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
210}
211
212fn collect_top_keys(v: &Value) -> Vec<String> {
213    v.as_object()
214        .map(|m| m.keys().cloned().collect())
215        .unwrap_or_default()
216}
217
218fn show_schema() -> Value {
219    json!({
220        "$schema": "https://json-schema.org/draft/2020-12/schema",
221        "title": "ConfigShowFilter",
222        "type": "object",
223        "required": ["environment_id"],
224        "additionalProperties": false,
225        "properties": {
226            "environment_id": {"type": "string"},
227            "slot": {"type": ["string", "null"], "enum": [null, "deployer", "secrets", "telemetry", "sessions", "state", "revocation"]}
228        }
229    })
230}
231
232fn set_schema() -> Value {
233    json!({
234        "$schema": "https://json-schema.org/draft/2020-12/schema",
235        "title": "ConfigSetPayload",
236        "type": "object",
237        "required": ["environment_id"],
238        "additionalProperties": false,
239        "properties": {
240            "environment_id": {"type": "string"},
241            "name": {"type": ["string", "null"]},
242            "region": {"type": ["string", "null"]},
243            "tenant_org_id": {"type": ["string", "null"]},
244            "listen_addr": {
245                "type": ["string", "null"],
246                "description": "Bind address for the runtime's local HTTP listener (e.g. 127.0.0.1:8080, 0.0.0.0:9090, [::1]:8443). Parsed as SocketAddr; malformed values are rejected."
247            },
248            "public_base_url": {"type": ["string", "null"], "description": "origin-only URL (https://host[:port])"},
249            "gui_enabled": {"type": ["boolean", "null"], "description": "serve the built-in webchat GUI; null leaves the stored value unchanged (the env-id default — on for local — applies only when unset)"}
250        }
251    })
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::cli::tests_common::make_env;
258    use tempfile::tempdir;
259
260    #[test]
261    fn show_returns_host_config_and_runtime() {
262        let dir = tempdir().unwrap();
263        let store = LocalFsStore::new(dir.path());
264        store.save(&make_env("local")).unwrap();
265        let outcome = show(
266            &store,
267            &OpFlags::default(),
268            Some(ConfigShowFilter {
269                environment_id: "local".to_string(),
270                slot: None,
271            }),
272        )
273        .unwrap();
274        assert_eq!(outcome.op, "show");
275        let host = outcome.result.get("host_config").unwrap();
276        assert_eq!(host.get("env_id").and_then(|v| v.as_str()), Some("local"));
277    }
278
279    #[test]
280    fn show_filters_by_slot() {
281        let dir = tempdir().unwrap();
282        let store = LocalFsStore::new(dir.path());
283        store.save(&make_env("local")).unwrap();
284        // Save answers for one slot only.
285        store
286            .save_pack_answers(
287                &EnvId::try_from("local").unwrap(),
288                CapabilitySlot::Secrets,
289                &json!({"backend": "dev-store", "url": "memory://"}),
290            )
291            .unwrap();
292        let outcome = show(
293            &store,
294            &OpFlags::default(),
295            Some(ConfigShowFilter {
296                environment_id: "local".to_string(),
297                slot: Some(CapabilitySlot::Secrets),
298            }),
299        )
300        .unwrap();
301        let pa = outcome.result.get("pack_answers").unwrap();
302        let secrets = pa.get("secrets").unwrap();
303        assert_eq!(secrets.get("present").and_then(|v| v.as_bool()), Some(true));
304        let keys = secrets.get("keys").and_then(|v| v.as_array()).unwrap();
305        assert_eq!(keys.len(), 2);
306    }
307
308    #[test]
309    fn set_updates_region_and_tenant_org_id() {
310        let dir = tempdir().unwrap();
311        let store = LocalFsStore::new(dir.path());
312        store.save(&make_env("local")).unwrap();
313        set(
314            &store,
315            &OpFlags::default(),
316            Some(ConfigSetPayload {
317                environment_id: "local".to_string(),
318                name: Some("renamed".to_string()),
319                region: Some("eu-west-1".to_string()),
320                tenant_org_id: Some("acme".to_string()),
321                listen_addr: None,
322                public_base_url: None,
323                gui_enabled: None,
324            }),
325        )
326        .unwrap();
327        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
328        assert_eq!(env.name, "renamed");
329        assert_eq!(env.host_config.region.as_deref(), Some("eu-west-1"));
330        assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("acme"));
331        // listen_addr is left untouched when payload field is None.
332        assert_eq!(env.host_config.listen_addr, None);
333    }
334
335    #[test]
336    fn set_updates_listen_addr_to_loopback_default() {
337        use greentic_deploy_spec::DEFAULT_LISTEN_ADDR;
338        let dir = tempdir().unwrap();
339        let store = LocalFsStore::new(dir.path());
340        store.save(&make_env("local")).unwrap();
341        let outcome = set(
342            &store,
343            &OpFlags::default(),
344            Some(ConfigSetPayload {
345                environment_id: "local".to_string(),
346                name: None,
347                region: None,
348                tenant_org_id: None,
349                listen_addr: Some("127.0.0.1:8080".to_string()),
350                public_base_url: None,
351                gui_enabled: None,
352            }),
353        )
354        .unwrap();
355        assert_eq!(outcome.op, "set");
356        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
357        assert_eq!(env.host_config.listen_addr, Some(DEFAULT_LISTEN_ADDR));
358    }
359
360    #[test]
361    fn set_updates_listen_addr_to_explicit_non_loopback() {
362        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
363        let dir = tempdir().unwrap();
364        let store = LocalFsStore::new(dir.path());
365        store.save(&make_env("local")).unwrap();
366        set(
367            &store,
368            &OpFlags::default(),
369            Some(ConfigSetPayload {
370                environment_id: "local".to_string(),
371                name: None,
372                region: None,
373                tenant_org_id: None,
374                listen_addr: Some("0.0.0.0:9090".to_string()),
375                public_base_url: None,
376                gui_enabled: None,
377            }),
378        )
379        .unwrap();
380        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
381        let expected = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
382        assert_eq!(env.host_config.listen_addr, Some(expected));
383    }
384
385    #[test]
386    fn set_rejects_malformed_listen_addr_before_touching_env() {
387        let dir = tempdir().unwrap();
388        let store = LocalFsStore::new(dir.path());
389        store.save(&make_env("local")).unwrap();
390        let err = set(
391            &store,
392            &OpFlags::default(),
393            Some(ConfigSetPayload {
394                environment_id: "local".to_string(),
395                name: None,
396                region: None,
397                tenant_org_id: None,
398                listen_addr: Some("not-a-socket-addr".to_string()),
399                public_base_url: None,
400                gui_enabled: None,
401            }),
402        )
403        .expect_err("malformed listen_addr must be rejected");
404        let msg = err.to_string();
405        assert!(
406            msg.contains("listen_addr") && msg.contains("not-a-socket-addr"),
407            "error must name the offending field + value, got: {msg}"
408        );
409        // Env state must remain untouched.
410        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
411        assert_eq!(env.host_config.listen_addr, None);
412    }
413
414    #[test]
415    fn set_error_debug_formats_hostile_input_to_neutralize_log_injection() {
416        let dir = tempdir().unwrap();
417        let store = LocalFsStore::new(dir.path());
418        store.save(&make_env("local")).unwrap();
419        // A payload that tries to inject a fake structured-log line.
420        let hostile = "1.2.3.4\nlevel=ERROR msg=injected";
421        let err = set(
422            &store,
423            &OpFlags::default(),
424            Some(ConfigSetPayload {
425                environment_id: "local".to_string(),
426                name: None,
427                region: None,
428                tenant_org_id: None,
429                listen_addr: Some(hostile.to_string()),
430                public_base_url: None,
431                gui_enabled: None,
432            }),
433        )
434        .expect_err("hostile listen_addr must still be rejected");
435        let msg = err.to_string();
436        // Debug-format escapes the newline as `\n`, so the literal newline
437        // doesn't survive into the error message — log shippers see one line.
438        assert!(
439            !msg.contains('\n'),
440            "error message must not contain a raw newline, got: {msg:?}"
441        );
442        assert!(
443            msg.contains("\\n"),
444            "debug-format should have escaped the newline; got: {msg:?}"
445        );
446    }
447
448    #[test]
449    fn set_schema_advertises_every_payload_field_so_strict_properties_does_not_reject() {
450        // `additionalProperties: false` means the schema gate rejects any key
451        // the schema does not list. If a contributor adds a field to
452        // `ConfigSetPayload` and forgets to extend `set_schema()`, callers
453        // can never set it. This test pins the full expected key set so the
454        // failure mode is "test break" not "silent CLI gap".
455        let schema = set_schema();
456        let props = schema
457            .get("properties")
458            .and_then(|p| p.as_object())
459            .expect("schema has properties");
460        let actual: std::collections::BTreeSet<&str> = props.keys().map(String::as_str).collect();
461        let expected: std::collections::BTreeSet<&str> = [
462            "environment_id",
463            "name",
464            "region",
465            "tenant_org_id",
466            "listen_addr",
467            "public_base_url",
468            "gui_enabled",
469        ]
470        .into_iter()
471        .collect();
472        assert_eq!(
473            actual, expected,
474            "set_schema properties must match ConfigSetPayload fields exactly"
475        );
476        assert_eq!(
477            schema.get("additionalProperties"),
478            Some(&json!(false)),
479            "set_schema must keep additionalProperties: false; otherwise the \
480             completeness check above is moot",
481        );
482    }
483
484    #[test]
485    fn config_set_schema_includes_public_base_url() {
486        let schema = set_schema();
487        let props = schema
488            .get("properties")
489            .and_then(|p| p.as_object())
490            .expect("schema has properties");
491        assert!(
492            props.contains_key("public_base_url"),
493            "set_schema must advertise public_base_url; got keys: {:?}",
494            props.keys().collect::<Vec<_>>()
495        );
496    }
497}