shep-deploy 0.1.0

A deploy dog for shep: watches a git branch, builds a release, swaps to it, and rolls back if it does not come up
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
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
//! `shep-deploy`: a deploy dog for shep.
//!
//! Watches a git branch, builds a release, swaps to it, and rolls back if it
//! does not come up. This file is the binary entry; the deploy sequence
//! itself lives in [`deploy`], and the layout it works over in [`paths`].
//!
//! # Two invocation modes
//!
//! Supervised, the dog is spawned with no argv at all and one environment
//! entry, `SHEP_HOME` - that is the dog contract, and it is why
//! [`daemon::adopted_name`] exists. That mode runs [`poll::run`], which
//! deploys every `watch = "auto"` target whose branch has moved, once an
//! interval, until the process is asked to stop.
//!
//! Run directly, it takes a verb:
//!
//! ```text
//! shep-deploy deploy <sheep>
//! shep-deploy deploy <sheep> --watch auto|manual
//! shep-deploy setup <sheep>
//! shep-deploy survey
//! ```
//!
//! `--watch` changes one setting and returns without deploying. See
//! [`deploy::set_watch`]. `survey` reports where every registered sheep
//! stands and touches nothing; see [`survey::survey`]. `setup` takes a
//! sheep over: it builds the deploy tree and its first release, then
//! re-registers the sheep against `current` and removes the instances it
//! replaced. See [`optin::prepare`] and [`optin::cut_over`] - and note that
//! it is the one deploy that may have downtime, and the one that is not
//! verified against the app's readiness probe.

#![forbid(unsafe_code)]

#[cfg(not(unix))]
compile_error!(
    "shep-deploy is Unix only. This is deliberate rather than an oversight: the deploy model is \
     rename(2) over a symlink, the build's privilege drop is a uid and a gid, and both are Unix \
     concepts this crate uses directly rather than through a portability layer. Windows support \
     is planned and will be scoped separately."
);

mod build;
mod config;
mod daemon;
mod deploy;
mod error;
mod flockfile;
mod git;
mod optin;
mod paths;
mod poll;
mod restore;
mod retention;
mod roll;
mod shared;
mod smit;
mod state;
mod survey;
mod swap;
mod verify;

use std::path::PathBuf;
use std::process::ExitCode;

use shep_client::Client;
use shep_client::shep_core::paths::ShepPaths;
use tokio::signal::unix::{Signal, SignalKind, signal};

use crate::daemon::Live;
use crate::deploy::Outcome;
use crate::error::Error;
use crate::paths::Tree;
use crate::state::{State, Watch};

/// What a direct invocation accepts. Printed on anything else.
const USAGE: &str = "\
usage: shep-deploy <verb> [args]

  deploy <sheep> [--watch auto|manual]   deploy one sheep, or set how it is watched
  setup <sheep>                          take a sheep over
  survey                                 report where every registered sheep stands
  on-remove                              lifecycle hook; shep runs this itself

Adopted as `deploy`, the same verbs run as `shep deploy <verb> [args]`, and
`shep deploy <sheep>` deploys one sheep directly. A sheep whose name is one of
the verbs above is reached with the verb spelled out: `shep deploy deploy survey`.";

/// The exit code for a deploy that was rolled back.
///
/// shep's own taxonomy (`docs/specs/shep-v1.md` section 9) runs from 0 to 11
/// and this is the next free number, claimed rather than invented: a
/// rollback is a cause shep has no code for, and every cause this dog
/// shares with shep uses shep's number for it. A script must be able to
/// tell three outcomes apart, deployed, cleanly reverted, and broke, and
/// two of those were the same code until Rin ruled otherwise.
const ROLLED_BACK: u8 = 12;

/// A cutover that landed and then could not tidy up: the sheep is live on the
/// new release, and something after the swap failed.
///
/// Its own code rather than the generic 1, because a script has to tell three
/// outcomes apart: it worked, it worked and needs tidying, it failed. The
/// poll loop is why that matters. Unattended, a generic failure here reads as
/// a deploy that did not land, and it would retry one that did.
const STRANDED: u8 = 13;

