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(§ion).unwrap(), wire);
1002 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
1003 }
1004}