Skip to main content

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}