/// What a parsed argv means to do.
///
/// Split out of `main` so the routing decision - which pattern wins when
/// several could match - is testable on its own. A match that both decides
/// and acts can only be exercised by actually running the binary.
#[derive(Debug, PartialEq, Eq)]
enum Route<'a> {
    /// No argv at all: the supervised poll loop, per the dog contract.
    Poll,
    /// shep's on-remove lifecycle hook: put every sheep back.
    OnRemove,
    /// Report where every registered sheep stands.
    Survey,
    /// Deploy the named sheep.
    Deploy(&'a str),
    /// Take the named sheep over, up to and including the cutover.
    Setup(&'a str),
    /// Set the named sheep's watch mode.
    Watch { sheep: &'a str, mode: &'a str },
    /// Nothing above matched; print [`USAGE`] and exit on the usage code.
    Usage,
}

/// Decides what an argv means, without acting on it.
///
/// Verb forms are matched before the passthrough arms, deliberately: shep's
/// dog passthrough strips this dog's own name, so `shep deploy koji` and
/// `shep-deploy koji` both arrive as `["koji"]`, indistinguishable from a
/// sheep whose name really is a verb. Checking verbs first means a sheep
/// named `survey` needs the explicit form, `shep deploy deploy survey`,
/// which is the escape hatch [`USAGE`] documents rather than a silent trap.
fn route<'a>(args: &[&'a str]) -> Route<'a> {
    match args {
        [] => Route::Poll,
        ["on-remove"] => Route::OnRemove,
        ["survey"] => Route::Survey,
        ["setup", sheep] => Route::Setup(sheep),
        ["deploy", sheep] => Route::Deploy(sheep),
        ["deploy", sheep, "--watch", mode] => Route::Watch { sheep, mode },
        // Reached only through `shep deploy <sheep>`: the passthrough
        // shipped in shep 0.1.1 and strips the dog's own name, so the
        // flagship command arrives as a bare sheep name with no verb.
        // Last, so a verb always wins.
        [sheep] => Route::Deploy(sheep),
        [sheep, "--watch", mode] => Route::Watch { sheep, mode },
        _ => Route::Usage,
    }
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode {
    let args: Vec<String> = std::env::args().skip(1).collect();
    let args: Vec<&str> = args.iter().map(String::as_str).collect();

    let outcome = match route(&args) {
        Route::Poll => poll_forever().await,
        // Its own connection, matching every sibling verb, rather than
        // reaching for a `daemon` that does not exist in this scope. It
        // returns an `ExitCode` directly, not a `Result<u8, Error>`, because
        // it never fails outward - see `on_remove`'s own doc.
        Route::OnRemove => return on_remove().await,
        Route::Survey => survey_once().await,
        Route::Deploy(sheep) => deploy_once(sheep).await,
        Route::Setup(sheep) => setup_once(sheep).await,
        Route::Watch { sheep, mode } => set_watch(sheep, mode),
        Route::Usage => {
            eprintln!("{USAGE}");
            return ExitCode::from(2);
        }
    };

    match outcome {
        Ok(code) => ExitCode::from(code),
        Err(err) => {
            eprintln!("shep-deploy: {err}");
            ExitCode::from(code_for(&err))
        }
    }
}

/// The supervised mode: connect, read this dog's own config section, and
/// poll until the process is asked to stop.
///
/// Answers 0 for a stop that was asked for, which is the only way out that
/// is not an error. [`poll::run`] itself never returns.
///
/// # Errors
/// [`Error::Io`] if `$SHEP_HOME` cannot be resolved, [`Error::Connect`] if
/// the shepherd's socket cannot be reached, and whatever
/// [`config::read`] returns - a `[dog.<name>]` section that cannot be
/// parsed stops the dog here rather than being ignored, because a dog
/// running on defaults it was not asked for looks exactly like one
/// honouring the config.
///
/// A target's own failure is NOT one of these. It is reported and the loop
/// carries on to the next target; see [`poll::run`].
async fn poll_forever() -> Result<u8, Error> {
    let home = shep_home()?;
    // First, and before anything is awaited. See `Stop`.
    let mut stop = Stop::install();

    let client = Client::connect(&socket()?).await?;
    let daemon = Live::new(client);
    let config = config::read(&daemon).await?;

    tokio::select! {
        result = poll::run(&daemon, &home, config) => result.map(|()| 0),
        () = stop.arrives() => {
            println!("shep-deploy: stopping");
            Ok(0)
        }
    }
}

