shep-deploy 0.3.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
670
671
672
673
674
675
676
677
678
679
680
//! `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 two environment
//! entries: `SHEP_HOME`, which every path below is joined onto, and
//! `SHEP_DOG_NAME`, which is what shep registered this process as. 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.
//!
//! The loop starts only when `SHEP_DOG_NAME` is set. A bare run without it is
//! a person at a terminal, and gets usage rather than a second loop beside the
//! adopted dog. Whoever needs the loop outside shep sets `SHEP_DOG_NAME`
//! themselves, which also names the handshake. shep sets it for a bare
//! `shep deploy` typed at a terminal too, and that run polls like the dog.
//!
//! The two modes connect differently. The poll loop connects through a client
//! that survives a daemon handover, because it is the only mode that outlives
//! one, and announces the name shep put in `SHEP_DOG_NAME`; a verb connects
//! through a plain client, announces nothing, and exits. See [`poll_forever`]
//! and [`adopted_as`].
//!
//! Run directly, it takes a verb, or `help` for usage:
//!
//! ```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 exit;
/// Test helpers shared by every module's own `mod tests`; see its own doc for
/// why a binary crate needs this declared here.
#[cfg(test)]
mod fixtures;
mod flockfile;
mod git;
mod lock;
mod optin;
mod paths;
mod poll;
mod restore;
mod retention;
mod roll;
mod route;
mod shared;
mod smit;
mod state;
mod survey;
mod swap;
mod verify;
mod window;

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

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

use crate::daemon::Live;
use crate::deploy::Outcome;
use crate::error::Error;
use crate::exit::{code_for, code_for_outcome};
use crate::paths::Tree;
use crate::route::{Route, USAGE, route};
use crate::state::{State, Watch};

/// How long the runtime waits for blocking work after the main future ends.
///
/// One second. The only blocking work is git and the artifact copy, both on
/// tokio's pool through `shared::off_thread`; a stop that lands during a
/// fetch must not wait the fetch out, which dropping the runtime without a
/// timeout would do. The git child left behind is bounded by `git_timeout`
/// and finishes or dies on its own after the dog has gone.
const DRAIN: std::time::Duration = std::time::Duration::from_secs(1);

fn main() -> ExitCode {
    // Answers shep's two probes and ends the process when this run is one of
    // them: `--version` with the version and the protocol number this binary
    // was compiled against, `--schema` with the JSON Schema of
    // `config::Section`. It returns on every ordinary run.
    //
    // First, before the runtime is built and before anything is opened,
    // because that is the whole point of the call. `shep restart deploy`
    // probes the binary on disk to see whether it has been upgraded under
    // the running process, and a dog that does not recognise `--version`
    // ignores it and starts doing its ordinary job instead - here, a poll
    // loop against the live shepherd, killed a second or two later. This
    // dog builds and cuts over releases, so "runs briefly and is then
    // killed" is not a harmless overlap.
    //
    // `route` never sees either flag as a result, which is also why neither
    // needs an arm there: a leading `-` is not a sheep name, so both used to
    // land on `USAGE` and exit 2, and shep read that as a dog with no
    // protocol and no schema.
    //
    // The name and the version are passed rather than read inside
    // shep-client because `env!` expands where it is written, and there it
    // would report shep-client's own version.
    shep_client::dogs::probe::<config::Section>(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));

    let args: Vec<String> = std::env::args().skip(1).collect();
    let args: Vec<&str> = args.iter().map(String::as_str).collect();

    // Built by hand rather than through `#[tokio::main]`, which drops the
    // runtime without a timeout and therefore waits for every blocking task
    // to finish: see `DRAIN`.
    let runtime = match tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
    {
        Ok(runtime) => runtime,
        Err(err) => {
            eprintln!("shep-deploy: cannot start a runtime: {err}");
            return ExitCode::from(1);
        }
    };
    let code = runtime.block_on(run(&args));
    runtime.shutdown_timeout(DRAIN);
    code
}

