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