shep 0.19.0

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
//! The small value types the flock table, the status bar and the wire share.

use super::*;

/// The connection's state, as the dashboard reports it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Link {
    /// Connected and subscribed.
    Live,
    /// Re-dialling. `attempt` is 1-based and bounded by
    /// `crate::lookout::link::RECONNECT_ATTEMPTS`.
    Retrying {
        /// Which attempt is in flight.
        attempt: u32,
    },
    /// The ladder is exhausted. Terminal: nothing moves this state, and the
    /// values on screen stay as they were.
    Lost {
        /// When it was declared lost, already rendered for display.
        at_local: String,
        /// Why the last dial failed, in the error's own words.
        why: String,
    },
}

/// One sheep's row: what the shepherd said, and when it said it.
#[derive(Debug, Clone)]
pub struct Row {
    /// The shepherd's own snapshot of this sheep.
    pub info: ProcessInfo,
    /// When [`Self::info`] was received: the origin for this row's live
    /// uptime, so a value two seconds old is never rendered as current.
    pub anchor: Instant,
}

impl Row {
    /// What this row's STATUS cell reports: [`Self::info`]'s status, or
    /// [`Reported::Silent`] for a dog whose process is up and which has never
    /// handshook this shepherd.
    ///
    /// Mirrors `output::rows::reported`. The `dog` check is read here rather
    /// than left to [`Reported::of`], so a non-dog row can never be painted
    /// silent by a stray `handshook`.
    #[must_use]
    pub(crate) fn reported(&self) -> Reported {
        if self.info.disabled == Some(true) && self.info.status == ProcStatus::Stopped {
            return Reported::Disabled;
        }
        if self.info.dog.is_none() {
            return Reported::Live(self.info.status);
        }
        Reported::of(self.info.status, self.info.handshook)
    }
}

/// One app's rolled-up numbers, computed from its own instances.
///
/// The fields `output::rows`'s own `GroupTotals` sums for `shep flock`.
/// Restarts, cpu and memory are summed; uptime is the minimum, so a group reads
/// as time since the app was last disturbed.
#[derive(Debug, Clone)]
pub struct GroupTotals {
    /// How many instances make up this group.
    pub count: usize,
    /// Every instance's restarts, added up.
    pub restarts: u32,
    /// Every instance's CPU reading summed, `None` only when not one
    /// instance has a live reading.
    pub cpu: Option<f32>,
    /// Every instance's memory reading summed, `None` only when not one
    /// instance has a live reading.
    pub memory: Option<u64>,
    /// The minimum live uptime across instances, `None` only when the group
    /// has none.
    pub uptime_ms: Option<u64>,
}