/// The whole program past argument parsing, on the runtime `main` built.
async fn run(args: &[&str]) -> ExitCode {
    let dog = adopted_as(&|key| std::env::var(key).ok());
    let outcome = match route(args, dog.is_some()) {
        Route::Poll => match &dog {
            Some(name) => poll_forever(name).await,
            None => return usage(),
        },
        Route::Help => {
            println!("{USAGE}");
            return ExitCode::SUCCESS;
        }
        // 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 => return usage(),
    };

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

/// Prints [`USAGE`] to stderr and answers the usage exit code.
fn usage() -> ExitCode {
    eprintln!("{USAGE}");
    ExitCode::from(2)
}

/// The environment entry shep puts an adopted dog's registered name in.
///
/// Set by the daemon itself when it spawns the dog, alongside `SHEP_HOME`.
/// Its absence is the signal that this process is NOT a supervised dog, and
/// so does not poll; see [`adopted_as`].
const DOG_NAME: &str = "SHEP_DOG_NAME";

/// The supervised mode: connect as `name`, the dog shep registered this
/// process as, read its own config section, and poll until the process is
/// asked to stop.
///
/// Only [`run`] reaches this, and only when shep started the process; a bare
/// run by hand is routed to usage first.
///
/// 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.
///
/// # Why this one connection is not a [`Client`]
///
/// This is the only invocation that outlives the daemon it connected to. A
/// `shep upgrade` hands the socket to a successor process, and a dog is an
/// ordinary child that keeps running through it, so its connection has to be
/// re-established or the dog is alive and mute for as long as it is left up.
/// [`ReconnectingClient`] is the client that does that, and
/// `connect_as_dog` is the only public door to a handshake that names a dog.
/// That name is what the daemon marks as handshook, and what it needs to
/// restart the right dog when a successor refuses one. A dog connecting
/// without it serves every request correctly and is still recorded as
/// silent, restarted once, and then written off as stale.
///
/// The four one-shot verbs stay on [`Client`], deliberately. Each runs for
/// one command and exits, so there is nothing to reconnect for, and a
/// command claiming to be this dog would have the daemon record a handshake
/// for a process about to exit - and restart the real dog if the command's
/// own handshake were refused.
///
/// # Errors
/// [`Error::Io`] if `$SHEP_HOME` cannot be resolved, [`Error::Connect`] if
/// the shepherd's socket cannot be reached, [`Error::Refused`] if a
/// successor daemon refuses this dog's handshake, and whatever
/// [`config::Reader::open`] 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.
///
/// That is the STARTING read. The loop reads the section again every tick,
/// so an operator who edits it does not have to restart the dog, and a
/// later read that fails keeps the last section that parsed instead of
/// ending a deploy that may be in flight. [`config::Reader::refresh`] says
/// why the two answers differ.
///
/// 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(name: &str) -> Result<u8, Error> {
    let home = shep_home()?;
    // First, and before anything is awaited. See `Stop`.
    let mut stop = Stop::install();

    let socket = socket()?;
    let client = ReconnectingClient::connect_as_dog(&socket, name).await?;
    let daemon = Live::dog(client);
    let config = config::Reader::open(&daemon).await?;

    tokio::select! {
        result = poll::run(&daemon, &home, config) => result.map(|()| 0),
        // Cancels `poll::run` the same way a stop does, and can land inside
        // a deploy for the same reason - see `Stop::arrives`. It costs
        // nothing here that carrying on would not cost anyway: past a
        // refusal every request this loop makes fails, so the deploy it
        // interrupts had already stopped being able to finish.
        refusal = refused(&daemon) => Err(refusal),
        () = stop.arrives() => {
            println!("shep-deploy: stopping");
            Ok(0)
        }
    }
}

/// The name shep registered this process under, or `None` when nothing did.
///
/// This is the handshake identity, and also what [`run`] reads as "supervised":
/// it polls only when this is `Some`. shep sets the variable for a bare
/// `shep deploy` typed at a terminal as well as for the dog it starts, so the
/// two are not told apart here. It has exactly one source. A name reaching the daemon is a claim about which dog
/// to restart when a handshake is refused, so a guessed one - the binary's
/// own stem, the config section's key, a default - gets some other dog
/// restarted for this process's problem. No name at all is a complete and
/// honest answer, and it is the right one for somebody running the binary by
/// hand: they are not a supervised dog, and the daemon should not record a
/// handshake for them.
///
/// Blank is treated as absent. An empty `SHEP_DOG_NAME` names no dog, and
/// announcing it would claim an identity the daemon cannot match. Anything
/// else is passed through verbatim rather than trimmed or validated: the
/// daemon looks this up against its own registry, so the only useful value
/// is the exact one it set.
///
/// Takes its lookup rather than reading the environment directly, matching
/// [`resolved`], so the decision is testable without a process-global
/// `set_var`.
fn adopted_as(env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
    env(DOG_NAME).filter(|name| !name.trim().is_empty())
}

/// Resolves when a successor daemon has refused this dog's handshake, and
/// never otherwise.
///
/// A refusal is terminal by design: the supervisor stops, and every later
/// request fails with no chance of recovering. The loop would carry on
/// ticking through that, deploying nothing and reporting each target's
/// failure once, which reads like a broken remote rather than a dog that
/// can no longer talk to its shepherd at all. Ending instead puts the reason
/// in the dog's log once and hands the process back to shep, which knows
/// from the handshake which dog this was and can start it again when it is
/// upgraded.
///
/// Polled rather than awaited, because [`LinkState`] is a snapshot and
/// `shep-client` offers no wakeup for it. [`RECONNECT_MAX_DELAY`] is the
/// supervisor's own ceiling between reconnect attempts, so a refusal is
/// noticed about one attempt after it happens, and the poll costs one
/// read of a lock per five seconds.
async fn refused(daemon: &Live) -> Error {
    loop {
        if let Some(LinkState::Refused {
            daemon_version,
            message,
        }) = daemon.link()
        {
            return Error::Refused {
                daemon_version,
                message,
            };
        }
        tokio::time::sleep(RECONNECT_MAX_DELAY).await;
    }
}

