1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub slot: Option<CapabilitySlot>,
28}
29
30pub 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 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub listen_addr: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub public_base_url: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub gui_enabled: Option<bool>,
106}
107
108pub 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 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
191fn 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 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 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 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 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 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 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}