hc_runner/
lib.rs

1#![warn(clippy::pedantic)]
2
3use std::fmt;
4use std::io::{self, Write};
5use std::process::Command;
6use std::time::Duration;
7
8use reqwest::{Client, Url};
9use tracing::{info, warn};
10
11extern crate config as config_rs;
12
13pub type Result<T> = std::result::Result<T, Error>;
14
15mod config;
16pub use config::Config;
17
18#[derive(thiserror::Error)]
19pub enum Error {
20    #[error(transparent)]
21    Cli(#[from] clap::error::Error),
22
23    /// Logical error in configuration
24    #[error("config error: {0}")]
25    Config(String),
26
27    #[error(transparent)]
28    EnvVar(#[from] std::env::VarError),
29
30    /// Error with configuration file or environment variables
31    #[error("settings error: {0}")]
32    Settings(#[from] config_rs::ConfigError),
33
34    #[error("command exited with empty exit status code")]
35    EmptyExitCode,
36
37    #[error(transparent)]
38    Io(#[from] std::io::Error),
39
40    #[error("join error: {0}")]
41    Join(#[from] tokio::task::JoinError),
42
43    #[error(transparent)]
44    ParseInt(#[from] std::num::ParseIntError),
45
46    #[error(transparent)]
47    ParseFilter(#[from] tracing_subscriber::filter::ParseError),
48
49    #[error(transparent)]
50    ParseUrl(#[from] url::ParseError),
51
52    #[error(transparent)]
53    Reqwest(#[from] reqwest::Error),
54
55    #[error(transparent)]
56    TryFromInt(#[from] std::num::TryFromIntError),
57
58    #[error("unknown hc-runner error")]
59    Unknown,
60
61    #[error(transparent)]
62    Utf8(#[from] std::str::Utf8Error),
63}
64
65impl fmt::Debug for Error {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "{self}")
68    }
69}
70
71fn add_slug(mut url: Url, slug: String) -> Result<Url> {
72    // Calls to `join` will only interpret the last segment of the path as a
73    // directory if it has a trailing slash
74    // https://docs.rs/reqwest/latest/reqwest/struct.Url.html#method.join
75    let path = url.path();
76    if !path.ends_with('/') {
77        url.set_path(&(path.to_string() + "/"));
78    };
79
80    let with_slug = url.join(&(slug + "/"))?;
81    Ok(with_slug)
82}
83
84/// # Errors
85/// Returns the exit code of the command
86#[tracing::instrument]
87pub async fn run(config: Config) -> Result<u8> {
88    let url = add_slug(config.url, config.slug)?;
89    info!("using base url: {}", url);
90
91    let client = Client::builder()
92        .timeout(Duration::from_secs(config.timeout))
93        .build()?;
94
95    // Some commands can be allowed to fail periodically and I only want a
96    // healthchecks notification if there are zero successes in a period of
97    // time. For these, use the `--success-only` flag, which will only update
98    // healthchecks when there is a successful run.
99    let start_req = if config.success_only {
100        None
101    } else {
102        let client = client.clone();
103        let mut url = url.join("start")?;
104        Some(tokio::spawn(async move {
105            url.set_query(Some("create=1"));
106            info!("calling start url {}", url);
107            client.head(url).send().await
108        }))
109    };
110
111    let output = if cfg!(target_os = "macos") {
112        Command::new("/usr/bin/caffeinate")
113            .args(config.command)
114            .output()?
115    } else {
116        let mut args = config.command.iter();
117        let cmd = args
118            .next()
119            .ok_or_else(|| Error::Config("command was empty".into()))?;
120        Command::new(cmd).args(args).output()?
121    };
122
123    let (stdout, stderr) = (output.stdout, output.stderr);
124    io::stdout().write_all(&stdout)?;
125    io::stderr().write_all(&stderr)?;
126
127    let status = output.status;
128    let exit_code = if status.success() {
129        0
130    } else {
131        status.code().ok_or_else(|| Error::EmptyExitCode)?
132    };
133
134    let stderr = std::str::from_utf8(&stderr)?;
135
136    if let Some(req) = start_req {
137        let _ = req.await?;
138    };
139
140    match (config.success_only, exit_code) {
141        (false, _) | (true, 0) => {
142            let res = {
143                let url = url.join(exit_code.to_string().as_ref())?;
144                info!("calling end url {}", url);
145                client.post(url).body(stderr.to_string()).send().await?
146            };
147
148            if !res.status().is_success() {
149                let text = res.text().await?;
150                writeln!(io::stderr(), "failed to update status: {text}")?;
151            };
152        }
153        _ => (),
154    }
155
156    Ok(exit_code.try_into()?)
157}