/// One request the dashboard asked the link task to send, carried back on the
/// reply so it can be routed.
///
/// An echo tag rather than a correlation id: an `Err` reply carries no shape of
/// its own.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sent {
    /// The selected sheep's process tree.
    Lambs {
        /// Which sheep was asked about.
        id: u32,
    },
    /// One action against a target: one sheep, or every instance of a named
    /// app. `name` rides along so a reply can be reported by name even after
    /// the target has left the flock.
    Action {
        /// Which verb.
        verb: ActionVerb,
        /// The pinned target.
        target: RowKey,
        /// Its name at arm time.
        name: String,
    },
    /// One dog's daemon half, after its file half landed. `entry` is what the
    /// write returned, so the request cannot disagree with the file.
    Dog {
        /// The dog's name.
        name: String,
        /// `true` to start it, `false` to stop it and keep it listed as disabled.
        enable: bool,
        /// Where the binary comes from and what `env` the operator set for
        /// it, exactly as the config write answered.
        entry: DogEntry,
        /// The ticket the file half went out with, carried on so the
        /// shepherd's answer resolves the prompt that toggle raised and no
        /// other.
        ticket: u64,
    },
    /// One dog's newly recorded grants, after `[daemon] dog_grants` landed:
    /// the same `Request::EnableDog` `shep dogs grant` sends, so a dog that is
    /// already running is judged by what the record now says rather than by
    /// what it was started with.
    ///
    /// Not [`Self::Dog`]: that variant's `enable` says which way the toggle
    /// went, and a grant changes no toggle.
    GrantDog {
        /// The dog.
        name: String,
        /// The entry carrying the grants the record now holds and the `env`
        /// the operator set, exactly as the config write answered.
        entry: DogEntry,
        /// The ticket the file half went out with, carried on so the
        /// shepherd's answer resolves the prompt that grant raised and no
        /// other.
        ticket: u64,
    },
    /// One sheep's effective config, for the config pane. Raised by `e` on
    /// the dashboard and by `r` from inside an open pane.
    ///
    /// A name rather than a [`RowKey`], because [`Request::SheepConfig`]
    /// takes one: the pane is about an app's stored spec, which every
    /// instance of a multi-instance app shares, so an id would name a
    /// narrower thing than the config it would come back with.
    SheepConfig {
        /// Which sheep was asked about.
        name: String,
    },
    /// One field of one sheep's config, off the config pane's own `Enter`.
    ///
    /// [`Self::SetEnv`]'s twin, and a `Request::SetSheepField` rather than a
    /// one-key `Request::ApplyConfig` for that variant's own reason: an
    /// `ApplyConfig` at `ResetDepth::File` spends the operator's override. The
    /// [`WriteAuthority`] is not decoration, for [`Effect::WriteSetting`]'s
    /// reason.
    ApplyField {
        /// The sheep.
        name: String,
        /// Which write this is, so a reply names its own request and no
        /// other. Minted per send, so no two are ever equal.
        ticket: u64,
        /// The field that moves.
        key: String,
        /// Its new value, in the shape that field serializes as. Wrapped,
        /// so a `{:?}` of this enum cannot print it: `cwd` and `script`
        /// hold a home directory and `args` holds a token (IR-41).
        value: FieldValue,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
    /// One dog's `[<name>]` section, for the dog config pane. Raised once
    /// the pane's schema probe has answered, and again by `r` from inside an
    /// open dog pane and after a landed write.
    ///
    /// The schema is not asked for here and never travels the wire: it comes
    /// off the dog's own binary ([`Effect::LoadDogPane`]), because nothing
    /// records one.
    DogSection {
        /// Which dog was asked about.
        name: String,
    },
    /// One dog's whole section, off the dog pane's own `Enter`.
    ///
    /// The whole section rather than one key, because
    /// `Request::SetDogConfig` replaces the table: `ConfigPane::edited_section_with`
    /// applies the batch through `toml_edit`, so the operator's comments and
    /// key order survive. A dog has no override store and no Flockfile. The
    /// [`WriteAuthority`] is not decoration, for [`Effect::WriteSetting`]'s reason.
    SetDogSection {
        /// The dog.
        name: String,
        /// Which write this is, so a reply names its own request and no
        /// other. Minted per send, so no two are ever equal.
        ticket: u64,
        /// The section, edit applied. [`DogSectionToml`] rather than a
        /// `String` so a `{:?}` of this enum cannot print the webhook
        /// credentials a dog's section routinely holds (IR-41).
        toml: DogSectionToml,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
    /// One dog's whole table on one sheep, or its removal with [`None`]:
    /// the per-sheep table pane's close, or the dogs sub-screen's confirmed
    /// `d`.
    ///
    /// The whole table, because `Request::SetSheepDogSettings` replaces it.
    /// [`DogTable`]'s own `Debug` prints a key count, so a `{:?}` of this
    /// enum prints no value (IR-41).
    SetSheepDogTable {
        /// The sheep.
        name: String,
        /// The dog whose table moves.
        dog: String,
        /// Which write this is, for [`Self::ApplyField`]'s reason.
        ticket: u64,
        /// The new table, or [`None`] to remove it.
        table: Option<DogTable>,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
    /// One env key of one sheep, off the env sub-screen's own `Enter`.
    ///
    /// Its own variant beside [`Self::ApplyField`] rather than a value of
    /// it, because the two are different requests: a whole env map is
    /// never sent (a pane is not told the values), so `SetSheepField`
    /// refuses the `env` key outright and `SetSheepEnv` takes one key at a
    /// time.
    SetEnv {
        /// The sheep.
        name: String,
        /// Which write this is, for [`Self::ApplyField`]'s reason.
        ticket: u64,
        /// The env key.
        key: String,
        /// The value, or `None` to remove the key. [`EnvValue`] rather than
        /// a `String` so a `{:?}` of this enum cannot print it (IR-41).
        value: Option<EnvValue>,
        /// Proof the control gate was open.
        authority: WriteAuthority,
    },
}

impl Sent {
    /// The wire request this asks for.
    #[must_use]
    pub fn request(&self) -> Request {
        match self {
            Self::Lambs { id } => Request::Describe {
                selector: SelectorSpec::Id(*id),
            },
            Self::Action { verb, target, .. } => {
                let selector = match target {
                    RowKey::Sheep(id) => SelectorSpec::Id(*id),
                    RowKey::Group(name) => SelectorSpec::Name(name.clone()),
                    RowKey::Fold(name) => SelectorSpec::Fold(name.clone()),
                    RowKey::Section(_) => unreachable!("a header is never an action target"),
                };
                match verb {
                    ActionVerb::Stop => Request::Stop { selector },
                    ActionVerb::Restart => Request::Restart { selector },
                    ActionVerb::Reload => Request::Reload { selector },
                }
            }
            Self::Dog {
                name,
                enable: true,
                entry,
                ..
            } => Request::EnableDog {
                name: name.clone(),
                env: entry.env.clone(),
                source: entry.source.clone(),
            },
            Self::Dog {
                name,
                enable: false,
                ..
            } => Request::DisableDog { name: name.clone() },
            // The same request an enable sends, for the reason
            // `Self::GrantDog`'s own doc gives: the shepherd records the
            // grants a source carries before it finds the dog already running.
            Self::GrantDog { name, entry, .. } => Request::EnableDog {
                name: name.clone(),
                env: entry.env.clone(),
                source: entry.source.clone(),
            },
            Self::SheepConfig { name } => Request::SheepConfig { name: name.clone() },
            // One field, recorded as an operator override, not an
            // `ApplyConfig`: see this variant's own doc for the marker that
            // route silently spent.
            Self::ApplyField {
                name, key, value, ..
            } => Request::SetSheepField {
                name: name.clone(),
                key: key.clone(),
                // Unwrapped only here, at the wire. See `FieldValue` for
                // why the protocol's own field is a bare `Value` while
                // everything above it is not.
                value: value.as_value().clone(),
            },
            Self::DogSection { name } => Request::DogConfig { name: name.clone() },
            Self::SetDogSection { name, toml, .. } => Request::SetDogConfig {
                name: name.clone(),
                toml: toml.clone(),
            },
            Self::SetSheepDogTable {
                name, dog, table, ..
            } => Request::SetSheepDogSettings {
                name: name.clone(),
                dog: dog.clone(),
                table: table.clone(),
            },
            Self::SetEnv {
                name, key, value, ..
            } => Request::SetSheepEnv {
                name: name.clone(),
                key: key.clone(),
                value: value.clone(),
            },
        }
    }

    /// The ticket a pane write minted itself with, or [`None`] for a
    /// variant no pane ever produces.
    ///
    /// What [`App::resolve_held_write`] matches a reply against, rather than
    /// trusting whatever a counter says is outstanding: two writes in flight
    /// from two different close-dialog answers are indistinguishable to a count.
    pub(super) fn ticket(&self) -> Option<u64> {
        match self {
            Self::ApplyField { ticket, .. }
            | Self::SetDogSection { ticket, .. }
            | Self::SetSheepDogTable { ticket, .. }
            | Self::SetEnv { ticket, .. } => Some(*ticket),
            Self::Lambs { .. }
            | Self::Action { .. }
            | Self::Dog { .. }
            | Self::GrantDog { .. }
            | Self::SheepConfig { .. }
            | Self::DogSection { .. } => None,
        }
    }
}

/// One dog pane's close, split by where its edits land.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PaneWrites {
    /// The requests the link task sends to the shepherd, in order.
    pub requests: Vec<Sent>,
    /// The dog pane's `shep.toml` half, when it has one: every `env` key the
    /// close filed, with the value or [`None`] for a removal.
    pub env: Option<DogEnvWrite>,
}

/// One dog's `env` keys, as one `shep.toml` write.
///
/// `Debug` is derived, safe because [`EnvValue`]'s own prints a byte count
/// (IR-41).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DogEnvWrite {
    /// The dog.
    pub name: String,
    /// Each key the close filed, with its value, or [`None`] to remove it.
    pub edits: Vec<(String, Option<EnvValue>)>,
    /// Which write this is, for [`Effect::WriteSetting`]'s reason.
    pub ticket: u64,
    /// Proof the control gate was open.
    pub authority: WriteAuthority,
}

