Skip to main content

shep_core/protocol/request/
verbs.rs

1//! [`Request`], one variant per verb, and the selector a verb names a sheep with.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::{AppConfig, DeclaredApp, ResetDepth};
8
9use super::{DogSectionToml, DogSource, EnvValue, Smit};
10
11// Named by intra-doc links and by nothing rustc compiles, so the
12// import is behind `cfg(doc)` rather than flagged unused.
13#[cfg(doc)]
14use super::{Response, RpcErrorCode, SheepApplied, SheepDrift};
15
16/// Serializable selector (mirror of [`crate::selector::ProcessSelector`];
17/// regex travels as its source string)
18// wire format: changing this is a breaking change
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
21pub enum SelectorSpec {
22    /// Every sheep
23    All,
24    /// By id
25    Id(u32),
26    /// By exact name
27    Name(String),
28    /// By regex source
29    Regex(String),
30    /// By fold name
31    Fold(String),
32    // Both field names are wire contract, pinned by `request_wire_v9`.
33    /// By app name and instance slot
34    ///
35    /// On the wire: `{"kind":"instance","value":{"name":"web","slot":2}}`.
36    Instance {
37        /// The app name
38        name: String,
39        /// The instance slot, counting from 0
40        slot: u32,
41    },
42}
43
44/// One RPC request
45// wire format: changing existing variants is a breaking change
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48#[non_exhaustive]
49pub enum Request {
50    /// Liveness check
51    Ping,
52    /// Full flock listing
53    ListFlock,
54    /// What the machine the flock runs on is doing
55    ///
56    /// Answers [`Response::HostUsage`]. Its own request rather than a field
57    /// on [`Response::Flock`]: that variant serializes as an array, and
58    /// giving it a second field would retype it into an object that no
59    /// shipped client can read. A daemon that has never heard of this one
60    /// decodes [`Self::Unrecognized`] and refuses by name, which costs the
61    /// caller the host block and nothing else.
62    HostUsage,
63    /// Detailed info for matching sheep
64    Describe {
65        /// Which sheep
66        selector: SelectorSpec,
67    },
68    /// Register + start apps
69    Start {
70        /// App configs. The daemon must re-normalize them, since peer input
71        /// is untrusted; failures return [`RpcErrorCode::InvalidConfig`]
72        apps: Vec<AppConfig>,
73    },
74    /// Register apps as flock members without starting any of them
75    ///
76    /// Each app lands `Stopped` and holds no pid; `shep add` is the verb.
77    ///
78    /// Idempotent by name: an app the flock already has is answered as it
79    /// stands, running or not, and nothing about it changes.
80    /// [`Self::ApplyConfig`] merges a template into one the flock already
81    /// has, and `shep add` sends both.
82    ///
83    /// Answers [`Response::Added`].
84    Add {
85        /// App configs, carried exactly as [`Self::Start`] carries them. The
86        /// daemon must re-normalize them, since peer input is untrusted;
87        /// failures return [`RpcErrorCode::InvalidConfig`]
88        apps: Vec<AppConfig>,
89    },
90    /// Ask which of `apps` name a sheep the flock already has under a
91    /// different config
92    ///
93    /// Read-only. [`Self::Start`] on an already-registered name adds
94    /// instances rather than reconciling config.
95    ///
96    /// Answers [`Response::Drifted`] with one [`SheepDrift`] per app that is
97    /// both registered and different. An app the flock does not have is
98    /// absent from the answer, not reported as unchanged.
99    ConfigDrift {
100        /// The configs to compare against, exactly as [`Self::Start`] would
101        /// carry them. The daemon must re-normalize them: peer input is
102        /// untrusted, and an unnormalized config would report every default
103        /// it has not spelled out as a difference. Failures return
104        /// [`RpcErrorCode::InvalidConfig`].
105        apps: Vec<AppConfig>,
106    },
107    /// Merge each declared app into the sheep of the same name, applying
108    /// what can be applied and parking the rest for that sheep's next spawn
109    ///
110    /// Nothing is registered, nothing is pruned and nothing running is
111    /// killed: an app the flock does not have is refused by name, and a
112    /// field the running child was spawned from waits for a `shep reload`.
113    /// Additive by default; `reset` widens it.
114    ///
115    /// Answers [`Response::Applied`] with one [`SheepApplied`] per entry in
116    /// `apps`, in the order given, found or not and changed or not. One
117    /// app's refusal rides in [`SheepApplied::refused`] and does not cost
118    /// the rest of the file its load.
119    ApplyConfig {
120        /// The apps to merge in, each carrying the keys its document
121        /// literally wrote. The daemon must re-normalize the merge result,
122        /// since peer input is untrusted, and refuses the whole request with
123        /// [`RpcErrorCode::InvalidConfig`] when two entries share a name:
124        /// the second would be merged against a store the first has not
125        /// written yet.
126        apps: Vec<DeclaredApp>,
127        /// How much of what the operator has set since a template last
128        /// loaded this request may overwrite. Default [`ResetDepth::None`],
129        /// which overwrites nothing.
130        ///
131        /// Spelled `none`/`file`/`env`/`policy` on the wire.
132        reset: ResetDepth,
133    },
134    /// One sheep's effective config, for a pane that is about to edit it.
135    ///
136    /// `env` comes back emptied and its key names ride separately, so a
137    /// value never crosses the wire. Read-only: nothing about the sheep
138    /// changes.
139    ///
140    /// Answers [`Response::SheepConfig`], or
141    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
142    SheepConfig {
143        /// The sheep's name, not a selector: a pane edits one sheep, for
144        /// the reason [`Self::Scale`] states at length.
145        name: String,
146    },
147    /// Sets, replaces, or with `None` removes one env key on one sheep,
148    /// recorded as an operator override. Never reads it back.
149    ///
150    /// Its own request rather than a [`Self::ApplyConfig`] depth, because
151    /// no depth does this: `ResetDepth::None` appends only, `File` and
152    /// `Policy` leave env alone, and `Env`/`All` replace the whole map with
153    /// the template's. A pane cannot send the whole map, since it is never
154    /// told the values it would have to send back.
155    ///
156    /// The running child holds the env it was spawned from, so the change
157    /// parks for the next spawn exactly as `ApplyConfig` parks a
158    /// respawn-only field, and `shep reload`/`shep restart` promote it.
159    ///
160    /// Answers [`Response::SheepEnvSet`], or
161    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
162    SetSheepEnv {
163        /// The sheep's name, not a selector, for [`Self::SheepConfig`]'s
164        /// reason.
165        name: String,
166        /// The env key.
167        key: String,
168        /// The value, or `None` to remove the key.
169        ///
170        /// [`EnvValue`], not a bare `String`, for the reason that type's
171        /// own doc gives: this is the most secret-dense field on the wire
172        /// and a derived `Debug` on [`Request`] would print it (IR-41).
173        value: Option<EnvValue>,
174    },
175    /// Sets several env keys on one sheep in a single write.
176    ///
177    /// [`Self::SetSheepEnv`]'s doc says a request taking a map would need
178    /// per-key reporting back "for no caller that wants it". `shep import
179    /// env` is that caller: it writes twenty keys at once and has to refuse
180    /// the whole set rather than leave eleven of them applied.
181    ///
182    /// The daemon applies this as one read-modify-write of the override
183    /// store, so every key lands or none does. No removal arm: a pane
184    /// deletes rows one at a time through [`Self::SetSheepEnv`], and an
185    /// import never removes anything.
186    ///
187    /// A key already holding a different value is a collision. Without
188    /// `force` any collision refuses the whole request and writes nothing;
189    /// with it, the collisions are overwritten and named in the reply as
190    /// well as counted in `set`. A key already holding the same value is
191    /// `unchanged`, never a collision, so re-running an unchanged import
192    /// needs no flag.
193    ///
194    /// `dry_run` computes the three lists and writes nothing.
195    ///
196    /// Parks for the next spawn, exactly as [`Self::SetSheepEnv`] does.
197    ///
198    /// Answers [`Response::SheepEnvBatch`], or
199    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
200    SetSheepEnvBatch {
201        /// The sheep's name.
202        name: String,
203        /// The keys and their values.
204        ///
205        /// [`EnvValue`], not `String`, for [`Self::SetSheepEnv`]'s reason:
206        /// `Request` derives `Debug` and this map is the densest run of
207        /// secrets on the wire (IR-41).
208        entries: BTreeMap<String, EnvValue>,
209        /// Overwrite colliding keys instead of refusing.
210        force: bool,
211        /// Report what would happen and write nothing.
212        dry_run: bool,
213    },
214    /// Sets one config field on one sheep, recorded as an operator
215    /// override.
216    ///
217    /// [`Self::SetSheepEnv`]'s twin for everything that is not `env`, and
218    /// it exists for the reason that one does rather than by symmetry.
219    /// [`Self::ApplyConfig`] can move a single field (one [`DeclaredApp`]
220    /// declaring one key, at [`ResetDepth::File`]), but it moves it as a
221    /// template and spends the operator's override for it. That reasoning
222    /// does not hold here: a pane's value is the operator's, and the sheep
223    /// still differs from its file. Routed through `ApplyConfig`, the `*`
224    /// marker would never appear for that edit.
225    ///
226    /// One field, not a map: a pane edits one row at a time, and a request
227    /// that took several would need [`Response::Applied`]'s per-field
228    /// reporting back again for no caller that wants it.
229    ///
230    /// `env` is refused here and goes through [`Self::SetSheepEnv`]. So are
231    /// `name` and `instances`, which are
232    /// [`ApplyGroup::Structural`](crate::config::ApplyGroup::Structural):
233    /// identity and flock shape rather than runtime knobs, and the count
234    /// moves through [`Self::Scale`].
235    ///
236    /// The four-way apply classification governs exactly as it does for a
237    /// load. A `Live` field is in force at the daemon's next decision, a
238    /// `NextSpawn` field reaches the stored spec, and a `NeedsRespawn`
239    /// field parks for `shep reload` to promote.
240    ///
241    /// Answers [`Response::SheepFieldSet`], or
242    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
243    SetSheepField {
244        /// The sheep's name, not a selector, for [`Self::SheepConfig`]'s
245        /// reason.
246        name: String,
247        /// The [`AppConfig`] field to set. A key that type has no such
248        /// field is refused with [`RpcErrorCode::InvalidConfig`] rather
249        /// than ignored.
250        key: String,
251        /// The new value, in the shape that field serializes as. The daemon
252        /// must re-validate the resulting config (peer input is untrusted)
253        /// and refuses with [`RpcErrorCode::InvalidConfig`] when it does not
254        /// deserialize or does not normalize; nothing is written in either
255        /// case.
256        ///
257        /// A bare [`serde_json::Value`] and not a redacting newtype, unlike
258        /// [`Self::SetSheepEnv`]'s [`EnvValue`], and the asymmetry is
259        /// deliberate. `env` is the one field [`AppConfig`]'s own manual
260        /// `Debug` redacts; `cwd`, `script` and `args` are printed in the
261        /// clear by every request that already carries a whole config
262        /// ([`Self::Start`], [`Self::Add`], [`Self::ApplyConfig`]). A
263        /// newtype here would protect one copy of a value this enum prints
264        /// three other ways, which reads as a guarantee the wire does not
265        /// make. Widening that protection is a change to [`AppConfig`]'s
266        /// `Debug`, not to this field.
267        value: serde_json::Value,
268    },
269    /// Replaces one dog's `[<name>]` section in `dogs.toml` and publishes
270    /// `config.dog.<name>` so a running dog re-reads it.
271    ///
272    /// The writing twin of [`Self::DogConfig`], which reads the same
273    /// section.
274    ///
275    /// Answers [`Response::DogConfigSet`].
276    SetDogConfig {
277        /// The dog's name, the config key.
278        name: String,
279        /// The whole section, as TOML text.
280        ///
281        /// [`DogSectionToml`], not a bare `String`, for the reason that
282        /// type's own doc gives: a section can hold a dog's credentials and
283        /// this is what keeps them out of a `{:?}` (IR-41).
284        toml: DogSectionToml,
285    },
286    /// A provider dog's values for one namespace and one environment.
287    ///
288    /// Replaces that pair rather than merging into it, so a key deleted at
289    /// the provider disappears here on the next push instead of lingering.
290    ///
291    /// `namespace` is the dog's own registered name. It is bookkeeping, not
292    /// authorization: `Hello::dog_name` is self-declared and nothing checks
293    /// it against the spawn. The boundary is the socket itself, which lives
294    /// under `$SHEP_HOME` at `0700`.
295    ///
296    /// The two names and every entry key are checked against
297    /// [`crate::secrets::is_name`], and a value against
298    /// [`crate::secrets::MAX_VALUE_BYTES`], the same cap the operator's own
299    /// store enforces. One offender refuses the whole push rather than
300    /// dropping its own entry, so a dog never reads `accepted` for a set
301    /// that was stored in part.
302    ///
303    /// Answers [`Response::SecretsPut`].
304    PutSecrets {
305        /// The dog's registered name.
306        namespace: String,
307        /// Which environment these values are for.
308        environment: String,
309        /// The values, keyed by secret name. [`EnvValue`] so a `{:?}` of
310        /// this request cannot print them.
311        entries: BTreeMap<String, EnvValue>,
312    },
313    /// Stop matching sheep (stay registered)
314    Stop {
315        /// Which sheep
316        selector: SelectorSpec,
317    },
318    /// Restart matching sheep
319    Restart {
320        /// Which sheep
321        selector: SelectorSpec,
322    },
323    /// Replace each matching sheep with a fresh instance of the same app, one
324    /// instance of an app at a time, so the app has a window in which it can
325    /// stay reachable across the swap
326    Reload {
327        /// Which sheep. No default: a reload replaces running processes.
328        selector: SelectorSpec,
329    },
330    /// Stop + deregister matching sheep
331    Delete {
332        /// Which sheep
333        selector: SelectorSpec,
334    },
335    /// Set how many instances one app runs (see `shep stock`).
336    ///
337    /// Takes a name where every other verb takes a [`SelectorSpec`]:
338    /// `instances` is a per-app number and slots are allocated against the
339    /// same-name group, so a selector matching two apps could mean four of
340    /// each or four in total.
341    ///
342    /// The count is absolute: two operators sending `+2` against the same
343    /// app would get a number neither asked for.
344    Scale {
345        /// The app's name, exactly as its config spells it. Not a selector: no
346        /// `all`, no regex, no `fold:`.
347        name: String,
348        /// How many instances the app has when this returns. `0` is refused
349        /// with [`RpcErrorCode::InvalidConfig`]: `shep delete` is the verb
350        /// for removing an app.
351        count: u32,
352    },
353    /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
354    /// it with `None`.
355    ///
356    /// By name, not a selector: a smit belongs to a sheep, and every
357    /// instance of that name shows it, one spawned after the paint included.
358    ///
359    /// Held in memory and scoped to the connection that sent it, so a
360    /// publisher republishes rather than publishing on change.
361    SetSmit {
362        /// Which sheep.
363        sheep: String,
364        /// The marker, or `None` to clear it.
365        smit: Option<Smit>,
366    },
367    /// Reopen every matched sheep's log files, for an external rotator that
368    /// has renamed them (`create`-mode rotation)
369    Reopen {
370        /// Which sheep
371        selector: SelectorSpec,
372    },
373    /// Empty every matched sheep's log files: flush what is still pending,
374    /// then truncate the recorded paths
375    Flush {
376        /// Which sheep. No default: this destroys log data.
377        selector: SelectorSpec,
378    },
379    /// Send a named action to every matched sheep over its shepherd channel
380    /// and report what each app says back (see `shep trigger`).
381    Trigger {
382        /// Which sheep. No default, matching every other verb that reaches
383        /// a running process.
384        selector: SelectorSpec,
385        /// The action name. Free-form: the daemon never declares, parses, or
386        /// validates it, and an app that does not recognize the name is
387        /// expected to say so in its own reply.
388        action: String,
389        /// Argument text, passed through to the app verbatim. One opaque
390        /// string, matching the shepherd channel's own `action` message this
391        /// becomes.
392        params: Option<String>,
393    },
394    /// Deliver one signal to every matched sheep's own process, never its
395    /// process group (see `shep signal`).
396    Signal {
397        /// Which sheep. No default, matching every other verb that reaches
398        /// a running process.
399        selector: SelectorSpec,
400        /// The signal's name, as
401        /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it. The
402        /// `SIG` prefix and the case are both optional; a name outside the
403        /// grammar answers [`RpcErrorCode::InvalidConfig`].
404        signal: String,
405    },
406    /// Write one line to every matched sheep's stdin (see `shep whisper`).
407    SendLine {
408        /// Which sheep. No default, matching every other verb that reaches a
409        /// running process.
410        selector: SelectorSpec,
411        /// The line, without its terminator: the shepherd appends exactly
412        /// one `\n` when it writes.
413        ///
414        /// A line containing an embedded newline is refused
415        /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
416        /// where the operator typed one.
417        line: String,
418    },
419    /// Write the muster roll now, bypassing the snapshot writer's debounce
420    SaveRoll,
421    /// Assemble the flock from the muster roll on disk: start every app the
422    /// roll recorded running, leaving every app the flock already has exactly
423    /// as it stands
424    Muster,
425    /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
426    DogConfig {
427        /// The dog's name: the config key, not a selector
428        name: String,
429    },
430    /// Start one dog now, marking it as coming from `source`
431    EnableDog {
432        /// The dog's name
433        name: String,
434        /// Where its binary comes from
435        source: DogSource,
436    },
437    /// Stop and deregister one dog
438    ///
439    /// Answers [`Response::Deleted`]: disabling deregisters exactly as
440    /// `Delete` does.
441    DisableDog {
442        /// The dog's name
443        name: String,
444    },
445    /// Ask which dogs this daemon has given up on, and which it is still
446    /// waiting to hear from (`shep daemon reload`).
447    ///
448    /// Read-only, and about this daemon's own handshakes: take the reading
449    /// after a reload, not before one. Never sent to an older daemon, on
450    /// [`Self::HandoverFitness`]'s terms.
451    ///
452    /// Answers [`Response::DogStaleness`].
453    DogStaleness,
454    /// Ask whether this daemon could hand its flock to a successor in place,
455    /// rather than stopping it and starting it again (`shep daemon reload`).
456    ///
457    /// Read-only: the handover itself is triggered by a signal, which reaches
458    /// a daemon that refuses the client at the handshake.
459    ///
460    /// Answers [`Response::HandoverFitness`]. A refusal is a feature the
461    /// running daemon cannot carry, not an error: the caller falls back to a
462    /// stop-and-start and prints the reason. Never sent to an older daemon:
463    /// shep-cli's `commands::daemon` gates it on the crate version the
464    /// handshake reported.
465    HandoverFitness,
466    /// Graceful daemon shutdown
467    KillDaemon,
468    /// Subscribe this connection to bus topics (glob patterns)
469    Subscribe {
470        /// Topic globs, e.g. `process.*`
471        topics: Vec<String>,
472    },
473    /// A request kind this build has not been taught.
474    ///
475    /// `#[serde(other)]`, which serde allows here because `Request` is
476    /// internally tagged and this variant carries nothing. The unknown
477    /// body's own fields are discarded: the only thing to do with a
478    /// request we cannot name is refuse it, and the refusal needs the
479    /// envelope's id rather than the body.
480    #[serde(other)]
481    Unrecognized,
482}
483
484#[cfg(test)]
485mod tests {
486    use super::super::{Envelope, LineOutcome, LineReply, Response, SignalOutcome, SignalReply};
487    use super::*;
488    use crate::protocol::MIN_SUPPORTED;
489
490    #[test]
491    fn a_signal_request_and_its_reply_round_trip() {
492        let request = Request::Signal {
493            selector: SelectorSpec::Name("web".to_string()),
494            signal: "SIGHUP".to_string(),
495        };
496        let json = serde_json::to_string(&request).unwrap();
497        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
498
499        let reply = Response::Signalled(vec![
500            SignalReply {
501                id: 1,
502                name: "web".to_string(),
503                outcome: SignalOutcome::Delivered,
504            },
505            SignalReply {
506                id: 2,
507                name: "web".to_string(),
508                outcome: SignalOutcome::NotRunning,
509            },
510            SignalReply {
511                id: 3,
512                name: "api".to_string(),
513                outcome: SignalOutcome::Failed {
514                    reason: "no such process".to_string(),
515                },
516            },
517        ]);
518        let json = serde_json::to_string(&reply).unwrap();
519        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
520        // The three tags, spelled out: a variant renamed in Rust changes
521        // these strings, compiles clean, and breaks a client matching on them.
522        assert!(json.contains(r#""kind":"delivered""#), "{json}");
523        assert!(json.contains(r#""kind":"not_running""#), "{json}");
524        assert!(json.contains(r#""kind":"failed""#), "{json}");
525    }
526
527    /// `instances` is a per-app number, so `shep stock /web.*/ 4` could mean
528    /// four each or four total.
529    #[test]
530    fn a_scale_request_names_one_app_and_a_count() {
531        let request = Request::Scale {
532            name: "web".to_string(),
533            count: 4,
534        };
535        let json = serde_json::to_string(&request).unwrap();
536        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
537        assert!(json.contains(r#""kind":"scale""#), "{json}");
538        assert!(json.contains(r#""name":"web""#), "{json}");
539        // No `selector` key at all: this verb is not one of the
540        // selector-taking family.
541        assert!(!json.contains("selector"), "{json}");
542    }
543
544    #[test]
545    fn a_scaled_reply_carries_its_own_tag() {
546        let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
547        assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
548    }
549
550    /// `Add` and `Start` carry byte-identical payloads and differ by their
551    /// `kind` alone.
552    #[test]
553    fn an_add_request_and_its_reply_round_trip() {
554        let request = Request::Add {
555            apps: vec![AppConfig::minimal("web", "./srv")],
556        };
557        let json = serde_json::to_string(&request).unwrap();
558        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
559        assert!(json.contains(r#""kind":"add""#), "{json}");
560
561        let reply = Response::Added(vec![]);
562        let json = serde_json::to_string(&reply).unwrap();
563        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
564        assert!(json.contains(r#""kind":"added""#), "{json}");
565    }
566
567    /// `NotWritten`'s reason is the only thing separating "the app is not
568    /// reading its stdin" from "the pipe broke".
569    #[test]
570    fn a_send_line_request_and_its_reply_round_trip() {
571        let request = Request::SendLine {
572            selector: SelectorSpec::Name("repl".to_string()),
573            line: "reload-config".to_string(),
574        };
575        let json = serde_json::to_string(&request).unwrap();
576        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
577
578        let reply = Response::SentLine(vec![
579            LineReply {
580                id: 1,
581                name: "repl".to_string(),
582                outcome: LineOutcome::Sent,
583            },
584            LineReply {
585                id: 2,
586                name: "web".to_string(),
587                outcome: LineOutcome::NoStdin,
588            },
589            LineReply {
590                id: 3,
591                name: "stuck".to_string(),
592                outcome: LineOutcome::NotWritten {
593                    reason: "the app did not read its stdin within 2s".to_string(),
594                },
595            },
596        ]);
597        let json = serde_json::to_string(&reply).unwrap();
598        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
599        assert!(json.contains(r#""kind":"sent""#), "{json}");
600        assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
601        assert!(json.contains("did not read its stdin"), "{json}");
602    }
603
604    #[test]
605    fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
606        let request = Request::SendLine {
607            selector: SelectorSpec::All,
608            line: "a\nb".to_string(),
609        };
610        let json = serde_json::to_string(&request).unwrap();
611        // Escaped, not literal: the frame stays one JSON object. Refusing
612        // it is the daemon's job, not serde's.
613        assert!(json.contains(r#""line":"a\nb""#), "{json}");
614        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
615    }
616
617    /// The wire shape, pinned the way every other variant's is.
618    #[test]
619    fn set_sheep_env_batch_wire_v8() {
620        let request = Request::SetSheepEnvBatch {
621            name: "web".to_string(),
622            entries: BTreeMap::from([("A".to_string(), EnvValue::from("1".to_string()))]),
623            force: true,
624            dry_run: false,
625        };
626        let json = serde_json::to_string(&request).unwrap();
627        assert_eq!(
628            json,
629            r#"{"kind":"set_sheep_env_batch","name":"web","entries":{"A":"1"},"force":true,"dry_run":false}"#
630        );
631        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
632    }
633
634    /// Additive, so the floor does not move. Guards against a reflexive
635    /// raise. The ceiling is pinned once, by `protocol::tests`, and moves
636    /// for reasons unrelated to this variant.
637    #[test]
638    fn the_batch_variant_did_not_raise_the_floor() {
639        assert_eq!(MIN_SUPPORTED, 8);
640    }
641
642    #[test]
643    fn request_wire_snapshots() {
644        let requests = vec![
645            Envelope {
646                id: 1,
647                deadline_ms: Some(5000),
648                body: Request::Ping,
649            },
650            Envelope {
651                id: 2,
652                deadline_ms: None,
653                body: Request::ListFlock,
654            },
655            Envelope {
656                id: 3,
657                deadline_ms: None,
658                body: Request::Stop {
659                    selector: SelectorSpec::Name("web".to_string()),
660                },
661            },
662            Envelope {
663                id: 4,
664                deadline_ms: None,
665                body: Request::Start {
666                    apps: vec![AppConfig::minimal("web", "./srv")],
667                },
668            },
669            // `All` rather than a named sheep: the selector `shep reopen`
670            // sends when given no argument.
671            Envelope {
672                id: 5,
673                deadline_ms: None,
674                body: Request::Reopen {
675                    selector: SelectorSpec::All,
676                },
677            },
678            // The same selector as the row above, so the two log-plane rows
679            // differ by their `kind` and by nothing else.
680            Envelope {
681                id: 6,
682                deadline_ms: None,
683                body: Request::Flush {
684                    selector: SelectorSpec::All,
685                },
686            },
687            // The same selector as the `stop` row: `reload` under `stop`'s tag
688            // shows up here as two identical objects.
689            Envelope {
690                id: 7,
691                deadline_ms: None,
692                body: Request::Reload {
693                    selector: SelectorSpec::Name("web".to_string()),
694                },
695            },
696            // `action`/`params` match channel.rs's with-params fixture
697            // verbatim, so a trigger reads the same at every hop.
698            Envelope {
699                id: 8,
700                deadline_ms: None,
701                body: Request::Trigger {
702                    selector: SelectorSpec::Name("web".to_string()),
703                    action: "set-log-level".to_string(),
704                    params: Some("debug".to_string()),
705                },
706            },
707            // A fieldless verb: a bare `{"kind":"..."}` with no `selector` key.
708            Envelope {
709                id: 9,
710                deadline_ms: None,
711                body: Request::SaveRoll,
712            },
713            // Paired with the `save_roll` row: they differ by their `kind` alone.
714            Envelope {
715                id: 10,
716                deadline_ms: None,
717                body: Request::Muster,
718            },
719            // The three dog verbs. `enable_dog` and `disable_dog` differ by
720            // their `kind` and by `source` alone.
721            Envelope {
722                id: 11,
723                deadline_ms: None,
724                body: Request::DogConfig {
725                    name: "bark".to_string(),
726                },
727            },
728            Envelope {
729                id: 12,
730                deadline_ms: None,
731                body: Request::EnableDog {
732                    name: "metrics".to_string(),
733                    source: DogSource::BuiltIn,
734                },
735            },
736            Envelope {
737                id: 13,
738                deadline_ms: None,
739                body: Request::DisableDog {
740                    name: "metrics".to_string(),
741                },
742            },
743            // `Id`, `Regex` and `Fold` are three newtypes the wire tells apart
744            // only by their `kind` tag: a `Fold` under `regex`'s tag turns
745            // `shep restart fold:api` into a regex match.
746            Envelope {
747                id: 14,
748                deadline_ms: None,
749                body: Request::Describe {
750                    selector: SelectorSpec::Id(7),
751                },
752            },
753            Envelope {
754                id: 15,
755                deadline_ms: None,
756                body: Request::Describe {
757                    selector: SelectorSpec::Regex("^web-".to_string()),
758                },
759            },
760            Envelope {
761                id: 16,
762                deadline_ms: None,
763                body: Request::Describe {
764                    selector: SelectorSpec::Fold("api".to_string()),
765                },
766            },
767            // `SIGHUP` rather than `SIGTERM`: the stop ladder already sends
768            // TERM, so a TERM fixture could not tell the two frames apart.
769            Envelope {
770                id: 17,
771                deadline_ms: None,
772                body: Request::Signal {
773                    selector: SelectorSpec::Name("web".to_string()),
774                    signal: "SIGHUP".to_string(),
775                },
776            },
777            // The one verb here whose body has no `selector` key.
778            Envelope {
779                id: 18,
780                deadline_ms: None,
781                body: Request::Scale {
782                    name: "web".to_string(),
783                    count: 4,
784                },
785            },
786            // The line carries no terminator on the wire, since the shepherd
787            // appends it.
788            Envelope {
789                id: 19,
790                deadline_ms: None,
791                body: Request::SendLine {
792                    selector: SelectorSpec::All,
793                    line: "reload-config".to_string(),
794                },
795            },
796            // Both halves of the `Option` are pinned, a paint and a clear, so a
797            // dog author does not have to guess the clear frame's shape.
798            Envelope {
799                id: 20,
800                deadline_ms: None,
801                body: Request::SetSmit {
802                    sheep: "web".to_string(),
803                    smit: Some(
804                        "\u{25b2} main@a1b2c3"
805                            .parse()
806                            .expect("the reference smit is valid"),
807                    ),
808                },
809            },
810            Envelope {
811                id: 21,
812                deadline_ms: None,
813                body: Request::SetSmit {
814                    sheep: "web".to_string(),
815                    smit: None,
816                },
817            },
818            // An empty `apps`: `start`'s row already pins the payload type, so
819            // this row's own are the tag and the key the list travels under.
820            Envelope {
821                id: 22,
822                deadline_ms: None,
823                body: Request::ConfigDrift { apps: Vec::new() },
824            },
825            // The only struct-shaped `SelectorSpec` variant, so the only place
826            // `"kind":"instance"` and the `slot` key are pinned.
827            Envelope {
828                id: 23,
829                deadline_ms: None,
830                body: Request::Restart {
831                    selector: SelectorSpec::Instance {
832                        name: "web".to_string(),
833                        slot: 2,
834                    },
835                },
836            },
837            // The one request an older daemon must never be sent: shep-cli
838            // gates it on the daemon's crate version.
839            Envelope {
840                id: 24,
841                deadline_ms: None,
842                body: Request::HandoverFitness,
843            },
844            // The second request gated on the daemon's crate version.
845            Envelope {
846                id: 25,
847                deadline_ms: None,
848                body: Request::DogStaleness,
849            },
850            // The only request carrying a `DeclaredApp`: a merge keys on what a
851            // document claimed. `declared_env` is non-empty to show it holds
852            // env key names and no env value, and `reset` is pinned at a
853            // non-default depth.
854            Envelope {
855                id: 26,
856                deadline_ms: None,
857                body: Request::ApplyConfig {
858                    apps: vec![DeclaredApp {
859                        config: AppConfig::minimal("web", "./srv"),
860                        declared: ["name", "script"]
861                            .iter()
862                            .map(|k| (*k).to_string())
863                            .collect(),
864                        declared_env: ["DATABASE_URL"].iter().map(|k| (*k).to_string()).collect(),
865                    }],
866                    reset: ResetDepth::Policy,
867                },
868            },
869            // The same app as the `start` row above: the two differ by their
870            // `kind` alone, so a mis-tagged `add` shows up as two identical
871            // objects.
872            Envelope {
873                id: 27,
874                deadline_ms: None,
875                body: Request::Add {
876                    apps: vec![AppConfig::minimal("web", "./srv")],
877                },
878            },
879            // The four config-pane requests. `SheepConfig` takes a name
880            // rather than a selector, like `Scale` and `SetSmit` above and
881            // for their reason: a pane edits one sheep.
882            Envelope {
883                id: 28,
884                deadline_ms: None,
885                body: Request::SheepConfig {
886                    name: "web".to_string(),
887                },
888            },
889            // `value` is pinned as `Some`, because the `None` spelling is
890            // what removes the key, and a reader that guessed the two apart
891            // wrongly would delete an operator's env instead of setting it.
892            // The value is a placeholder, not a secret: this is the one
893            // request in the enum that carries an env value at all, and it
894            // travels in one direction only, nothing ever reads it back.
895            Envelope {
896                id: 29,
897                deadline_ms: None,
898                body: Request::SetSheepEnv {
899                    name: "web".to_string(),
900                    key: "DATABASE_URL".to_string(),
901                    value: Some("postgres://localhost/app".to_string().into()),
902                },
903            },
904            // `SetSheepEnv`'s twin for everything that is not `env`, and
905            // pinned beside it: the two are one letter apart in the tag and
906            // a reader that crossed them would write a config field into an
907            // env map. `value` is a bare JSON value rather than a string,
908            // which is the half a hand-written reader gets wrong: an
909            // integer field is an integer here, not `"32"`.
910            Envelope {
911                id: 30,
912                deadline_ms: None,
913                body: Request::SetSheepField {
914                    name: "web".to_string(),
915                    key: "max_restarts".to_string(),
916                    value: serde_json::json!(32),
917                },
918            },
919            // The second request carrying a `DogSectionToml`, and pinned
920            // beside its reader: `DogConfig` asks for a section and this
921            // writes one back, so the two have to agree about the shape a
922            // section takes on the wire.
923            Envelope {
924                id: 31,
925                deadline_ms: None,
926                body: Request::SetDogConfig {
927                    name: "bark".to_string(),
928                    toml: "debounce = \"30s\"\n".to_string().into(),
929                },
930            },
931            // The one request a provider dog sends, and the row that pins
932            // what `EnvValue` costs the wire: `entries` is a plain object
933            // of strings, so a dog written against this fixture in another
934            // language needs no newtype of its own.
935            Envelope {
936                id: 32,
937                deadline_ms: None,
938                body: Request::PutSecrets {
939                    namespace: "vercel".to_string(),
940                    environment: "production".to_string(),
941                    entries: BTreeMap::from([(
942                        "API_KEY".to_string(),
943                        EnvValue::from("sk_live_placeholder".to_string()),
944                    )]),
945                },
946            },
947            // `SetSheepEnvBatch`'s own doc comment calls this the densest
948            // run of secrets on the wire, so it gets two entries rather
949            // than one: a single-entry map would not distinguish an object
950            // from a map with one key. `force` and `dry_run` are both
951            // pinned away from their default so a silent default flip on
952            // either field shows up here.
953            Envelope {
954                id: 33,
955                deadline_ms: None,
956                body: Request::SetSheepEnvBatch {
957                    name: "web".to_string(),
958                    entries: BTreeMap::from([
959                        (
960                            "DATABASE_URL".to_string(),
961                            EnvValue::from("postgres://localhost/app".to_string()),
962                        ),
963                        (
964                            "API_KEY".to_string(),
965                            EnvValue::from("sk_live_placeholder".to_string()),
966                        ),
967                    ]),
968                    force: true,
969                    dry_run: true,
970                },
971            },
972            Envelope {
973                id: 34,
974                deadline_ms: None,
975                body: Request::HostUsage,
976            },
977        ];
978        insta::assert_json_snapshot!("request_wire_v9", requests);
979    }
980
981    #[test]
982    fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
983        assert_eq!(
984            serde_json::to_string(&Request::DogConfig {
985                name: "bark".to_string()
986            })
987            .unwrap(),
988            r#"{"kind":"dog_config","name":"bark"}"#
989        );
990        assert_eq!(
991            serde_json::to_string(&Request::DisableDog {
992                name: "bark".to_string()
993            })
994            .unwrap(),
995            r#"{"kind":"disable_dog","name":"bark"}"#
996        );
997        let section = Response::DogSection {
998            toml: "port = 9615\n".to_string().into(),
999        };
1000        let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
1001        assert_eq!(serde_json::to_string(&section).unwrap(), wire);
1002        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
1003    }
1004}