/// The two signals that mean stop, registered.
///
/// `SIGTERM` is what shep sends a dog it is stopping; `SIGINT` is what a
/// terminal sends somebody who started the dog by hand to watch it.
///
/// # Why this is a type, and why it is installed by a plain function
///
/// [`signal`] inside an `async fn` does not run when the future is created.
/// It runs when the future is first polled, and `tokio::select!` polls its
/// branches in an order that is randomised per process, so a handler
/// installed that way does not exist yet on about half of starts. The
/// window is the first tick, which opens with a `git fetch` - and a
/// `SIGTERM` arriving in it kills the process on the signal's default
/// disposition, with no message and nothing else in this file running.
///
/// A non-async `install` cannot be lazy, so the handlers exist before the
/// loop is polled at all.
struct Stop {
    /// The `SIGTERM` stream, or `None` if it could not be installed.
    term: Option<Signal>,
    /// The `SIGINT` stream, on the same terms.
    interrupt: Option<Signal>,
}

impl Stop {
    /// Installs both handlers now.
    fn install() -> Self {
        Self {
            term: listen(SignalKind::terminate()),
            interrupt: listen(SignalKind::interrupt()),
        }
    }

    /// Resolves when either signal arrives, and never otherwise.
    ///
    /// A stream that is absent or closed is not a request to stop, and
    /// returning for one would print "stopping" for a stop nobody asked
    /// for. The signal keeps its default disposition in that case, so a
    /// `SIGTERM` still ends the process - without the tidy message.
    ///
    /// # What a stop does NOT interrupt
    ///
    /// Not a tick boundary: cancelling [`poll::run`] can land inside a
    /// deploy, which is acceptable and documented there. It IS deferred
    /// while a `git` call is in flight, because those run through blocking
    /// `std::process::Command` on a current-thread runtime, so nothing else
    /// is polled until the child exits - and a fetch against a host that is
    /// not answering is not a bounded wait.
    async fn arrives(&mut self) {
        let asked = match (&mut self.term, &mut self.interrupt) {
            (Some(term), Some(interrupt)) => tokio::select! {
                arrived = term.recv() => arrived,
                arrived = interrupt.recv() => arrived,
            },
            (Some(only), None) | (None, Some(only)) => only.recv().await,
            (None, None) => None,
        };

        if asked.is_none() {
            core::future::pending().await
        }
    }
}

/// One signal handler, or `None` and a complaint if it cannot be installed.
///
/// Registering fails only on a runtime with no I/O driver, which
/// `#[tokio::main]` never builds. It is reported rather than fatal because
/// a dog that cannot catch `SIGTERM` still deploys perfectly well; it just
/// dies less tidily.
fn listen(kind: SignalKind) -> Option<Signal> {
    match signal(kind) {
        Ok(stream) => Some(stream),
        Err(err) => {
            eprintln!("shep-deploy: cannot listen for {kind:?}: {err}");
            None
        }
    }
}

/// The exit code for a run that finished, reporting what it finished as.
const fn code_for_outcome(outcome: &Outcome) -> u8 {
    match outcome {
        Outcome::UpToDate | Outcome::Deployed { .. } => 0,
        Outcome::RolledBack { .. } => ROLLED_BACK,
    }
}

/// The exit code for a run that failed.
///
/// The first two arms are this dog's own numbers, for outcomes shep has no
/// code for. Every arm after them is shep's own number for the same cause, so
/// an operator reading a dog's status does not have to learn a second
/// vocabulary. Anything with no more specific cause is 1, which is shep's
/// rule as well.
fn code_for(err: &Error) -> u8 {
    match err {
        Error::RolledBack { .. } => ROLLED_BACK,
        Error::Stranded { .. } => STRANDED,
        // The same number as any other configuration refusal: it is one,
        // from outside. What its own variant buys is inside - see the
        // variant's doc, and `crate::poll::worth_saying`.
        Error::Config(_) | Error::NotCutOver { .. } => 4,
        Error::Connect(_) => 5,
        _ => 1,
    }
}