/// One dog toggle, ready for the file half: [`Effect::WriteDog`] carries one
/// and [`Msg::DogWritten`] echoes it back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogEdit {
    /// The dog's name.
    pub name: String,
    /// `true` to enable, `false` to disable.
    pub enable: bool,
}

/// What one of a dog's other `[daemon] keys left in `shep.toml`.
///
/// Two answers because the two keys do different things: `boot_first_dogs` is
/// read by the next boot and reaches no running anything, and a grant is read
/// by the shepherd when it starts the dog, so it has a daemon half.
///
/// `Debug` is derived, not redacted: a binary's path and permission names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DogTuneLanded {
    /// `[daemon] boot_first_dogs` changed. Nothing to tell the shepherd: the
    /// order is read at boot.
    BootOrder,
    /// `[daemon] dog_grants` changed, carrying what the record now describes
    /// and whether the dog is in `enabled_dogs`.
    ///
    /// `enabled` is the one `record_change` read under the lock, so it does
    /// not depend on the settings screen still being open when this answer
    /// lands.
    Grants {
        /// What the record now describes, which is what telling the shepherd
        /// needs: its source, and the `env` the operator set beside it.
        entry: DogEntry,
        /// Whether the dog is enabled, as the file read it under the lock.
        enabled: bool,
    },
}

