shep_core/protocol/request/config_reply.rs
1//! What a config request answers with: drift, what applied, what was refused, and the view.
2
3use core::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::AppConfig;
8
9// Named by intra-doc links and by nothing rustc compiles, so the
10// import is behind `cfg(doc)` rather than flagged unused.
11#[cfg(doc)]
12use super::{Request, Response};
13
14/// One registered sheep whose stored config differs from a caller's copy:
15/// the answer [`Request::ConfigDrift`] is asking for
16///
17/// Field names only, never their values. This is printed at an operator,
18/// and [`AppConfig::env`](crate::config::AppConfig::env) carries secrets,
19/// so a differing `env` reports `"env"` and nothing more. `Debug` is
20/// derived: there is nothing here to redact.
21// wire format: changing field names is a breaking change
22#[non_exhaustive]
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct SheepDrift {
25 /// The sheep's name. Both configs share it by construction: it is what
26 /// matched them to each other.
27 pub name: String,
28 /// The [`AppConfig`] fields that differ, in field-name order. Never
29 /// empty: a sheep with nothing to report is left out of the answer.
30 pub fields: Vec<String>,
31}
32
33impl SheepDrift {
34 /// Builds one sheep's report.
35 #[must_use]
36 pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
37 Self {
38 name: name.into(),
39 fields,
40 }
41 }
42}
43
44/// What one app's [`Request::ApplyConfig`] did: the answer a load owes the
45/// operator who ran it
46///
47/// One of these per app the request named, found or not and changed or not.
48///
49/// [`Self::applied`] and [`Self::pending`] carry field names only, never
50/// their values, as [`SheepDrift`] does; the merged config never reaches a
51/// client. [`Self::refused`] is prose and is scoped out of that rule: it
52/// quotes values out of the file the caller just sent, never out of the
53/// flock's stored config. `Debug` is derived on that basis: nothing here
54/// needs redacting.
55// wire format: changing field names is a breaking change
56#[non_exhaustive]
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct SheepApplied {
59 /// The sheep's name, exactly as the request spelled it.
60 pub name: String,
61 /// Fields now in force, in field-name order. Empty when the load changed
62 /// nothing the daemon could act on immediately.
63 pub applied: Vec<String>,
64 /// Fields the app picks up at its next spawn, in field-name order. Empty
65 /// when nothing is waiting.
66 ///
67 /// `shep reload <name>` promotes them; a client rendering this list says
68 /// so, since a pending list with no remedy beside it cannot be acted on.
69 pub pending: Vec<String>,
70 /// Why some or all of this app's change did not land, in the daemon's own
71 /// words, or `None` when the whole of it did.
72 ///
73 /// Not the same question as the two lists being empty: a refusal raised
74 /// before anything was touched leaves both empty, and so does a load with
75 /// nothing to do. It is a sentence rather than a code because the message
76 /// is what tells them apart.
77 pub refused: Option<String>,
78}
79
80impl SheepApplied {
81 /// Builds one app's report.
82 #[must_use]
83 pub fn new(
84 name: impl Into<String>,
85 applied: Vec<String>,
86 pending: Vec<String>,
87 refused: Option<String>,
88 ) -> Self {
89 Self {
90 name: name.into(),
91 applied,
92 pending,
93 refused,
94 }
95 }
96}
97
98/// One app a multi-sheep reload or restart could not accept, and why
99///
100/// A staged walk asks the supervisor per app, so an app already reloading is
101/// refused on its own while the rest of the fold goes ahead, and so is one
102/// that left the flock after the walk was planned. One of these per refused
103/// app rides back in [`Response::Reloading`] or [`Response::Restarted`],
104/// which is what lets the client name the app and exit non-zero instead of
105/// printing a table with a row quietly missing from it.
106///
107/// [`Self::reason`] is the daemon's own sentence rather than a code, the
108/// rule [`SheepApplied::refused`] takes and for its reason: the class of
109/// refusal is not on the wire, and the message is what tells two of them
110/// apart. `Debug` is derived; a name and a refusal sentence carry no env,
111/// no path and no argument vector.
112// wire format: changing field names is a breaking change
113#[non_exhaustive]
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct SheepRefusal {
116 /// The app's name, as the walk that planned the reload or the restart
117 /// spelled it.
118 pub name: String,
119 /// Why that app was refused, in the daemon's own words.
120 pub reason: String,
121}
122
123impl SheepRefusal {
124 /// Builds one app's refusal.
125 #[must_use]
126 pub fn new(name: impl Into<String>, reason: impl Into<String>) -> Self {
127 Self {
128 name: name.into(),
129 reason: reason.into(),
130 }
131 }
132}
133
134/// One sheep's effective config as a pane sees it: every field but env's
135/// values, plus which fields an operator has overridden and which are
136/// waiting on a respawn.
137///
138/// The answer to [`Request::SheepConfig`], and the one reply in this module
139/// that carries a whole [`AppConfig`]. [`SheepApplied`] deliberately carries
140/// field names alone, and the difference is what each is for: that one is
141/// printed at an operator who already has the file, this one feeds a pane
142/// that is about to edit fields it has to be able to show first.
143// wire format: changing field names is a breaking change
144//
145// `#[non_exhaustive]`: shep-core is a published library and a sixth field
146// would otherwise break an out-of-tree consumer's construction of this with
147// no version bump to say so (IR-20). [`SheepConfigView::new`] is how the
148// daemon builds one, and it is what enforces the emptied `env`.
149#[non_exhaustive]
150#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct SheepConfigView {
152 /// The sheep's name.
153 pub name: String,
154 /// The effective config with `env` cleared. Every remaining field is
155 /// operator-supplied policy the pane is about to let them edit, so
156 /// withholding a value would make the pane unusable while protecting
157 /// nothing.
158 pub config: AppConfig,
159 /// The env keys, so the pane can list them. Never the values.
160 pub env_keys: Vec<String>,
161 /// Which of [`Self::env_keys`] resolve from the secret store, so a pane
162 /// can mark the row without showing anything. Recorded before `env` is
163 /// cleared, which is the only moment the values exist to be read.
164 pub env_secrets: Vec<String>,
165 /// Field names an operator has set that the Flockfile does not declare.
166 pub overridden: Vec<String>,
167 /// Field names parked until the next respawn.
168 pub pending: Vec<String>,
169}
170
171impl SheepConfigView {
172 /// Builds one, clearing `env` and recording its keys.
173 ///
174 /// The clearing happens here rather than at the one call site, so a
175 /// second caller cannot forget it: this constructor is the only way to
176 /// build the type outside this crate, since `#[non_exhaustive]` blocks
177 /// a literal.
178 #[must_use]
179 pub fn new(mut config: AppConfig, overridden: Vec<String>, pending: Vec<String>) -> Self {
180 let env_secrets = crate::secrets::sealed_keys(&config);
181 let env_keys = config.env.keys().cloned().collect();
182 config.env.clear();
183 Self {
184 name: config.name.clone(),
185 config,
186 env_keys,
187 env_secrets,
188 overridden,
189 pending,
190 }
191 }
192}
193
194/// Redacted (IR-41): `config` carries `args` and `cwd`, which routinely hold
195/// a token or a home directory, and this type is what a `{:?}` on a
196/// [`Response`] would print. The four lists are counted rather than named
197/// for the same reason: `env_keys` and `env_secrets` are key sets, which are
198/// themselves worth keeping out of a log.
199impl fmt::Debug for SheepConfigView {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 write!(
202 f,
203 "SheepConfigView {{ name: {:?}, env_keys: {}, env_secrets: {}, overridden: {}, pending: {} }}",
204 self.name,
205 self.env_keys.len(),
206 self.env_secrets.len(),
207 self.overridden.len(),
208 self.pending.len()
209 )
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 /// The pane edits everything else about a sheep, so the config itself
218 /// has to travel; `env` is the one map in it that holds secrets, and
219 /// the keys travel while the values never do (IR-41).
220 #[test]
221 fn a_sheep_config_view_never_carries_an_env_value() {
222 let mut config = AppConfig::minimal("web", "./srv");
223 config
224 .env
225 .insert("DB_PASS".to_string(), "hunter2".to_string());
226 let view = SheepConfigView::new(config, Vec::new(), Vec::new());
227 assert!(view.config.env.is_empty());
228 assert_eq!(view.env_keys, ["DB_PASS"]);
229 let json = serde_json::to_string(&view).unwrap();
230 assert!(!json.contains("hunter2"), "{json}");
231 }
232
233 /// A `{:?}` on a `Response` reaches it, and `config` holds `args` and
234 /// `cwd` as well as the env keys (IR-41).
235 #[test]
236 fn a_sheep_config_views_debug_is_the_exact_redacted_string() {
237 let mut config = AppConfig::minimal("web", "./srv");
238 config.env.insert("A".to_string(), "1".to_string());
239 let view = SheepConfigView::new(config, vec!["max_restarts".to_string()], Vec::new());
240 assert_eq!(
241 format!("{view:?}"),
242 r#"SheepConfigView { name: "web", env_keys: 1, env_secrets: 0, overridden: 1, pending: 0 }"#
243 );
244 }
245
246 /// Recorded before the clear, since the values are what name a reference
247 /// and they are gone by the time anything else can look.
248 #[test]
249 fn a_config_view_records_which_env_keys_are_sealed() {
250 let mut config = AppConfig::minimal("web", "./srv");
251 config.env.insert("PLAIN".into(), "value".into());
252 config.env.insert("SEALED".into(), "{{secret:PW}}".into());
253 let view = SheepConfigView::new(config, Vec::new(), Vec::new());
254 assert!(view.config.env.is_empty());
255 assert_eq!(view.env_keys, ["PLAIN", "SEALED"]);
256 assert_eq!(view.env_secrets, ["SEALED"]);
257 }
258
259 /// Asserts on the JSON, not the struct: a `Vec<String>` cannot say which
260 /// of the two a string is, so a build carrying a value would typecheck.
261 #[test]
262 fn a_sheep_applied_carries_names_and_never_values() {
263 let applied = SheepApplied::new(
264 "web",
265 vec!["cwd".to_string()],
266 vec!["env".to_string()],
267 None,
268 );
269 let json = serde_json::to_string(&applied).unwrap();
270 assert!(json.contains("\"env\""), "the NAME travels: {json}");
271 assert!(
272 !json.contains("DATABASE_URL"),
273 "and no value ever does: {json}"
274 );
275 }
276
277 #[test]
278 fn a_sheep_applied_debug_prints_the_names_it_was_given() {
279 let applied = SheepApplied::new("web", vec!["cwd".to_string()], Vec::new(), None);
280 assert_eq!(
281 format!("{applied:?}"),
282 "SheepApplied { name: \"web\", applied: [\"cwd\"], pending: [], refused: None }"
283 );
284 }
285}