/// One deploy of `sheep`, reporting what it did.
///
/// # Errors
/// Whatever [`deploy::deploy`] returns, plus [`Error::Io`] if the target's
/// `deploy.toml` cannot be read and [`Error::Connect`] if the shepherd's
/// socket cannot be reached.
async fn deploy_once(sheep: &str) -> Result<u8, Error> {
    let tree = Tree::for_sheep(&shep_home()?, sheep);
    let mut state = State::read(&tree.state_file())?;

    let client = Client::connect(&socket()?).await?;
    let daemon = Live::new(client);

    let keep = config::read(&daemon).await?.retention;
    let outcome = deploy::deploy(&daemon, &tree, &mut state, keep).await?;
    match &outcome {
        Outcome::UpToDate => println!("{sheep} is up to date at {}", deployed(&state)),
        Outcome::Deployed { sha } => println!("{sheep} deployed {sha}"),
        Outcome::RolledBack { to, why } => println!("{sheep} rolled back to {to}: {why}"),
    }

    Ok(code_for_outcome(&outcome))
}

/// Takes `sheep` over: builds its deploy tree and first release, then cuts
/// it over to `current`.
///
/// Prints where the sheep now deploys from, because that path is the one
/// thing an operator has no other way to learn - nothing in `shep flock`
/// names it, and it is where every later release lands.
///
/// # Errors
/// [`Error::Io`] if `$SHEP_HOME` cannot be resolved, [`Error::Connect`] if
/// the shepherd's socket cannot be reached, and whatever
/// [`optin::prepare`] or [`optin::cut_over`] return. [`Error::Stranded`] is
/// the one that is returned AFTER a success is printed: the cutover landed
/// and the instances it replaced did not all go.
async fn setup_once(sheep: &str) -> Result<u8, Error> {
    let client = Client::connect(&socket()?).await?;
    let daemon = Live::new(client);

    let prepared = optin::prepare(&daemon, &shep_home()?, sheep).await?;
    // Read before `cut_over` consumes `prepared`.
    let current = prepared.tree.current();

    match optin::cut_over(&daemon, prepared).await {
        Ok(sha) => {
            println!("{sheep} now deploys from {}, at {sha}", current.display());
            Ok(0)
        }
        // The cutover landed and only the cleanup did not, so the operator
        // still needs the path - it is the one thing this command tells
        // them that nothing else will - and then the error says what is
        // left to remove by hand.
        Err(err @ Error::Stranded { .. }) => {
            println!("{sheep} now deploys from {}", current.display());
            Err(err)
        }
        Err(err) => Err(err),
    }
}

/// Reports where every registered sheep stands, and touches nothing.
///
/// # Errors
/// [`Error::Io`] if `$SHEP_HOME` cannot be resolved, and whatever
/// [`survey::survey`] returns.
async fn survey_once() -> Result<u8, Error> {
    let client = Client::connect(&socket()?).await?;
    let daemon = Live::new(client);

    print!("{}", survey::survey(&daemon, &shep_home()?).await?);
    Ok(0)
}

/// The on-remove hook. shep runs this argv before forgetting the dog, under
/// a timeout, and proceeds regardless of the outcome.
///
/// ALWAYS exits 0, including when a sheep could not be restored and
/// including when the shepherd cannot be reached at all. An operator asking
/// to remove something is entitled to have it removed, and a nonzero exit
/// here would be a dog arguing about its own uninstallation. Failures are
/// named in the report instead, which is the output shep pipes to them and
/// the only thing they see about any of this.
async fn on_remove() -> ExitCode {
    let Ok(home) = shep_home() else {
        return ExitCode::SUCCESS;
    };
    // Through `socket()` rather than a second `home.join(...)`: joining by
    // hand here was a second place that knew the control socket's layout,
    // and a change to it would have had to land in both without anything
    // catching the drift.
    let Ok(socket) = socket() else {
        return ExitCode::SUCCESS;
    };
    match Client::connect(&socket).await {
        Ok(client) => {
            let daemon = Live::new(client);
            print!("{}", restore::report(&restore::all(&daemon, &home).await));
        }
        // Nothing was restored and nothing was broken. Said plainly,
        // because silence here is indistinguishable from success.
        Err(err) => println!(
            "no sheep were restored: the shepherd could not be reached ({err}). Any sheep this \
             dog took over is still running from its deploy tree under {}.",
            home.join("deploy").display()
        ),
    }
    ExitCode::SUCCESS
}

