bhtune_runtime/config/model.rs
1#![allow(rustdoc::broken_intra_doc_links)]
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6use super::demo::{DemoPolicyConfig, ServerMode};
7use super::log::LogConfig;
8use super::tuning::TuningConfig;
9
10/// Default opcda-bridge gateway address bhtune connects to when nothing else specifies one.
11pub const DEFAULT_BRIDGE_HOST: &str = "localhost:7600";
12
13/// Default address `bhtune-server` binds to when nothing else specifies one -- loopback
14/// only, so the server is not exposed to the network unless explicitly configured. Lives
15/// alongside [`DEFAULT_BRIDGE_HOST`] in this shared config module (rather than in
16/// `bhtune-server` itself) even though only the server binary ever calls
17/// [`resolve_bind_addr`], the same way `templates`/`log` below are settings only some
18/// commands consume -- one `bhtune.toml` file and one precedence chain for every bhtune
19/// setting, CLI or server.
20pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8787";
21
22/// bhtune's configuration, loaded from an optional TOML file. Every field is optional; a
23/// value missing from the file (or the file itself missing) falls back to the env var / CLI
24/// flag / built-in default resolution in the `resolve_*` functions below.
25#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27pub struct BhtuneConfig {
28 /// Runtime server mode. The environment variable `BHTUNE_SERVER_MODE` overrides this.
29 #[serde(default)]
30 pub server_mode: Option<ServerMode>,
31 /// Safety limits for the simulator-only public demo mode.
32 #[serde(default)]
33 pub demo: DemoPolicyConfig,
34 /// Overrides the default SQLite database path (see [`default_db_path_from`]).
35 pub db: Option<PathBuf>,
36 /// Overrides [`DEFAULT_BRIDGE_HOST`] for every `tune --driver opcda` and `opc`
37 /// subcommand invocation that doesn't pass `--bridge-host` explicitly.
38 pub bridge_host: Option<String>,
39 /// Default OPC DA server ProgID, used when `--server` is omitted. Unlike the other
40 /// fields there is no built-in default -- if this is unset and `--server` is omitted,
41 /// the command errors (see [`resolve_server`]).
42 pub server: Option<String>,
43 /// Overrides the default user-supplied DCS/PLC template catalog path (see
44 /// [`templates_path_from`]). A file here is loaded on every startup in addition to the
45 /// embedded built-in catalog (see `crate::db::open` and [`load_user_templates`]),
46 /// attributed `TemplateOrigin::Catalog`.
47 pub templates: Option<PathBuf>,
48 /// Overrides [`DEFAULT_BIND_ADDR`] -- the `host:port` `bhtune-server` listens on. Only
49 /// meaningful to the server binary; see [`resolve_bind_addr`].
50 pub bind: Option<String>,
51 /// Optional exact browser origin for state-changing HTTP requests. `BHTUNE_ORIGIN`
52 /// overrides this value. Full mode automatically matches the browser origin to the
53 /// request host when this is unset; set it for a reverse proxy that rewrites `Host` or
54 /// to pin one public origin. Demo mode always requires an exact configured origin and
55 /// requires HTTPS, except for explicit loopback HTTP origins used by local tests and
56 /// development.
57 #[serde(default)]
58 pub origin: Option<String>,
59 /// IP address or matching-family CIDR of a reverse proxy trusted to supply the
60 /// single-address `X-BHTune-Client-IP` header for Demo quota accounting.
61 #[serde(default)]
62 pub trusted_proxy: Option<String>,
63 /// Age-based history retention (`history-retention`): tune runs with `started_at` older
64 /// than this many days are deleted automatically on every startup (both binaries, via
65 /// `crate::db::open`) and, for `bhtune-server`, again on a periodic timer while it keeps
66 /// running -- see `crate::retention`. A present value must be at least 1. `None` (the
67 /// default) means retain forever: there is no built-in number of days, since at this
68 /// project's data volumes (see AGENTS.md's History explorer notes) an unexpected
69 /// auto-delete of someone's baseline tune is a worse failure mode than an ever-growing
70 /// database file. See [`resolve_retention_days`].
71 #[serde(default, deserialize_with = "deserialize_retention_days")]
72 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
73 pub retention_days: Option<u32>,
74 /// Default OPC sample-quality policy for the server config page: `true` accepts
75 /// `Uncertain` quality, while `false` rejects it. A missing key is treated as `true`
76 /// when the config file is parsed, matching the configuration-page default rather than
77 /// `bool`'s ordinary `false`.
78 #[serde(default = "default_allow_uncertain_quality")]
79 pub allow_uncertain_quality: bool,
80 /// Global tune timing defaults. Missing keys remain `None` and resolve through
81 /// [`resolve_tuning_config`] only when a tune is prepared.
82 #[serde(default)]
83 pub tuning: TuningConfig,
84 /// `[log]` sub-table: level/directory/format/rotation for `crate::logging`'s tracing
85 /// setup, mirroring `opcda-bridge-gateway`'s own `log.*` config conventions.
86 #[serde(default)]
87 pub log: LogConfig,
88}
89
90/// The default configuration-page quality policy: absent `allow_uncertain_quality` resolves
91/// to `true` rather than `bool`'s usual `false`.
92pub const fn default_allow_uncertain_quality() -> bool {
93 true
94}
95
96fn deserialize_retention_days<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
97where
98 D: serde::Deserializer<'de>,
99{
100 let days = Option::<u32>::deserialize(deserializer)?;
101 match days {
102 Some(0) => Err(serde::de::Error::custom(
103 "retention_days must be at least 1 or omitted",
104 )),
105 other => Ok(other),
106 }
107}
108
109impl Default for BhtuneConfig {
110 fn default() -> Self {
111 Self {
112 server_mode: None,
113 demo: DemoPolicyConfig::default(),
114 db: None,
115 bridge_host: None,
116 server: None,
117 templates: None,
118 bind: None,
119 origin: None,
120 trusted_proxy: None,
121 retention_days: None,
122 allow_uncertain_quality: default_allow_uncertain_quality(),
123 tuning: TuningConfig::default(),
124 log: LogConfig::default(),
125 }
126 }
127}
128
129/// Parses the in-memory TOML representation of a config file.
130///
131/// Keeping the parser separate from filesystem discovery gives property tests and fuzz
132/// targets a narrow, side-effect-free boundary to exercise.
133pub fn parse_config_contents(contents: &str) -> anyhow::Result<BhtuneConfig> {
134 toml::from_str(contents).map_err(Into::into)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::super::browser::load_config_file;
140 use super::super::tuning::MAX_TUNING_MRFT_DELAY_SECS;
141 use super::*;
142 use proptest::prelude::*;
143 use std::io::Write;
144 proptest::proptest! {
145 #[test]
146 fn serialized_configs_round_trip(
147 db in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
148 bridge_host in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
149 server in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
150 templates in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
151 bind in prop::option::of("[A-Za-z0-9_.:-]{0,32}"),
152 retention_days in prop::option::of(1u32..),
153 allow_uncertain_quality in any::<bool>(),
154 mrft_delay_secs in prop::option::of(0u32..=MAX_TUNING_MRFT_DELAY_SECS),
155 poll_interval_ms in prop::option::of(1u64..=1_000_000),
156 timeout_secs in prop::option::of(1u64..=1_000_000),
157 op_timeout_secs in prop::option::of(1u64..=1_000_000),
158 restore_timeout_secs in prop::option::of(1u64..=1_000_000),
159 level in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
160 dir in prop::option::of("[A-Za-z0-9_./:-]{0,32}"),
161 format in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
162 rotation in prop::option::of("[A-Za-z0-9_.:-]{0,16}"),
163 ) {
164 let config = BhtuneConfig {
165 server_mode: None,
166 demo: DemoPolicyConfig::default(),
167 db: db.map(PathBuf::from),
168 bridge_host,
169 server,
170 templates: templates.map(PathBuf::from),
171 bind,
172 origin: None,
173 trusted_proxy: None,
174 retention_days,
175 allow_uncertain_quality,
176 tuning: TuningConfig {
177 mrft_delay_secs,
178 poll_interval_ms,
179 timeout_secs,
180 op_timeout_secs,
181 restore_timeout_secs,
182 },
183 log: LogConfig {
184 level,
185 dir,
186 format,
187 rotation,
188 },
189 };
190 let encoded = toml::to_string(&config).unwrap();
191 prop_assert_eq!(parse_config_contents(&encoded).unwrap(), config);
192 }
193
194 #[test]
195 fn arbitrary_config_text_never_panics(input in any::<String>()) {
196 let _ = parse_config_contents(&input);
197 }
198 }
199
200 #[test]
201 fn parse_config_rejects_zero_retention_days() {
202 let error = parse_config_contents("retention_days = 0").unwrap_err();
203 assert!(
204 error
205 .to_string()
206 .contains("retention_days must be at least 1")
207 );
208 }
209
210 #[test]
211 fn default_allow_uncertain_quality_is_true() {
212 assert!(BhtuneConfig::default().allow_uncertain_quality);
213 }
214
215 #[test]
216 fn load_config_file_missing_allow_uncertain_quality_key_defaults_to_true() {
217 let mut file = tempfile::NamedTempFile::new().unwrap();
218 writeln!(file, "bridge_host = \"gateway:7600\"").unwrap();
219 let config = load_config_file(file.path(), true).unwrap();
220 assert!(config.allow_uncertain_quality);
221 assert_eq!(config.bridge_host, Some("gateway:7600".to_string()));
222 }
223}