/// A dog's schema, and the binary it was probed from.
///
/// Held on [`App`] between the probe answering ([`Msg::DogPane`]) and the
/// section landing ([`Sent::DogSection`]), and then for as long as the pane
/// is open, so a refresh re-reads the section without respawning the dog.
/// `Debug` is derived rather than redacted (IR-41): a schema describes values
/// without carrying any.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DogProbe {
    /// The dog.
    pub name: String,
    /// The adopted binary, or [`None`] for a built-in.
    pub adopted_path: Option<PathBuf>,
    /// What it answered the schema flag with.
    pub schema: serde_json::Value,
    /// The key names of the `env` its `shep.toml` entry sets, carried from
    /// [`Msg::DogPane`] so a refresh of the pane does not have to re-read
    /// `shep.toml` to know which rows it has.
    pub env_keys: Vec<String>,
}

/// What the cursor can sit on: one sheep, or the header above an app's
/// instances.
///
/// A name earns a [`Self::Group`] only with more than one instance, every one
/// of them reporting its slot ([`App::grouped_names`]).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum RowKey {
    /// One app's group header, carrying its name.
    Group(String),
    /// One sheep, by id.
    Sheep(u32),
    /// A header, never selectable. `&'static str` because the only two are
    /// written here.
    Section(&'static str),
    /// One fold's header, carrying its name.
    ///
    /// Selectable and actionable, unlike [`Self::Section`], because
    /// `SelectorSpec::Fold` can name its members on the wire.
    Fold(String),
}

/// How the flock table gathers its rows.
///
/// `Debug` is derived; it is two words.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Grouping {
    /// Every sheep in one list, apps rolled up by instance.
    #[default]
    Flat,
    /// Sheep gathered under their fold.
    ByFold,
}