/// Sets `sheep`'s watch mode and returns, without deploying.
///
/// # Errors
/// [`Error::Config`] if `mode` is neither `auto` nor `manual`, or if `auto`
/// was asked for on a tree the cutover never landed on - see
/// [`deploy::set_watch`]. [`Error::Io`] if `deploy.toml` cannot be read or
/// written.
fn set_watch(sheep: &str, mode: &str) -> Result<u8, Error> {
    let watch = match mode {
        "auto" => Watch::Auto,
        "manual" => Watch::Manual,
        other => {
            return Err(Error::Config(format!(
                "--watch takes auto or manual, not {other:?}"
            )));
        }
    };

    let tree = Tree::for_sheep(&shep_home()?, sheep);
    let mut state = State::read(&tree.state_file())?;
    let was = state.watch;

    deploy::set_watch(&tree, &mut state, watch)?;

    if was == watch {
        println!("{sheep} was already watch = {}", named(watch));
    } else {
        println!(
            "{sheep} watch: {} -> {}, still deployed at {}",
            named(was),
            named(watch),
            deployed(&state)
        );
    }

    Ok(0)
}

/// `$SHEP_HOME`, absolute.
///
/// Absolute at the point of reading, deliberately: every path this crate
/// builds is joined onto this one, and [`crate::swap::point_at`] writes some
/// of them into symlink targets. A symlink target is resolved against the
/// symlink's own directory rather than this process's working directory, so
/// a relative `SHEP_HOME` would produce links that dangle silently -
/// exactly the failure [`crate::shared::link_into`] canonicalises to avoid,
/// one module over. [`std::path::absolute`] rather than `canonicalize`
/// because this may run before the tree exists, and because resolving
/// through symlinks in `$SHEP_HOME` itself is not this dog's business.
///
/// # Errors
/// [`Error::Io`] if the path cannot be made absolute, which needs the
/// current directory to be readable.
fn shep_home() -> Result<PathBuf, Error> {
    let home = std::env::home_dir().unwrap_or_default();
    let resolved = ShepPaths::resolve(&|key| std::env::var(key).ok(), &home).home;

    std::path::absolute(&resolved).map_err(|source| Error::Io {
        path: resolved,
        source,
    })
}

/// The shepherd's control socket, from the same layout as [`shep_home`].
///
/// # Errors
/// As [`shep_home`].
fn socket() -> Result<PathBuf, Error> {
    Ok(shep_home()?.join("run").join("shep.sock"))
}

/// The sha a target is deployed at, for a message.
fn deployed(state: &State) -> &str {
    state.deployed.as_deref().unwrap_or("nothing yet")
}