/// 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 used to be
    /// deferred while a `git` call was in flight, because those ran through
    /// blocking `std::process::Command` on the runtime's one thread; they
    /// run on tokio's blocking pool now (`shared::off_thread`), so a stop
    /// is answered during a fetch and `main` gives the pool [`DRAIN`] to
    /// wind down rather than waiting the fetch out.
    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
        }
    }
}

/// 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::command(client);

    let config = config::read(&daemon).await?;
    let outcome = deploy::deploy(&daemon, &tree, &mut state, &config).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::command(client);

    let config = config::read(&daemon).await?;
    let prepared = optin::prepare(&daemon, &shep_home()?, sheep, &config).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::command(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::command(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`, if `auto` was
/// asked for on a tree the cutover never landed on, or if `deploy.toml` does
/// not parse or fails validation - see [`deploy::set_watch`]. [`Error::Io`]
/// if it 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);
    // Both the record before and the record after come back from the write:
    // `set_watch` reads immediately before writing, and a read made here
    // would be the older copy the "was already" line must not be true of.
    let (was, state) = deploy::set_watch(&tree, 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> {
    absolute(resolved().home)
}

/// The shepherd's control socket, from the same layout as [`shep_home`].
///
/// Reads `ShepPaths`'s own `socket` field rather than joining `run/shep.sock`
/// onto the home. Both spellings agree today, and the point is that only one
/// of them is shep's to change: the layout belongs to `shep_core::paths`, and
/// a copy here is a second source of truth that drifts silently the day shep
/// moves the socket.
///
/// # Errors
/// As [`shep_home`].
fn socket() -> Result<PathBuf, Error> {
    absolute(resolved().socket)
}

/// The shep layout, as this process's environment spells it.
fn resolved() -> ShepPaths {
    let home = std::env::home_dir().unwrap_or_default();
    ShepPaths::resolve(&|key| std::env::var(key).ok(), &home)
}

/// One path from [`resolved`], made absolute.
///
/// # Errors
/// [`Error::Io`] if the path cannot be made absolute, which needs the current
/// directory to be readable.
fn absolute(path: PathBuf) -> Result<PathBuf, Error> {
    std::path::absolute(&path).map_err(|source| Error::Io { path, source })
}

/// 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 supervised dog stops announcing the name shep gave it.
    ///
    /// That name is the whole of the bug this function exists for. Without
    /// it in the handshake the daemon never marks the dog handshook: it
    /// connects, serves every request correctly, and is rendered `silent`,
    /// restarted once after five seconds, then written off as stale and
    /// never started again. Everything an operator can see says the dog is
    /// working.
    #[test]
    fn a_supervised_dog_announces_the_name_shep_gave_it() {
        let env = |key: &str| (key == DOG_NAME).then(|| "log-rotate".to_owned());
        assert_eq!(adopted_as(&env).as_deref(), Some("log-rotate"));
    }

    /// fails if a process nobody supervised claims to be a dog anyway.
    ///
    /// No `SHEP_DOG_NAME` means shep did not spawn this, so there is no dog
    /// to be, and the bare command prints usage instead of polling. The name
    /// must never be guessed here - from the binary's stem, from the
    /// `[dog.<name>]` section, from a default - because it is what the daemon
    /// restarts on a refused handshake, and a guess gets some other dog
    /// restarted for a hand run's problem.
    #[test]
    fn a_hand_run_announces_no_name_at_all() {
        assert_eq!(adopted_as(&|_| None), None);
        // Blank names no dog either, and an identity the daemon cannot
        // match is worse than none: it is a claim.
        assert_eq!(adopted_as(&|_| Some(String::new())), None);
        assert_eq!(adopted_as(&|_| Some("   ".to_owned())), None);
    }

    /// fails if the name stops being read from `SHEP_DOG_NAME` specifically.
    /// `SHEP_NAME` and `SHEP_INSTANCE` sit beside it in a dog's environment
    /// and neither is the registered dog name.
    #[test]
    fn only_shep_dog_name_is_read() {
        let env = |key: &str| (key == "SHEP_NAME").then(|| "web".to_owned());
        assert_eq!(adopted_as(&env), None);
    }

    /// 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");
    }
}