Skip to main content

cordis_cli/
lib.rs

1//! Command-line runner for the [cordis-rs](https://crates.io/crates/cordis-rs)
2//! plugin framework: `cordis run <config.yml>`.
3//!
4//! The process model follows upstream Cordis' NodeLoader: `cordis run`
5//! supervises a worker subprocess running the loader. The worker exits
6//! with code `51` to request a hot restart and `52` to quit; `SIGINT` /
7//! `SIGTERM` dispose the root context gracefully and exit `52`. `.env` and
8//! `.env.local` are loaded (without overriding existing variables) before
9//! the worker boots, and the entry file is watched for hot reload.
10//!
11//! Plugins reach the process controls through the `worker` service
12//! ([`worker::WorkerHandle`]): `restart()` maps to a full worker reload,
13//! and `shutdown()` stops the whole application.
14
15#![deny(unsafe_code)]
16#![warn(missing_docs)]
17
18pub mod dotenv;
19pub mod worker;
20
21use std::path::PathBuf;
22use std::sync::Arc;
23use std::sync::atomic::{AtomicBool, Ordering};
24
25/// What the supervisor does after a worker exits.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Action {
28    /// Spawn a fresh worker (hot restart).
29    Restart,
30    /// Stop supervising.
31    Stop,
32}
33
34/// Decide whether to restart the worker after it exited with `exit_code`.
35///
36/// Only exit code [`worker::EXIT_RESTART`] (51) restarts, and never once
37/// the daemon itself received a shutdown signal.
38pub fn supervisor_action(exit_code: Option<i32>, shutdown: bool) -> Action {
39    if shutdown || exit_code != Some(worker::EXIT_RESTART) {
40        Action::Stop
41    } else {
42        Action::Restart
43    }
44}
45
46/// Parsed command line.
47#[derive(Debug, PartialEq, Eq)]
48pub struct Options {
49    /// Entry config file.
50    pub config: PathBuf,
51    /// Run as the daemon's worker process instead of supervising.
52    pub worker: bool,
53}
54
55/// Parse `cordis` arguments (after the binary name).
56pub fn parse_args(args: &[String]) -> Result<Options, String> {
57    let Some(command) = args.first() else {
58        return Err(usage());
59    };
60    if command != "run" {
61        return Err(format!("unknown command `{command}`\n\n{}", usage()));
62    }
63    let mut config = None;
64    let mut worker = false;
65    for arg in &args[1..] {
66        match arg.as_str() {
67            "--worker" | "-w" => worker = true,
68            "--help" | "-h" => return Err(usage()),
69            other if other.starts_with('-') => {
70                return Err(format!("unknown flag `{other}`\n\n{}", usage()));
71            }
72            other => {
73                if config.replace(PathBuf::from(other)).is_some() {
74                    return Err(format!(
75                        "unexpected extra argument `{other}`\n\n{}",
76                        usage()
77                    ));
78                }
79            }
80        }
81    }
82    match config {
83        Some(config) => Ok(Options { config, worker }),
84        None => Err(format!("missing config file\n\n{}", usage())),
85    }
86}
87
88fn usage() -> String {
89    "usage: cordis run <config.yml> [--worker]
90
91  run        start the loader from an entry config file
92  --worker   internal: run as the daemon's worker process
93
94Worker exit codes: 51 = hot restart, 52 = quit."
95        .to_owned()
96}
97
98/// Entry point used by the `cordis` binary: parse arguments, load dotenv,
99/// then supervise or run as the worker.
100pub fn run<I, S>(args: I) -> i32
101where
102    I: IntoIterator<Item = S>,
103    S: Into<String>,
104{
105    let args: Vec<String> = args.into_iter().map(Into::into).collect();
106    let options = match parse_args(&args) {
107        Ok(options) => options,
108        Err(message) => {
109            eprintln!("{message}");
110            return 2;
111        }
112    };
113    if let Ok(dir) = std::env::current_dir() {
114        dotenv::load(&dir);
115    }
116    if options.worker {
117        worker::run(&options.config);
118    }
119    supervise(&options.config)
120}
121
122/// Supervise worker subprocesses until they quit or a signal arrives.
123fn supervise(config: &std::path::Path) -> i32 {
124    let shutdown = Arc::new(AtomicBool::new(false));
125    let signal_flag = Arc::clone(&shutdown);
126    if ctrlc::set_handler(move || {
127        eprintln!("cordis: shutdown requested");
128        signal_flag.store(true, Ordering::SeqCst);
129    })
130    .is_err()
131    {
132        eprintln!("cordis: could not install signal handlers");
133    }
134
135    let exe = match std::env::current_exe() {
136        Ok(exe) => exe,
137        Err(error) => {
138            eprintln!("cordis: cannot resolve own executable: {error}");
139            return 1;
140        }
141    };
142    loop {
143        if shutdown.load(Ordering::SeqCst) {
144            break;
145        }
146        let child = std::process::Command::new(&exe)
147            .arg("run")
148            .arg(config)
149            .arg("--worker")
150            .spawn();
151        let mut child = match child {
152            Ok(child) => child,
153            Err(error) => {
154                eprintln!("cordis: cannot spawn worker: {error}");
155                return 1;
156            }
157        };
158        let exit_code = child.wait().ok().and_then(|status| status.code());
159        if supervisor_action(exit_code, shutdown.load(Ordering::SeqCst)) == Action::Stop {
160            break;
161        }
162        eprintln!("cordis: worker requested restart, respawning");
163    }
164    0
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn args(list: &[&str]) -> Vec<String> {
172        list.iter().map(ToString::to_string).collect()
173    }
174
175    #[test]
176    fn parses_run_command_and_flags() {
177        assert_eq!(
178            parse_args(&args(&["run", "cordis.yml"])).unwrap(),
179            Options {
180                config: "cordis.yml".into(),
181                worker: false
182            }
183        );
184        assert_eq!(
185            parse_args(&args(&["run", "cordis.yml", "--worker"])).unwrap(),
186            Options {
187                config: "cordis.yml".into(),
188                worker: true
189            }
190        );
191    }
192
193    #[test]
194    fn rejects_missing_or_unknown_arguments() {
195        assert!(parse_args(&args(&[])).is_err());
196        assert!(parse_args(&args(&["start", "cordis.yml"])).is_err());
197        assert!(parse_args(&args(&["run"])).is_err());
198        assert!(parse_args(&args(&["run", "a.yml", "b.yml"])).is_err());
199        assert!(parse_args(&args(&["run", "a.yml", "--nope"])).is_err());
200    }
201
202    #[test]
203    fn only_code_51_restarts_and_never_after_shutdown() {
204        assert_eq!(supervisor_action(Some(51), false), Action::Restart);
205        assert_eq!(supervisor_action(Some(51), true), Action::Stop);
206        assert_eq!(supervisor_action(Some(52), false), Action::Stop);
207        assert_eq!(supervisor_action(Some(0), false), Action::Stop);
208        assert_eq!(supervisor_action(None, false), Action::Stop);
209    }
210}