/// A [`Watch`] as an operator spells it on the command line.
const fn named(watch: Watch) -> &'static str {
    match watch {
        Watch::Auto => "auto",
        Watch::Manual => "manual",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// fails if a rolled-back deploy stops being distinguishable from a
    /// hard failure by exit status alone. A script running
    /// `shep deploy web && notify` has three outcomes to tell apart:
    /// deployed, rejected and cleanly reverted, and broke. Collapsing the
    /// middle one into either neighbour is what makes "reports a success it
    /// did not achieve" possible, which is the species of every serious
    /// finding in the engine plan.
    #[test]
    fn a_rollback_has_its_own_code_distinct_from_failure_and_success() {
        let rolled_back = Error::RolledBack {
            to: "old-sha".to_owned(),
            source: Box::new(Error::Build { status: Some(1) }),
        };
        assert_eq!(code_for(&rolled_back), ROLLED_BACK);
        assert_ne!(ROLLED_BACK, 0);
        assert_ne!(
            code_for(&rolled_back),
            code_for(&Error::Build { status: Some(1) })
        );
    }

    /// fails if a cutover that landed and then could not tidy up is reported
    /// as an ordinary failure. The sheep IS live on the new release; only the
    /// cleanup after the swap did not finish. The poll loop is why the
    /// distinction has to survive into the exit status: unattended, a generic
    /// failure here reads as a deploy that never landed, and it would redeploy
    /// one that did.
    #[test]
    fn a_stranded_cutover_is_neither_a_success_nor_an_ordinary_failure() {
        let stranded = Error::Stranded {
            sheep: "web".to_owned(),
            sha: "abc1234".to_owned(),
            ids: vec![3],
        };
        assert_eq!(code_for(&stranded), STRANDED);
        assert_ne!(STRANDED, 0);
        assert_ne!(
            code_for(&stranded),
            code_for(&Error::Build { status: Some(1) })
        );
        assert_ne!(code_for(&stranded), ROLLED_BACK);
    }

    /// fails if this dog stops joining shep's own exit-code taxonomy and
    /// starts inventing numbers. These four are shep's, from
    /// docs/specs/shep-v1.md section 9, and an operator who has learned that
    /// 5 means "no daemon answered" should not have to learn a second
    /// meaning for it because a dog chose differently.
    #[test]
    fn the_shared_causes_use_sheps_own_numbers() {
        assert_eq!(code_for(&Error::Config("bad".to_owned())), 4);
        assert_eq!(code_for(&Error::Protocol("odd".to_owned())), 1);
        assert_eq!(
            code_for(&Error::Git {
                command: "git fetch".to_owned(),
                status: Some(128),
                stderr: String::new(),
            }),
            1
        );
        assert_eq!(code_for(&Error::Build { status: Some(3) }), 1);
        assert_eq!(
            code_for(&Error::Connect(shep_client::ConnectError::HandshakeClosed)),
            5
        );
        // A tree the cutover never landed on is a configuration problem
        // like any other from outside, whatever the loop makes of it
        // inside: giving it a variant of its own must not give it a number
        // of its own.
        assert_eq!(
            code_for(&Error::NotCutOver {
                sheep: "web".to_owned(),
                tree: PathBuf::from("/srv/shep/deploy/web"),
            }),
            4
        );
    }

    /// fails if a rollback that happened on the ORDINARY path stops being
    /// reported. `Outcome::RolledBack` is the common trigger, a verify that
    /// timed out, and `Error::RolledBack` is the rarer one where something
    /// failed after the reload. Both mean the requested deploy did not
    /// happen, so both take the same code; only this one is reached through
    /// `Ok`.
    #[test]
    fn the_ok_rollback_path_reports_the_same_code() {
        let outcome = Outcome::RolledBack {
            to: "old-sha".to_owned(),
            why: "it did not come up".to_owned(),
        };
        assert_eq!(code_for_outcome(&outcome), ROLLED_BACK);
        assert_eq!(code_for_outcome(&Outcome::UpToDate), 0);
        assert_eq!(
            code_for_outcome(&Outcome::Deployed {
                sha: "new".to_owned()
            }),
            0
        );
    }

    /// fails if a bare sheep name stops routing to a deploy, or if it
    /// starts shadowing a verb. `shep deploy koji` is the flagship command
    /// and arrives here as `["koji"]`, with no verb, because the
    /// passthrough strips the dog's own name.
    #[test]
    fn a_bare_name_is_a_deploy_and_a_verb_still_wins() {
        assert_eq!(route(&["koji"]), Route::Deploy("koji"));
        assert_eq!(route(&["deploy", "koji"]), Route::Deploy("koji"));
        assert_eq!(route(&["survey"]), Route::Survey);
        assert_eq!(route(&["setup", "koji"]), Route::Setup("koji"));
        // The escape hatch for a sheep whose name is a verb.
        assert_eq!(route(&["deploy", "survey"]), Route::Deploy("survey"));
        assert_eq!(route(&[]), Route::Poll);
    }

    /// fails if the stop handlers stop being installed eagerly. An
    /// `async fn` that calls `signal()` registers on its first poll, not on
    /// creation, and `select!` polls in a randomised order - so lazily
    /// installed handlers do not exist during the first tick on about half
    /// of starts, and that tick opens with a `git fetch`. A `SIGTERM` in
    /// that window kills the dog on the default disposition.
    ///
    /// `install` being a plain function is what makes it eager; this is
    /// what makes it work.
    #[tokio::test]
    async fn both_stop_handlers_are_installed_up_front() {
        let stop = Stop::install();
        assert!(stop.term.is_some(), "SIGTERM");
        assert!(stop.interrupt.is_some(), "SIGINT");
    }

    /// fails if `on-remove` stops routing to its own hook, or starts being
    /// swallowed by the bare-name catch-all - a sheep really could be named
    /// `on-remove`, and it gets the same escape hatch every other verb
    /// does.
    #[test]
    fn on_remove_routes_to_its_own_hook() {
        assert_eq!(route(&["on-remove"]), Route::OnRemove);
        assert_eq!(route(&["deploy", "on-remove"]), Route::Deploy("on-remove"));
    }
}