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//! `--plugin-dir <dir>` (repeatable) additionally resolves entries from
12//! dynamic-library plugins compiled against the same toolchain; a change
13//! to a library in those directories hot-restarts the worker so the new
14//! build is loaded by a fresh process.
15//!
16//! Plugins reach the process controls through the `worker` service
17//! ([`worker::WorkerHandle`]): `restart()` maps to a full worker reload,
18//! and `shutdown()` stops the whole application.
19
20#![deny(unsafe_code)]
21#![warn(missing_docs)]
22
23pub mod dotenv;
24pub mod worker;
25
26use std::path::PathBuf;
27use std::sync::Arc;
28use std::sync::atomic::{AtomicBool, Ordering};
29
30/// What the supervisor does after a worker exits.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Action {
33    /// Spawn a fresh worker (hot restart).
34    Restart,
35    /// Stop supervising.
36    Stop,
37}
38
39/// Decide whether to restart the worker after it exited with `exit_code`.
40///
41/// Only exit code [`worker::EXIT_RESTART`] (51) restarts, and never once
42/// the daemon itself received a shutdown signal.
43pub fn supervisor_action(exit_code: Option<i32>, shutdown: bool) -> Action {
44    if shutdown || exit_code != Some(worker::EXIT_RESTART) {
45        Action::Stop
46    } else {
47        Action::Restart
48    }
49}
50
51/// Parsed command line.
52#[derive(Debug, PartialEq, Eq)]
53pub struct Options {
54    /// Entry config file.
55    pub config: PathBuf,
56    /// Directories searched for dynamic-library plugins (repeatable).
57    pub plugin_dirs: Vec<PathBuf>,
58    /// Run as the daemon's worker process instead of supervising.
59    pub worker: bool,
60}
61
62/// Parse `cordis` arguments (after the binary name).
63pub fn parse_args(args: &[String]) -> Result<Options, String> {
64    let Some(command) = args.first() else {
65        return Err(usage());
66    };
67    if command != "run" {
68        return Err(format!("unknown command `{command}`\n\n{}", usage()));
69    }
70    let mut config = None;
71    let mut plugin_dirs = Vec::new();
72    let mut worker = false;
73    let mut rest = args[1..].iter();
74    while let Some(arg) = rest.next() {
75        match arg.as_str() {
76            "--worker" | "-w" => worker = true,
77            "--help" | "-h" => return Err(usage()),
78            "--plugin-dir" => {
79                let Some(value) = rest.next() else {
80                    return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
81                };
82                plugin_dirs.push(PathBuf::from(value));
83            }
84            other if other.starts_with("--plugin-dir=") => {
85                let value = other.strip_prefix("--plugin-dir=").unwrap();
86                if value.is_empty() {
87                    return Err(format!("--plugin-dir requires a directory\n\n{}", usage()));
88                }
89                plugin_dirs.push(PathBuf::from(value));
90            }
91            other if other.starts_with('-') => {
92                return Err(format!("unknown flag `{other}`\n\n{}", usage()));
93            }
94            other => {
95                if config.replace(PathBuf::from(other)).is_some() {
96                    return Err(format!(
97                        "unexpected extra argument `{other}`\n\n{}",
98                        usage()
99                    ));
100                }
101            }
102        }
103    }
104    match config {
105        Some(config) => Ok(Options {
106            config,
107            plugin_dirs,
108            worker,
109        }),
110        None => Err(format!("missing config file\n\n{}", usage())),
111    }
112}
113
114fn usage() -> String {
115    "usage: cordis run <config.yml> [--plugin-dir <dir>]... [--worker]
116
117  run             start the loader from an entry config file
118  --plugin-dir    also resolve plugins from dynamic libraries in <dir>
119                  (repeatable); library changes there hot-restart the worker
120  --worker        internal: run as the daemon's worker process
121
122Worker exit codes: 51 = hot restart, 52 = quit."
123        .to_owned()
124}
125
126/// Entry point used by the `cordis` binary: parse arguments, load dotenv,
127/// then supervise or run as the worker.
128pub fn run<I, S>(args: I) -> i32
129where
130    I: IntoIterator<Item = S>,
131    S: Into<String>,
132{
133    let args: Vec<String> = args.into_iter().map(Into::into).collect();
134    let options = match parse_args(&args) {
135        Ok(options) => options,
136        Err(message) => {
137            eprintln!("{message}");
138            return 2;
139        }
140    };
141    if let Ok(dir) = std::env::current_dir() {
142        dotenv::load(&dir);
143    }
144    if options.worker {
145        worker::run(&options.config, &options.plugin_dirs);
146    }
147    supervise(&options.config, &options.plugin_dirs)
148}
149
150/// Supervise worker subprocesses until they quit or a signal arrives.
151fn supervise(config: &std::path::Path, plugin_dirs: &[PathBuf]) -> i32 {
152    let shutdown = Arc::new(AtomicBool::new(false));
153    let signal_flag = Arc::clone(&shutdown);
154    if ctrlc::set_handler(move || {
155        eprintln!("cordis: shutdown requested");
156        signal_flag.store(true, Ordering::SeqCst);
157    })
158    .is_err()
159    {
160        eprintln!("cordis: could not install signal handlers");
161    }
162
163    let exe = match std::env::current_exe() {
164        Ok(exe) => exe,
165        Err(error) => {
166            eprintln!("cordis: cannot resolve own executable: {error}");
167            return 1;
168        }
169    };
170    loop {
171        if shutdown.load(Ordering::SeqCst) {
172            break;
173        }
174        let mut command = std::process::Command::new(&exe);
175        command.arg("run").arg(config).arg("--worker");
176        for dir in plugin_dirs {
177            command.arg("--plugin-dir").arg(dir);
178        }
179        let mut child = match command.spawn() {
180            Ok(child) => child,
181            Err(error) => {
182                eprintln!("cordis: cannot spawn worker: {error}");
183                return 1;
184            }
185        };
186        let exit_code = child.wait().ok().and_then(|status| status.code());
187        if supervisor_action(exit_code, shutdown.load(Ordering::SeqCst)) == Action::Stop {
188            break;
189        }
190        eprintln!("cordis: worker requested restart, respawning");
191    }
192    0
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn args(list: &[&str]) -> Vec<String> {
200        list.iter().map(ToString::to_string).collect()
201    }
202
203    #[test]
204    fn parses_run_command_and_flags() {
205        assert_eq!(
206            parse_args(&args(&["run", "cordis.yml"])).unwrap(),
207            Options {
208                config: "cordis.yml".into(),
209                plugin_dirs: Vec::new(),
210                worker: false,
211            }
212        );
213        assert_eq!(
214            parse_args(&args(&["run", "cordis.yml", "--worker"])).unwrap(),
215            Options {
216                config: "cordis.yml".into(),
217                plugin_dirs: Vec::new(),
218                worker: true,
219            }
220        );
221    }
222
223    #[test]
224    fn parses_plugin_dirs_in_both_forms() {
225        let options = parse_args(&args(&[
226            "run",
227            "cordis.yml",
228            "--plugin-dir",
229            "a",
230            "--plugin-dir=b",
231        ]))
232        .unwrap();
233        assert_eq!(
234            options.plugin_dirs,
235            [PathBuf::from("a"), PathBuf::from("b")]
236        );
237        assert!(!options.worker);
238    }
239
240    #[test]
241    fn rejects_malformed_plugin_dirs() {
242        assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir"])).is_err());
243        assert!(parse_args(&args(&["run", "cordis.yml", "--plugin-dir="])).is_err());
244    }
245
246    #[test]
247    fn rejects_missing_or_unknown_arguments() {
248        assert!(parse_args(&args(&[])).is_err());
249        assert!(parse_args(&args(&["start", "cordis.yml"])).is_err());
250        assert!(parse_args(&args(&["run"])).is_err());
251        assert!(parse_args(&args(&["run", "a.yml", "b.yml"])).is_err());
252        assert!(parse_args(&args(&["run", "a.yml", "--nope"])).is_err());
253    }
254
255    #[test]
256    fn only_code_51_restarts_and_never_after_shutdown() {
257        assert_eq!(supervisor_action(Some(51), false), Action::Restart);
258        assert_eq!(supervisor_action(Some(51), true), Action::Stop);
259        assert_eq!(supervisor_action(Some(52), false), Action::Stop);
260        assert_eq!(supervisor_action(Some(0), false), Action::Stop);
261        assert_eq!(supervisor_action(None, false), Action::Stop);
262    }
263}