shep_core/protocol/request/response.rs
1//! [`Response`], one variant per request kind, and the host reading one of them carries.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::DogTable;
8
9use super::{
10 ActionReply, DogSectionToml, LineReply, ProcessInfo, SheepApplied, SheepConfigView, SheepDrift,
11 SheepRefusal, SignalReply,
12};
13
14// Named by intra-doc links and by nothing rustc compiles, so the
15// import is behind `cfg(doc)` rather than flagged unused.
16#[cfg(doc)]
17use super::{Request, RpcErrorCode, sort_flock};
18#[cfg(doc)]
19use crate::config::AppConfig;
20
21/// The machine the flock runs on, as the shepherd last read it.
22///
23/// Three of the four numbers are rates, so none of them exists in a single
24/// moment: CPU, disk traffic and network traffic are all differences between
25/// two readings of a counter the kernel only ever increments. The shepherd
26/// keeps the earlier reading between its own ticks and serves the
27/// difference, which is what lets a one-shot listing carry them at all.
28///
29/// Answered by [`Response::HostUsage`], and by nothing else: this is
30/// deliberately not a field on [`Response::Flock`], whose array shape
31/// predates it.
32#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub struct HostUsage {
34 /// Every core's usage over the shepherd's last window, as one percentage
35 /// of one whole machine.
36 ///
37 /// `None` where no window long enough to divide by has passed yet, which
38 /// on a shepherd that has just booted it has not. Absent is not idle.
39 pub cpu_percent: Option<f32>,
40 /// Memory in use, as the platform reports it. Not a rate, so never
41 /// absent.
42 pub memory_used_bytes: u64,
43 /// Total physical memory.
44 pub memory_total_bytes: u64,
45 /// Bytes a second the block devices read and wrote over that window,
46 /// absent on [`Self::cpu_percent`]'s terms.
47 pub disk_bytes_per_second: Option<(u64, u64)>,
48 /// Bytes a second the non-loopback interfaces received and transmitted
49 /// over that window, absent on the same terms.
50 pub network_bytes_per_second: Option<(u64, u64)>,
51}
52
53/// One RPC response (pairs with [`Request`] variants)
54///
55/// Ten variants carry a bare `Vec<ProcessInfo>`. Do not collapse them into
56/// one: each names which request it answers, which is what lets a variant
57/// diverge without a protocol bump. `Reloading` already means an acceptance
58/// rather than a result, `Scaled` only the survivors of a scale-down, and
59/// `Mustered` every sheep of every restored app rather than what this call
60/// started.
61// wire format: changing existing variants is a breaking change.
62// `large_enum_variant` allowed, not fixed: clippy's remedy is to box
63// `DogStarted`'s payload, a source break for every
64// `Response::DogStarted(info)` in and out of this workspace.
65#[allow(clippy::large_enum_variant)]
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum Response {
70 /// Answer to `Ping`
71 Pong,
72 /// Answer to `ListFlock`
73 Flock(Vec<ProcessInfo>),
74 /// Answer to `HostUsage`
75 ///
76 /// `None` on a platform `sysinfo` cannot read at all, which is a state a
77 /// caller renders rather than an error. A supported platform with no
78 /// window yet answers `Some`, carrying the memory it could read and
79 /// `None` for each rate it could not.
80 HostUsage(Option<HostUsage>),
81 /// Answer to `Describe`
82 Described(Vec<ProcessInfo>),
83 /// Answer to `Start`
84 Started(Vec<ProcessInfo>),
85 /// Answer to `Add`: one row per app the request named, registered and
86 /// spawning nothing.
87 ///
88 /// A row here can still be `Online`: `Add` is idempotent by name, so the
89 /// reply describes the membership the request leaves behind.
90 Added(Vec<ProcessInfo>),
91 /// Answer to `ConfigDrift`: one entry per app that is registered under a
92 /// config different from the one asked about, and no entry for anything
93 /// else. An empty vector means every app asked about either matches or
94 /// is not registered at all.
95 Drifted(Vec<SheepDrift>),
96 /// Answer to `ApplyConfig`: one entry per app the request named, in the
97 /// order it named them, the refused and the unchanged included.
98 ///
99 /// Complete where [`Self::Drifted`] is filtered: an app missing from
100 /// "what did you do to each of these" looks like one the daemon dropped.
101 Applied(Vec<SheepApplied>),
102 /// Answer to `SheepConfig`: one sheep's config with `env` emptied and
103 /// its keys listed beside it.
104 ///
105 /// Boxed, and the only variant here that is. This one carries a whole
106 /// [`AppConfig`], which is several times the size of anything else in
107 /// the enum, and a `Response` is inside a `Reply` which is inside a
108 /// [`ServerFrame`](crate::protocol::ServerFrame): without the box,
109 /// every frame the daemon sends costs the largest config's worth of
110 /// stack for a variant almost none of them use.
111 ///
112 /// The enum-level `#[allow(clippy::large_enum_variant)]` below does not
113 /// cover it, and the difference is the point of that allow's own
114 /// argument: boxing `DogStarted` would be a source break for every
115 /// `Response::DogStarted(info)` in and out of this workspace, where
116 /// this variant has never shipped and so breaks nobody.
117 ///
118 /// `Box<T>` serializes exactly as `T`, so the wire bytes and the pinned
119 /// fixtures are untouched.
120 SheepConfig(Box<SheepConfigView>),
121 /// Answer to `SetSheepEnv`: the key that was set or removed.
122 ///
123 /// Never the value, and never the resulting env map. This reply exists
124 /// to confirm which key moved, and echoing what was just written back
125 /// down a socket would undo the whole point of `SheepConfig` withholding
126 /// it (IR-41).
127 SheepEnvSet {
128 /// The sheep.
129 name: String,
130 /// The key.
131 key: String,
132 },
133 /// What a [`Request::SetSheepEnvBatch`] did, or would have done.
134 ///
135 /// Key names only. `set` is what was written, `unchanged` what already
136 /// held the same value, `collisions` what held a different one. A
137 /// forced request reports a collision in both `set` and `collisions`;
138 /// an unforced one that collides reports an empty `set` and wrote
139 /// nothing.
140 SheepEnvBatch {
141 /// The sheep's name.
142 name: String,
143 /// Keys written.
144 set: Vec<String>,
145 /// Keys that already held this value.
146 unchanged: Vec<String>,
147 /// Keys that held a different value.
148 collisions: Vec<String>,
149 },
150 /// Answer to `SetSheepField`: which field moved, and whether the
151 /// running child has it.
152 ///
153 /// Not [`Self::Applied`]'s three lists; the difference is the
154 /// request's own shape. `applied`, `pending` and `refused` exist
155 /// because `ApplyConfig` carries N apps of M fields, so a caller cannot
156 /// otherwise tell which field went where or that one app of eleven was
157 /// refused. This request carries one field of one sheep, so `refused`
158 /// would be a second way to say no beside the `Err` arm (a client
159 /// checking only the `Err` would silently swallow the other), and the
160 /// two lists collapse to the one bit that is left.
161 ///
162 /// That bit is not redundant with the field's own
163 /// [`ApplyGroup`](crate::config::ApplyGroup), which the caller already
164 /// knows. It is the daemon's answer about state a caller cannot see:
165 /// `autostart` is `NextSpawn` and yet reports as in force, because it
166 /// is read at muster rather than at a spawn, and a `Live` field whose
167 /// config subset will not normalize on its own parks instead of
168 /// applying.
169 SheepFieldSet {
170 /// The sheep.
171 name: String,
172 /// The field that moved.
173 key: String,
174 /// `true` when the running child does not have the value yet and
175 /// `shep reload <name>` is what promotes it. A client rendering
176 /// this says so, the same rule [`SheepApplied::pending`] carries.
177 pending: bool,
178 /// A `cwd`, `script`, `out_file` or `err_file` that looks wrong on
179 /// disk, `None` for every other field and for one of these four
180 /// that looks fine.
181 ///
182 /// Advisory, not a second way to say no: the write above still
183 /// landed. `normalize` cannot see the filesystem (a daemon and a
184 /// CLI normalizing the same config may run as different users), so
185 /// this is checked once, daemon-side, after the value is already
186 /// accepted, and a directory a deploy script has not created yet is
187 /// a real config the write must not refuse. The window between this
188 /// check and the respawn that actually needs the path is the same
189 /// one `check_log_ancestry`'s own doc comment names for its check
190 /// (`docs/specs/deferred.md`).
191 ///
192 /// Additive: absent rather than `null` on a peer built before this
193 /// field existed, so `PROTOCOL_VERSION` does not move for it.
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 warning: Option<String>,
196 },
197 /// Answer to `DogSheepSettings`: one dog's table from every sheep
198 /// carrying one.
199 ///
200 /// Empty rather than absent for a sheep naming no table, and empty
201 /// rather than [`RpcErrorCode::NotFound`] for a dog nobody names: a
202 /// dog asks this on its own name, which is never missing the way a
203 /// sheep selector can be.
204 ///
205 /// A table's values never print in a `{:?}` of this reply the way they
206 /// never print in [`AppConfig`]'s own (IR-41): [`DogTable`]'s manual
207 /// `Debug` carries that redaction wherever it rides.
208 DogSheepSettings {
209 /// Sheep name to that sheep's table for this dog.
210 tables: BTreeMap<String, DogTable>,
211 },
212 /// Answer to `SetSheepDogSettings`: which sheep and dog moved.
213 ///
214 /// Names only, like [`Self::SheepEnvSet`] and [`Self::DogConfigSet`]:
215 /// the table just written is the caller's own, so echoing it back
216 /// would put it on the wire a second time for no reader (IR-41).
217 SheepDogSettingsSet {
218 /// The sheep.
219 name: String,
220 /// The dog whose table moved.
221 dog: String,
222 },
223 /// Answer to `SetDogConfig`: the section was written and the topic
224 /// published.
225 DogConfigSet {
226 /// The dog.
227 name: String,
228 },
229 /// Answer to [`Request::PutSecrets`]: how many entries were stored.
230 SecretsPut {
231 /// Entry count, after the namespace and environment were replaced.
232 accepted: u32,
233 },
234 /// Answer to `Stop`
235 Stopped(Vec<ProcessInfo>),
236 /// Answer to `Restart`: the sheep that were restarted, one row each.
237 ///
238 /// **When the reply arrives depends on how many sheep matched.** One
239 /// sheep is answered as soon as its respawn is issued. Two or more are
240 /// restarted in dependency order, and the daemon holds each stage until
241 /// the apps a later stage waits on are back, so the reply arrives no
242 /// sooner than the last stage's respawns and the rows are stitched from
243 /// one answer per stage. A client asking for a budget sizes it for the
244 /// whole walk, not for one respawn.
245 Restarted {
246 /// The sheep the restart reached, one row each.
247 ///
248 /// A row is not a promise the process is up. A respawn that could
249 /// not exec is an `errored` row here rather than an entry in
250 /// `refused` below: the sheep was reached and the restart was not
251 /// refused, it is the child that failed.
252 accepted: Vec<ProcessInfo>,
253 /// The apps the walk could not restart, empty when it restarted
254 /// every one it named.
255 ///
256 /// Only a walk fills this. A selector matching one app is refused
257 /// whole, as the `Err` arm, so a client reading a single-target
258 /// restart never sees a row here.
259 refused: Vec<SheepRefusal>,
260 },
261 /// Answer to `Reload`: acceptances, not results.
262 ///
263 /// One instance costs a readiness wait plus a drain, so a clustered app
264 /// outlasts any deadline a client may ask for. Every row is therefore the
265 /// sheep as it stood when its own reload was accepted, and the swaps
266 /// report themselves on the bus (`process.reload`, `process.reloaded`,
267 /// `process.reload_abandoned`). A matched sheep with nothing to replace
268 /// is listed as the no-op success it is.
269 ///
270 /// **When the reply arrives depends on how many sheep matched.** One
271 /// sheep is answered as soon as its reload is accepted. Two or more are
272 /// reloaded in dependency order, and the daemon holds each stage until
273 /// the swaps of the apps a later stage waits on have landed, so the
274 /// reply arrives no sooner than the last stage's acceptance and the rows
275 /// are stitched from one acceptance per stage. A client asking for a
276 /// budget sizes it for the whole walk, not for one acceptance.
277 Reloading {
278 /// The sheep whose reloads were accepted, one row each.
279 accepted: Vec<ProcessInfo>,
280 /// The apps the walk could not reload, empty when it reloaded every
281 /// one it named.
282 ///
283 /// Only a walk fills this. A selector matching one app is refused
284 /// whole, as the `Err` arm, so a client reading a single-target
285 /// reload never sees a row here.
286 refused: Vec<SheepRefusal>,
287 },
288 /// Answer to `Scale`: the app's instances that will remain, one row each,
289 /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
290 /// order with the id breaking a tie.
291 ///
292 /// Scaling down, the departing instances are absent even though their
293 /// kill ladders are still running; they report themselves on the bus as
294 /// `process.delete`.
295 Scaled(Vec<ProcessInfo>),
296 /// Answer to `SetSmit`: every instance of the named sheep, one row each,
297 /// carrying the smit as it now stands.
298 SmitPainted(Vec<ProcessInfo>),
299 /// Answer to `Delete`: ids removed
300 Deleted(Vec<u32>),
301 /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
302 /// no live log pump has nothing to reopen and is reported as a success,
303 /// so this carries the same matches `Describe` would.
304 Reopened(Vec<ProcessInfo>),
305 /// Answer to `Flush`: one row per matched sheep, running or not, exactly
306 /// as [`Self::Reopened`].
307 ///
308 /// One row per sheep, not per file emptied: several sheep can share one
309 /// log path, and the daemon truncates each distinct path once.
310 Flushed(Vec<ProcessInfo>),
311 /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
312 /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
313 /// body.
314 Triggered(Vec<ActionReply>),
315 /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
316 ///
317 /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
318 /// outcome.
319 Signalled(Vec<SignalReply>),
320 /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
321 SentLine(Vec<LineReply>),
322 /// Answer to `SaveRoll`
323 RollSaved {
324 /// Absolute path of the roll the daemon wrote
325 path: String,
326 /// How many apps that roll records
327 apps: u32,
328 },
329 /// Answer to `Muster`: every sheep of every app the roll restored, not
330 /// only the ones this call spawned.
331 ///
332 /// Assembling a flock that is already assembled starts nothing, so a
333 /// listing of what this call spawned would be indistinguishable from an
334 /// empty roll.
335 Mustered(Vec<ProcessInfo>),
336 /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
337 ///
338 /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
339 /// credentials this text carries out of a `{:?}`-formatted `Response`.
340 DogSection {
341 /// The `[dog.<name>]` table as TOML text, empty when the file has
342 /// no such section
343 toml: DogSectionToml,
344 },
345 /// Answer to `EnableDog`: the dog as it stands now
346 DogStarted(ProcessInfo),
347 /// Answer to `DogStaleness`: this daemon's own handshake record, split
348 /// into the dogs it has given up on and the dogs it is still waiting on.
349 ///
350 /// Two lists because they answer two questions. `stale` is a finding;
351 /// `pending` is a reason to ask again, since a reading taken now would
352 /// be a guess about them.
353 ///
354 /// Names only: two builds differing only in the protocol they speak
355 /// report the same crate version.
356 DogStaleness {
357 /// Dogs this daemon has refused twice: once on the handshake that
358 /// bought them a restart from disk, and again after it. It will not
359 /// restart them a third time.
360 stale: Vec<String>,
361 /// Dogs this daemon is still waiting to hear a final answer from: one
362 /// whose restart is in flight, or one it supervises that has not
363 /// handshook yet. Neither stale nor known healthy.
364 pending: Vec<String>,
365 },
366 /// Answer to `HandoverFitness`: `None` when the whole flock can be
367 /// carried across a daemon handover, and otherwise the sentence saying
368 /// which sheep cannot be and why.
369 ///
370 /// A rendered sentence rather than a structured reason: the set of things
371 /// a handover cannot carry keeps changing, and the client only prints it.
372 HandoverFitness {
373 /// Why the flock cannot be handed over in place, or `None` when it
374 /// can.
375 refusal: Option<String>,
376 },
377 /// Answer to `Subscribe`
378 Subscribed,
379 /// Answer to `KillDaemon`
380 ShuttingDown,
381}
382
383#[cfg(test)]
384mod tests {
385 use super::super::process::sample_info;
386 use super::super::{
387 ActionOutcome, DogSource, ExitInfo, Lamb, LineOutcome, Reply, Request, RpcError,
388 RpcErrorCode, SignalOutcome,
389 };
390 use super::*;
391 use crate::config::AppConfig;
392 use crate::status::ProcStatus;
393
394 /// The reply carries key names and never a value.
395 #[test]
396 fn sheep_env_batch_response_wire_v8() {
397 let response = Response::SheepEnvBatch {
398 name: "web".to_string(),
399 set: vec!["A".to_string()],
400 unchanged: vec!["B".to_string()],
401 collisions: Vec::new(),
402 };
403 let json = serde_json::to_string(&response).unwrap();
404 assert_eq!(
405 json,
406 r#"{"kind":"sheep_env_batch","data":{"name":"web","set":["A"],"unchanged":["B"],"collisions":[]}}"#
407 );
408 assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), response);
409 }
410
411 #[test]
412 fn reply_wire_snapshots() {
413 let replies = vec![
414 Reply {
415 id: 1,
416 result: Ok(Response::Pong),
417 },
418 Reply {
419 id: 2,
420 result: Ok(Response::Flock(vec![sample_info()])),
421 },
422 Reply {
423 id: 3,
424 result: Err(RpcError {
425 code: RpcErrorCode::NotFound,
426 message: "no sheep matches `web`".to_string(),
427 daemon_version: None,
428 }),
429 },
430 // `ActionReply` is not a `ProcessInfo`. `Replied` is the
431 // struct-shaped `ActionOutcome` variant and so the one worth
432 // pinning.
433 Reply {
434 id: 4,
435 result: Ok(Response::Triggered(vec![ActionReply {
436 id: 3,
437 name: "web".to_string(),
438 outcome: ActionOutcome::Replied {
439 body: "ok".to_string(),
440 },
441 }])),
442 },
443 // The only struct-shaped `Response` variant; every other one is
444 // a newtype over a `Vec` or a unit, both proven above.
445 Reply {
446 id: 5,
447 result: Ok(Response::RollSaved {
448 path: "/home/ada/.shep/flock.json".to_string(),
449 apps: 2,
450 }),
451 },
452 // The present `dog` marker; `sample_info()` pins the absent one.
453 // `Adopted` because it is the variant carrying a payload.
454 Reply {
455 id: 6,
456 result: Ok(Response::Flock(vec![ProcessInfo {
457 id: 7,
458 name: "otel".to_string(),
459 dog: Some(DogSource::Adopted {
460 path: "/usr/local/bin/shep-otel".to_string(),
461 channel: false,
462 }),
463 ..sample_info()
464 }])),
465 },
466 // The section crosses the wire as text, never a typed structure.
467 Reply {
468 id: 7,
469 result: Ok(Response::DogSection {
470 toml: "port = 9615\n".to_string().into(),
471 }),
472 },
473 // The only `Response` variant carrying a bare `ProcessInfo`
474 // rather than a `Vec`: `enable` starts exactly one dog.
475 Reply {
476 id: 8,
477 result: Ok(Response::DogStarted(ProcessInfo {
478 id: 4,
479 name: "metrics".to_string(),
480 dog: Some(DogSource::BuiltIn),
481 ..sample_info()
482 })),
483 },
484 // Each row below carries the smallest body that shows its wire
485 // shape: the tag is what is being pinned. `Deleted` is a
486 // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
487 Reply {
488 id: 9,
489 result: Ok(Response::Described(vec![])),
490 },
491 Reply {
492 id: 10,
493 result: Ok(Response::Started(vec![])),
494 },
495 Reply {
496 id: 11,
497 result: Ok(Response::Stopped(vec![])),
498 },
499 // Both halves populated, as the row below: `refused` is the one
500 // field on either variant a walk fills and a single-target
501 // request never does.
502 Reply {
503 id: 12,
504 result: Ok(Response::Restarted {
505 accepted: vec![],
506 refused: vec![SheepRefusal::new(
507 "db",
508 "selector matched no registered sheep",
509 )],
510 }),
511 },
512 Reply {
513 id: 13,
514 result: Ok(Response::Reloading {
515 accepted: vec![],
516 refused: vec![SheepRefusal::new("db", "db is already being reloaded")],
517 }),
518 },
519 Reply {
520 id: 14,
521 result: Ok(Response::Deleted(vec![7, 8])),
522 },
523 Reply {
524 id: 15,
525 result: Ok(Response::Reopened(vec![])),
526 },
527 Reply {
528 id: 16,
529 result: Ok(Response::Flushed(vec![])),
530 },
531 Reply {
532 id: 17,
533 result: Ok(Response::Mustered(vec![])),
534 },
535 Reply {
536 id: 18,
537 result: Ok(Response::Subscribed),
538 },
539 Reply {
540 id: 19,
541 result: Ok(Response::ShuttingDown),
542 },
543 // `Signalled`, mirroring the `Triggered` row: one row per
544 // `SignalOutcome` variant, so no tag is left unproven.
545 Reply {
546 id: 20,
547 result: Ok(Response::Signalled(vec![
548 SignalReply {
549 id: 1,
550 name: "web".to_string(),
551 outcome: SignalOutcome::Delivered,
552 },
553 SignalReply {
554 id: 2,
555 name: "web".to_string(),
556 outcome: SignalOutcome::NotRunning,
557 },
558 SignalReply {
559 id: 3,
560 name: "api".to_string(),
561 outcome: SignalOutcome::Failed {
562 reason: "no such process".to_string(),
563 },
564 },
565 ])),
566 },
567 Reply {
568 id: 21,
569 result: Ok(Response::Scaled(vec![sample_info()])),
570 },
571 // `SentLine`, mirroring the `Signalled` row: one row per
572 // `LineOutcome` variant.
573 Reply {
574 id: 22,
575 result: Ok(Response::SentLine(vec![
576 LineReply {
577 id: 1,
578 name: "repl".to_string(),
579 outcome: LineOutcome::Sent,
580 },
581 LineReply {
582 id: 2,
583 name: "web".to_string(),
584 outcome: LineOutcome::NoStdin,
585 },
586 LineReply {
587 id: 3,
588 name: "stuck".to_string(),
589 outcome: LineOutcome::NotWritten {
590 reason: "the app did not read its stdin within 2s".to_string(),
591 },
592 },
593 ])),
594 },
595 // A walked lamb tree; every other row pins the `null` shape.
596 Reply {
597 id: 23,
598 result: Ok(Response::Described(vec![
599 ProcessInfo::builder(3, "web", ProcStatus::Online)
600 .pid(Some(4242))
601 .lambs(Some(vec![
602 Lamb::new(4243, "node"),
603 Lamb::new(4244, "sh").with_label("worker 1"),
604 ]))
605 .build(),
606 ])),
607 },
608 // The killed-by-signal shape of `last_exit`; every row above pins
609 // the exited-normally one. `SIGTERM`'s raw number, since this
610 // crate carries no name for it.
611 Reply {
612 id: 24,
613 result: Ok(Response::Flock(vec![
614 ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
615 .restarts(1)
616 .last_exit(Some(ExitInfo {
617 code: None,
618 signal: Some(15),
619 }))
620 .build(),
621 ])),
622 },
623 // The one row that pins a smit on the wire; `sample_info()` carries
624 // none.
625 Reply {
626 id: 25,
627 result: Ok(Response::SmitPainted(vec![
628 ProcessInfo::builder(3, "web", ProcStatus::Online)
629 .pid(Some(4242))
630 .smit(Some("\u{25b2} main@a1b2c3".to_string()))
631 .build(),
632 ])),
633 },
634 // A sheep drifting in one field and a sheep drifting in several.
635 // `env` is one of them: the name travels and the value never does.
636 Reply {
637 id: 26,
638 result: Ok(Response::Drifted(vec![
639 SheepDrift::new("web", vec!["cwd".to_string()]),
640 SheepDrift::new(
641 "api",
642 vec!["args".to_string(), "env".to_string(), "script".to_string()],
643 ),
644 ])),
645 },
646 // The present shape of `instance`; every row above pins its absence.
647 Reply {
648 id: 27,
649 result: Ok(Response::Flock(vec![
650 ProcessInfo::builder(9, "web", ProcStatus::Online)
651 .pid(Some(5150))
652 .instance(Some(2))
653 .build(),
654 ])),
655 },
656 // Both shapes of the handover answer; the difference between them
657 // is a `null`.
658 Reply {
659 id: 28,
660 result: Ok(Response::HandoverFitness { refusal: None }),
661 },
662 Reply {
663 id: 29,
664 result: Ok(Response::HandoverFitness {
665 refusal: Some("sheep 'web' has a shepherd channel".to_string()),
666 }),
667 },
668 // Both lists non-empty and different: the two carry the same wire
669 // shape.
670 Reply {
671 id: 30,
672 result: Ok(Response::DogStaleness {
673 stale: vec!["metrics".to_string()],
674 pending: vec!["bark".to_string()],
675 }),
676 },
677 // A dog whose process is up and which has never answered this
678 // shepherd. `dog_stale: false` is the silence still being waited
679 // out; the row below is the one it has given up on.
680 Reply {
681 id: 31,
682 result: Ok(Response::Flock(vec![
683 ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
684 .pid(Some(208_341))
685 .dog(Some(DogSource::Adopted {
686 path: "/usr/local/bin/shep-log-rotate".to_string(),
687 channel: false,
688 }))
689 .handshook(Some(false))
690 .dog_stale(Some(false))
691 .build(),
692 ])),
693 },
694 Reply {
695 id: 32,
696 result: Ok(Response::Flock(vec![
697 ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
698 .pid(Some(208_341))
699 .dog(Some(DogSource::Adopted {
700 path: "/usr/local/bin/shep-log-rotate".to_string(),
701 channel: false,
702 }))
703 .handshook(Some(false))
704 .dog_stale(Some(true))
705 .build(),
706 ])),
707 },
708 // Three entries, one per shape a load produces: applied, pending,
709 // refused. `env` is a pending name on purpose: the name travels
710 // and the value never does.
711 Reply {
712 id: 32,
713 result: Ok(Response::Applied(vec![
714 SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
715 SheepApplied::new(
716 "api",
717 Vec::new(),
718 vec!["args".to_string(), "env".to_string()],
719 None,
720 ),
721 SheepApplied::new(
722 "worker",
723 Vec::new(),
724 Vec::new(),
725 Some("worker is not registered".to_string()),
726 ),
727 ])),
728 },
729 // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
730 // variant. Down here because every id in this vector is
731 // hand-written.
732 Reply {
733 id: 33,
734 result: Ok(Response::Added(vec![])),
735 },
736 // The config pane's answer, and the row that proves its whole
737 // security property: `env` serializes as an empty object while
738 // `env_keys` names the key beside it, so an out-of-tree reader
739 // learns here that a value never travels (IR-41).
740 Reply {
741 id: 34,
742 result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
743 {
744 let mut config = AppConfig::minimal("web", "./srv");
745 config
746 .env
747 .insert("DATABASE_URL".to_string(), "postgres://x".to_string());
748 config
749 },
750 vec!["max_restarts".to_string()],
751 vec!["env".to_string()],
752 )))),
753 },
754 // The three acknowledgements. None echoes what was written:
755 // `SheepEnvSet` names the key and not its value, for the reason
756 // the row above pins, `SheepFieldSet` does the same and adds
757 // the one bit the caller cannot derive, and `DogConfigSet`
758 // names the dog and not the section.
759 Reply {
760 id: 35,
761 result: Ok(Response::SheepEnvSet {
762 name: "web".to_string(),
763 key: "DATABASE_URL".to_string(),
764 }),
765 },
766 // `pending` pinned `true`, because `false` is the value a reader
767 // that dropped the field entirely would decode by accident, and
768 // the two answers send an operator to different places: one
769 // says the change is in force, the other says to reload.
770 Reply {
771 id: 36,
772 result: Ok(Response::SheepFieldSet {
773 name: "web".to_string(),
774 key: "script".to_string(),
775 pending: true,
776 // `None` on purpose: this is the row every earlier
777 // version's fixture already pinned, and `warning` must
778 // stay invisible on the wire for that value or the
779 // additive claim above is false.
780 warning: None,
781 }),
782 },
783 Reply {
784 id: 37,
785 result: Ok(Response::DogConfigSet {
786 name: "bark".to_string(),
787 }),
788 },
789 // A count and not the entries: a dog already knows what it
790 // pushed, and echoing the map back would put every value it
791 // sent on the wire a second time for no reader (IR-41).
792 Reply {
793 id: 38,
794 result: Ok(Response::SecretsPut { accepted: 2 }),
795 },
796 // Key names only, on all three lists, and none empty here on
797 // purpose: an empty `Vec` serializes the same whether it holds
798 // strings or something else, so a reader that guessed the
799 // element type wrong would still pass against an empty-list
800 // fixture. `collisions` also proves a forced write can name a
801 // key in both `set` and `collisions` at once.
802 Reply {
803 id: 39,
804 result: Ok(Response::SheepEnvBatch {
805 name: "web".to_string(),
806 set: vec!["DATABASE_URL".to_string(), "API_KEY".to_string()],
807 unchanged: vec!["LOG_LEVEL".to_string()],
808 collisions: vec!["API_KEY".to_string()],
809 }),
810 },
811 // Both halves of the same reply: a machine that has been read
812 // and one that has not been read for long enough yet. The rates
813 // are what separate them, and they are `null` rather than `0`
814 // in the second, which is the whole distinction the renderer
815 // spells as `-`.
816 Reply {
817 id: 40,
818 result: Ok(Response::HostUsage(Some(HostUsage {
819 cpu_percent: Some(11.5),
820 memory_used_bytes: 39_963_869_184,
821 memory_total_bytes: 51_539_607_552,
822 disk_bytes_per_second: Some((1_258_291, 491_520)),
823 network_bytes_per_second: Some((24_594, 9_260)),
824 }))),
825 },
826 Reply {
827 id: 41,
828 result: Ok(Response::HostUsage(Some(HostUsage {
829 cpu_percent: None,
830 memory_used_bytes: 39_963_869_184,
831 memory_total_bytes: 51_539_607_552,
832 disk_bytes_per_second: None,
833 network_bytes_per_second: None,
834 }))),
835 },
836 // A platform `sysinfo` cannot read at all, which is neither of
837 // the two above.
838 Reply {
839 id: 42,
840 result: Ok(Response::HostUsage(None)),
841 },
842 // Two sheep, one table each, so this row proves a dog reads
843 // more than one sheep's table from a single answer. The table
844 // itself repeats `SetSheepDogSettings`'s own two keys, one
845 // nested, so both rides through this enum agree on the shape.
846 Reply {
847 id: 43,
848 result: Ok(Response::DogSheepSettings {
849 tables: BTreeMap::from([
850 (
851 "web".to_string(),
852 DogTable::from(serde_json::Map::from_iter([
853 ("concurrency".to_string(), serde_json::json!(2)),
854 (
855 "hours".to_string(),
856 serde_json::json!({ "start": "09:00:00" }),
857 ),
858 ])),
859 ),
860 (
861 "worker".to_string(),
862 DogTable::from(serde_json::Map::from_iter([(
863 "concurrency".to_string(),
864 serde_json::json!(1),
865 )])),
866 ),
867 ]),
868 }),
869 },
870 Reply {
871 id: 44,
872 result: Ok(Response::SheepDogSettingsSet {
873 name: "web".to_string(),
874 dog: "jobs".to_string(),
875 }),
876 },
877 ];
878 insta::assert_json_snapshot!("reply_wire_v11", replies);
879 }
880
881 /// The additive claim on `SheepFieldSet::warning` has three halves and
882 /// the reply fixture pins only the first. `None` staying off the wire is
883 /// what makes the field additive at all; `Some` surviving a round trip is
884 /// what makes it useful; and a payload with no `warning` key reading back
885 /// as `None` is what lets a daemon built before the field answer a client
886 /// built after it.
887 ///
888 /// The third pins the behaviour, not the attribute. Measured by removing
889 /// `#[serde(default)]` and re-running: it still passes, because serde
890 /// already reads a missing `Option` field as `None` without being asked.
891 /// So the attribute is belt-and-braces and this test would not notice its
892 /// removal. What it does notice is the field being renamed, retyped, or
893 /// made required, which is what would actually break an older peer.
894 #[test]
895 fn the_set_field_warning_is_additive_in_both_directions() {
896 let quiet = Response::SheepFieldSet {
897 name: "web".to_string(),
898 key: "script".to_string(),
899 pending: true,
900 warning: None,
901 };
902 let json = serde_json::to_string(&quiet).unwrap();
903 assert!(
904 !json.contains("warning"),
905 "a `None` warning must not reach the wire at all: {json}"
906 );
907
908 let loud = Response::SheepFieldSet {
909 name: "web".to_string(),
910 key: "cwd".to_string(),
911 pending: true,
912 warning: Some("/srv/app does not exist yet".to_string()),
913 };
914 let json = serde_json::to_string(&loud).unwrap();
915 let back: Response = serde_json::from_str(&json).unwrap();
916 assert_eq!(back, loud, "a warning must survive the round trip: {json}");
917
918 let older =
919 r#"{"kind":"sheep_field_set","data":{"name":"web","key":"script","pending":true}}"#;
920 let back: Response = serde_json::from_str(older).unwrap();
921 assert_eq!(
922 back,
923 Response::SheepFieldSet {
924 name: "web".to_string(),
925 key: "script".to_string(),
926 pending: true,
927 warning: None,
928 },
929 "a peer that predates the field must still deserialize"
930 );
931 }
932
933 #[test]
934 fn save_roll_serializes_snake_case_with_its_payload_under_data() {
935 assert_eq!(
936 serde_json::to_string(&Request::SaveRoll).unwrap(),
937 r#"{"kind":"save_roll"}"#
938 );
939 let reply = Response::RollSaved {
940 path: "/tmp/flock.json".to_string(),
941 apps: 3,
942 };
943 let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
944 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
945 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
946 }
947
948 /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
949 /// field by field.
950 #[test]
951 fn muster_serializes_snake_case_with_its_listing_under_data() {
952 assert_eq!(
953 serde_json::to_string(&Request::Muster).unwrap(),
954 r#"{"kind":"muster"}"#
955 );
956 let reply = Response::Mustered(Vec::new());
957 let wire = r#"{"kind":"mustered","data":[]}"#;
958 assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
959 assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
960 }
961}