/// What one lamb fetch came back with. The pane says a different sentence for
/// each variant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LambWalk {
    /// The shepherd walked the process table. Possibly to no descendants.
    Walked(Vec<Lamb>),
    /// The reply carried no walk at all, which for a `Describe` means this
    /// sheep has no pid to walk from.
    NotWalked,
    /// The request did not come back, or came back as something this lookout
    /// has no shape for.
    Failed,
}

/// One reading of a sheep's two log files on disk, and which sheep it was
/// taken for.
///
/// Keyed by id for [`LambReading`]'s reason: the detail pane must not print
/// one sheep's byte count under another's paths, and a stale reading and a
/// failed one both have to read as "not read".
#[derive(Debug, Clone, Copy)]
pub struct LogSize {
    pub(super) id: u32,
    /// `out` plus `err`, or `None` when either path is unreported or either
    /// file could not be read. The pane then draws no size, which is what it
    /// did for the same two cases before the read moved off the draw path.
    pub(super) total_bytes: Option<u64>,
}

/// One lamb reading, and which sheep it was taken for.
#[derive(Debug, Clone)]
pub struct LambReading {
    pub(super) id: u32,
    pub(super) at: Instant,
    pub(super) walk: LambWalk,
}

/// The sentence for a reply this lookout has never heard of, shared so one
/// failed request reads the same wherever it lands.
pub(super) const UNRECOGNISED_REPLY: &str =
    "the shepherd answered something this lookout does not understand";

/// Why a reply is not the one the asking screen expected, in words an operator
/// can read.
///
/// A daemon-side refusal contributes its own message rather than
/// [`RequestError`]'s `Display` for it, which renders the code as a Rust
/// identifier.
pub(super) fn failed_reply(result: Result<Response, RequestError>) -> String {
    match result {
        Ok(_) => UNRECOGNISED_REPLY.to_owned(),
        Err(RequestError::Rpc(err)) => err.message,
        Err(other) => other.to_string(),
    }
}

/// A short line the status bar shows instead of the key hints, cleared by the
/// next keypress.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
    pub(super) text: String,
    /// True for a refusal or a damage report: the status bar picks
    /// [`Palette::refusal`] over [`Palette::attention`].
    pub(super) grave: bool,
}

impl Notice {
    /// A refusal or a damage report: the status bar paints it in
    /// [`Palette::refusal`].
    #[must_use]
    pub(super) fn grave(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            grave: true,
        }
    }

    /// An informational line: the status bar paints it in
    /// [`Palette::attention`].
    #[must_use]
    pub(super) fn info(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            grave: false,
        }
    }

    /// Whether this notice is a refusal or a damage report rather than an
    /// informational one.
    #[must_use]
    pub fn is_grave(&self) -> bool {
        self.grave
    }
}

impl fmt::Display for Notice {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.text)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use shep_core::protocol::{RpcError, RpcErrorCode};

    #[test]
    fn notice_grave_and_info_set_the_flag_and_keep_the_text() {
        let grave = Notice::grave("refused: the control gate is closed");
        assert!(grave.is_grave());
        assert_eq!(grave.text, "refused: the control gate is closed");
        assert_eq!(grave.to_string(), "refused: the control gate is closed");

        let info = Notice::info("restart scheduled for api");
        assert!(!info.is_grave());
        assert_eq!(info.text, "restart scheduled for api");
        assert_eq!(info.to_string(), "restart scheduled for api");
    }

    #[test]
    fn failed_reply_names_each_of_its_three_cases_apart() {
        let unrecognised = failed_reply(Ok(Response::Pong));
        let daemon = failed_reply(Err(RequestError::Rpc(RpcError {
            code: RpcErrorCode::NotFound,
            message: "no sheep named web".into(),
            daemon_version: None,
        })));
        let transport = failed_reply(Err(RequestError::Timeout {
            after: Duration::from_secs(3),
        }));

        assert_eq!(unrecognised, UNRECOGNISED_REPLY);
        assert_eq!(daemon, "no sheep named web");
        assert_eq!(transport, "no reply within 3s");
    }
}