Skip to main content

kanade_shared/
config_field.rs

1//! Field/value grammar for `agent_config` scope edits.
2//!
3//! The CLI validates up front and the backend validates again at its
4//! write boundary; both call the functions here so the two can never
5//! disagree about which `<field>=<value>` specs are acceptable or what
6//! the error says.
7
8use anyhow::{Context, Result, bail};
9use serde::{Deserialize, Serialize};
10
11use crate::wire::ConfigScope;
12
13/// Request body of the backend's single-field set route
14/// (`PUT …/config/fields/{field}`). Unset has no body — it is the
15/// `DELETE` of the same route.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct FieldValue {
18    pub value: String,
19}
20
21/// Response of the backend's single-field set / unset routes: the scope
22/// as it stands afterwards, and whether the call actually wrote. An
23/// already-satisfied request answers `changed: false` without touching
24/// the KV row, so the revision does not move and no watcher wakes.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26pub struct FieldUpdate {
27    pub scope: ConfigScope,
28    pub changed: bool,
29}
30
31/// Split a `<field>=<value>` spec (the argument of `kanade config set`).
32/// Only the first `=` separates, so values may contain `=`.
33pub fn parse_set_spec(spec: &str) -> Result<(&str, &str)> {
34    spec.split_once('=')
35        .ok_or_else(|| anyhow::anyhow!("expected <field>=<value>, got '{spec}'"))
36}
37
38/// Apply `value` (or `None` for unset) to the named field on
39/// `scope`. An open-coded match rather than a generic helper because
40/// the field names + types are stable enough that it is the most
41/// readable form.
42pub fn apply_field(scope: &mut ConfigScope, field: &str, value: Option<&str>) -> Result<()> {
43    // #491: duration fields are humantime-validated BEFORE the KV
44    // put. The agent maps an unparseable value to a silent fallback
45    // (jitter especially used to fall back to ZERO — turning a
46    // `30minutes`-style typo into a fleet-wide simultaneous-download
47    // herd on the next rollout), so the only safe place to catch the
48    // typo is the write boundary, where the operator gets an error.
49    let parsed_duration = |field: &str, v: Option<&str>| -> Result<Option<String>> {
50        match v {
51            None => Ok(None),
52            Some(v) => {
53                humantime::parse_duration(v).with_context(|| {
54                    format!("{field}: expected a humantime duration (e.g. 30s, 10m, 1h), got {v:?}")
55                })?;
56                Ok(Some(v.to_string()))
57            }
58        }
59    };
60    match field {
61        "max_local_concurrent" => {
62            scope.max_local_concurrent = value
63                .map(str::parse::<std::num::NonZeroU32>)
64                .transpose()
65                .context("max_local_concurrent: expected an integer >= 1")?;
66        }
67        "target_version" => scope.target_version = value.map(String::from),
68        "target_version_jitter" => {
69            scope.target_version_jitter = parsed_duration(field, value)?;
70        }
71        "heartbeat_interval" => scope.heartbeat_interval = parsed_duration(field, value)?,
72        "host_perf_interval" => scope.host_perf_interval = parsed_duration(field, value)?,
73        "process_perf_enabled" => {
74            scope.process_perf_enabled = match value {
75                None => None,
76                Some(v) => Some(v.parse::<bool>().with_context(|| {
77                    format!("process_perf_enabled: expected true|false, got {v:?}")
78                })?),
79            };
80        }
81        "process_perf_expires_at" => {
82            scope.process_perf_expires_at = match value {
83                None => None,
84                Some(v) => Some(
85                    chrono::DateTime::parse_from_rfc3339(v)
86                        .with_context(|| {
87                            format!(
88                                "process_perf_expires_at: expected RFC3339 timestamp, got {v:?}"
89                            )
90                        })?
91                        .with_timezone(&chrono::Utc),
92                ),
93            };
94        }
95        "process_perf_top_n" => {
96            scope.process_perf_top_n = match value {
97                None => None,
98                Some(v) => Some(v.parse::<u32>().with_context(|| {
99                    format!("process_perf_top_n: expected positive integer, got {v:?}")
100                })?),
101            };
102        }
103        // Free-form product name (e.g. "端末管理支援ツール") — no
104        // format validation; any non-empty string is a valid brand.
105        // The agent/client trim + treat blank as "unset" downstream.
106        "client_display_name" => scope.client_display_name = value.map(String::from),
107        other => bail!(
108            "unknown field '{other}' — supported: max_local_concurrent, target_version, target_version_jitter, heartbeat_interval, host_perf_interval, process_perf_enabled, process_perf_expires_at, process_perf_top_n, client_display_name"
109        ),
110    }
111    Ok(())
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn local_limit_sets_clears_and_rejects_invalid_values() {
120        let mut s = ConfigScope::default();
121        apply_field(&mut s, "max_local_concurrent", Some("2")).unwrap();
122        assert_eq!(s.max_local_concurrent.unwrap().get(), 2);
123        for value in ["0", "-1", "1.5", "4294967296"] {
124            assert!(apply_field(&mut s, "max_local_concurrent", Some(value)).is_err());
125        }
126        apply_field(&mut s, "max_local_concurrent", None).unwrap();
127        assert!(s.max_local_concurrent.is_none());
128    }
129    #[test]
130    fn apply_field_sets_string() {
131        let mut s = ConfigScope::default();
132        apply_field(&mut s, "heartbeat_interval", Some("15s")).unwrap();
133        assert_eq!(s.heartbeat_interval.as_deref(), Some("15s"));
134    }
135
136    #[test]
137    fn apply_field_unset_clears_string() {
138        let mut s = ConfigScope {
139            heartbeat_interval: Some("15s".into()),
140            ..Default::default()
141        };
142        apply_field(&mut s, "heartbeat_interval", None).unwrap();
143        assert!(s.heartbeat_interval.is_none());
144    }
145
146    #[test]
147    fn apply_field_sets_and_clears_client_display_name() {
148        let mut s = ConfigScope::default();
149        apply_field(&mut s, "client_display_name", Some("端末管理支援ツール")).unwrap();
150        assert_eq!(s.client_display_name.as_deref(), Some("端末管理支援ツール"));
151        apply_field(&mut s, "client_display_name", None).unwrap();
152        assert!(s.client_display_name.is_none());
153    }
154
155    #[test]
156    fn apply_field_rejects_unknown() {
157        let mut s = ConfigScope::default();
158        let err = apply_field(&mut s, "nope", Some("x")).unwrap_err();
159        assert!(err.to_string().contains("unknown field"));
160    }
161
162    #[test]
163    fn apply_field_rejects_malformed_durations() {
164        // #491: a typo'd duration must be rejected at the write
165        // boundary, never stored (the agent's parse failure falls
166        // back silently — jitter especially used to fall back to
167        // ZERO, defeating the rollout stagger fleet-wide).
168        let mut s = ConfigScope::default();
169        for field in [
170            "target_version_jitter",
171            "heartbeat_interval",
172            "host_perf_interval",
173        ] {
174            let err = apply_field(&mut s, field, Some("not-a-duration")).unwrap_err();
175            assert!(err.to_string().contains("humantime"), "{field}: {err:#}",);
176        }
177        // Unset still works for validated fields.
178        apply_field(&mut s, "target_version_jitter", None).unwrap();
179        assert!(s.target_version_jitter.is_none());
180    }
181
182    #[test]
183    fn parse_set_spec_splits_on_first_equals() {
184        assert_eq!(
185            parse_set_spec("heartbeat_interval=15s").unwrap(),
186            ("heartbeat_interval", "15s")
187        );
188        assert_eq!(parse_set_spec("a=b=c").unwrap(), ("a", "b=c"));
189        assert_eq!(
190            parse_set_spec("client_display_name=").unwrap(),
191            ("client_display_name", "")
192        );
193    }
194
195    #[test]
196    fn parse_set_spec_rejects_missing_equals() {
197        let err = parse_set_spec("heartbeat_interval").unwrap_err();
198        assert_eq!(
199            err.to_string(),
200            "expected <field>=<value>, got 'heartbeat_interval'"
201        );
202    }
203}