lxcrond 0.2.1

cron and entr/inotify server for lxc containers
Documentation

use std::fs::{File, metadata, read_dir};
use std::io::{BufReader, BufRead};

use cmdline_words_parser::StrExt;

use regex::Regex;
use std::borrow::{BorrowMut};
use chrono::{DateTime, Timelike, Datelike, Local};
use std::sync::atomic::{AtomicBool,Ordering};

pub const CRONTAB: &str = "/etc/lxcrontab";
pub const ANY: u8 = 255;
pub const NEVER: u8 = 254;

lazy_static! {
    pub static ref VERBOSE: AtomicBool = AtomicBool::new(false);

    static ref REGEX_FILE: Regex = Regex::new(r"^(/[^\s]+)\s+([a-z][-a-z0-9]+)\s+(.*)$").unwrap();
    static ref REGEX_CRON: Regex = Regex::new(r"^([0-9,\*]+)\s+([0-9,\*]+)\s+([0-9,\*]+)\s+([0-9,\*]+)\s+([0-9,\*]+)\s+([a-z][-a-z0-9]+)\s+(.*)$").unwrap();
    static ref REGEX_CRON_ALIAS: Regex = Regex::new(r"^(@[a-z]+)\s+([a-z][-a-z0-9]+)\s+(.*)$").unwrap();
}

/// Contains code relating to reading configuration files and storing the resulting data in memory.
///
/// N.B. dates are relative to the machines local timezone and its not possible to switch timezones.

/// Specification for when to run a cron
pub struct TimeSpec {
    minute: Vec<u8>,
    hour: Vec<u8>,
    day_of_month: Vec<u8>,
    month: Vec<u8>,
    day_of_week: Vec<u8>,
}

/// Specification fora  job that runs when a file or directory changes.
pub struct FileSpec {
    is_dir: bool,
    path: String,
    last_mod: i64,
}

impl FileSpec{
    pub fn new(path: String) -> FileSpec {
        FileSpec {
            is_dir: false,
            path,
            last_mod: 0
        }
    }
    pub fn path(&self) -> &String {
        &self.path
    }
    pub fn is_dir(&self) -> bool {
        self.is_dir
    }
}

/// One line in the crontab file.
pub struct Job {
    time_spec: Option<TimeSpec>,
    file_spec: Option<FileSpec>,
    user: String,
    command: String,
    args: Vec<String>,
    pub(crate) watch_descriptor: i32,
}

impl Job {

    pub fn new(time_spec: Option<TimeSpec>, file_spec: Option<FileSpec>, user: &str, mut command: String) -> Job {
        let mut cmd: Vec<String> = command.parse_cmdline_words().map(|s| String::from(s)).collect();
        Job {
            time_spec,
            file_spec,
            user: String::from(user),
            command: cmd.remove(0),
            args: cmd,
            watch_descriptor: 0
        }
    }

    pub fn user(&self) -> &String {
        &self.user
    }

    pub fn command(&self) -> &String {
        &self.command
    }

    pub fn args(&self) -> &Vec<String> {
        &self.args
    }

    pub fn file_spec(&self) -> &FileSpec {
        self.file_spec.as_ref().unwrap()
    }

    pub fn set_watch_descriptor(&mut self, wd: i32) {
        self.watch_descriptor = wd;
    }

    /// return `true` if this job is due now or the file spec matches
    /// `now` time in seconds
    pub fn is_due(&self, now: DateTime<Local>) -> bool {
        if self.time_spec.is_some() {
            return self.is_time(now);
        }
        if let Some(file_spec) = &self.file_spec {
            return if file_spec.is_dir {
                self.dir_has_files()
            } else {
                false
            }
        }
        panic!("BUG: job must be file or time type");
    }

    /// panics if not a time type cron job
    ///
    pub fn is_time(&self, now: DateTime<Local>) -> bool {
        let time_spec = self.time_spec.as_ref().unwrap();
        if (time_spec.minute.contains(&ANY) || time_spec.minute.contains(&(now.minute() as u8))) &&
            (time_spec.hour.contains(&ANY) || time_spec.hour.contains( &(now.hour() as u8))) &&
            (time_spec.day_of_month.contains(&ANY) || time_spec.day_of_month.contains(&(now.day() as u8))) &&
            (time_spec.month.contains(&ANY) || time_spec.month.contains(&(now.month() as u8))) &&
            (time_spec.day_of_week.contains(&ANY) || time_spec.day_of_week.contains(&((now.weekday().num_days_from_monday() + 1) as u8) ))
            {
            return true;
        }
        false
    }

    /// Check to see if a file has been created or updated
    /// panics if not a file type cron job
    pub fn file_has_changed(&mut self) -> bool {
        let mut file_spec = self.file_spec.as_mut().unwrap();
        if let Ok(stat) = metadata(&file_spec.path) {
            if let Ok(stat) = stat.modified() {
                let last_mod = stat.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
                if file_spec.last_mod < last_mod {
                    file_spec.last_mod = last_mod;
                    return true;
                }
            }
        }
        false
    }

    /// panics if not a directory type cron job
    pub fn dir_has_files(&self) -> bool {
        let file_spec = self.file_spec.as_ref().unwrap();
        if let Ok(mut paths) = read_dir(&file_spec.path) {
            return paths.borrow_mut().any(|f| f.is_ok());
        }
        false
    }
}

pub struct Config {
    cron_jobs: Vec<Job>,
    file_jobs: Vec<Job>,
}

impl Config {

    pub fn new() -> Config {
        Config::read_config(CRONTAB)
    }

    pub fn new_jobs(cron_jobs: Vec<Job>, file_jobs: Vec<Job>) -> Config {
        Config { cron_jobs, file_jobs }
    }

    pub fn len(&self) -> usize {
        self.cron_jobs.len() + self.file_jobs.len()
    }

    pub fn take_cron_jobs(&mut self) -> Vec<Job> {
        let mut jobs = vec![];
        let mut len = self.cron_jobs.len();
        while len > 0 {
            jobs.push(self.cron_jobs.remove(len - 1));
            len = len - 1;
        }
        jobs
    }

    pub fn take_file_jobs(&mut self) -> Vec<Job> {
        let mut jobs = vec![];
        let mut len = self.file_jobs.len();
        while len > 0 {
            jobs.push(self.file_jobs.remove(len - 1));
            len = len - 1;
        }
        jobs
    }

    pub fn read_config(path: &str) -> Config {

        let file_data = File::open(path).expect(format!("file not found {}", path).as_str());
        let buf = BufReader::new(file_data);

        return Config::read_lines(buf.lines().map(|line| line.unwrap()));
    }

    pub fn read_lines<'a, I>(lines: I) -> Config
        where
            I: Iterator<Item = String>, {

        let mut cfg = Config { cron_jobs: vec![], file_jobs: vec![]};

        lines.for_each(|line| {
                let line_str = line.trim();
                if line_str.is_empty() {
                    return;
                }
                match line_str.chars().next().unwrap() {
                    '#' => {

                    }
                    '/' => {
                        cfg.file_jobs.push(Config::parse_file(line_str));
                    }
                    '@' => {
                        cfg.cron_jobs.push(Config::parse_cron_alias(line_str));
                    }
                    '0'..='9' | '*' => {
                        cfg.cron_jobs.push(Config::parse_cron(line_str));
                    }
                    _ => {}
                }

            });

        if VERBOSE.load(Ordering::Relaxed) {
            println!("loaded {} jobs", cfg.file_jobs.len() + cfg.cron_jobs.len());
        }

        cfg
    }

    ///
    /// parse cron line in the form  "/etc/nginx.conf user action"
    ///
    // TODO lots of error possible here should use ? operator and Result
    fn parse_file(line: &str) -> Job {
        let cap = REGEX_FILE.captures_iter(line).next().unwrap();
        let file_name = String::from(&cap[1]);
        let mut last_mod= 0;
        if let Ok(stat) = metadata(file_name) {
            if let Ok(stat) = stat.modified() {
                last_mod = stat.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
            }
        }

        let mut cmd: Vec<String> = String::from(&cap[3]).parse_cmdline_words().map(|s| String::from(s)).collect();

        Job {
            time_spec: None,
            file_spec: Some(FileSpec {
                is_dir: cap[1].ends_with("/"),
                path: String::from(&cap[1]),
                last_mod,
            }),
            user: String::from(&cap[2]),
            command: cmd.remove(0),
            args: cmd,
            watch_descriptor: -1
        }
    }

    fn parse_cron(line: &str) -> Job {
        let cap = REGEX_CRON.captures_iter(line).next().unwrap();

        let mut cmd: Vec<String> = String::from(&cap[7]).parse_cmdline_words().map(|s| String::from(s)).collect();

        Job {
            time_spec: Some(Config::from_fields(&cap[1],&cap[2],&cap[3],&cap[4],&cap[5])),
            file_spec: None,
            user: String::from(&cap[6]),
            command: cmd.remove(0),
            args: cmd,
            watch_descriptor: -1
        }
    }

    fn parse_cron_alias(line: &str) -> Job {
        let cap = REGEX_CRON_ALIAS.captures_iter(line).next().unwrap();

        let mut cmd: Vec<String> = String::from(&cap[3]).parse_cmdline_words().map(|s| String::from(s)).collect();

        Job {
            time_spec: Some(Config::from_alias(&cap[1])),
            file_spec: None,
            user: String::from(&cap[2]),
            command: cmd.remove(0),
            args: cmd,
            watch_descriptor: -1
        }
    }

    fn from_fields(minute: &str, hour: &str, day_of_month: &str, month: &str, day_of_week: &str) -> TimeSpec {
        TimeSpec {
            minute: Config::parse_field(minute),
            hour: Config::parse_field(hour),
            day_of_month: Config::parse_field(day_of_month),
            month: Config::parse_field(month),
            day_of_week: Config::parse_field(day_of_week),
        }
    }

    fn from_alias(line: &str) -> TimeSpec {
        return match line {
            "@always" | "@minutely" | "@1min" => TimeSpec {
                minute: vec!(ANY),
                hour: vec!(ANY),
                day_of_month: vec!(ANY),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@5mins" => TimeSpec {
                minute: vec!(0,5,10,15,20,25,30,35,40,45,50,55),
                hour: vec!(ANY),
                day_of_month: vec!(ANY),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@10mins" => TimeSpec {
                minute: vec!(0,10,20,30,40,50),
                hour: vec!(ANY),
                day_of_month: vec!(ANY),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@15mins" => TimeSpec {
                minute: vec!(0,15,30,45),
                hour: vec!(ANY),
                day_of_month: vec!(ANY),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@hourly" => TimeSpec {
                minute: vec!(0),
                hour: vec!(ANY),
                day_of_month: vec!(ANY),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@daily" | "@midnight" => TimeSpec {
                minute: vec!(0),
                hour: vec!(0),
                day_of_month: vec!(ANY),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@mondays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(1),
            },
            "@tuesdays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(2),
            },
            "@wednesdays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(3),
            },
            "@thursdays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(4),
            },
            "@fridays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(5),
            },
            "@saturdays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(6),
            },
            "@sundays" => TimeSpec {
                minute: vec!(0), hour: vec!(0), day_of_month: vec!(ANY), month: vec!(ANY), day_of_week: vec!(7),
            },
            // N.B. twice a month, not every two weeks
            "@semimonthly" | "@fortnightly" => TimeSpec {
                minute: vec!(0),
                hour: vec!(0),
                day_of_month: vec!(1,15),
                month: vec!(1),
                day_of_week: vec!(ANY),
            },
            "@monthly" => TimeSpec {
                minute: vec!(0),
                hour: vec!(0),
                day_of_month: vec!(1),
                month: vec!(ANY),
                day_of_week: vec!(ANY),
            },
            "@semiannually" | "@biannually" => TimeSpec {
                minute: vec!(0),
                hour: vec!(0),
                day_of_month: vec!(1),
                month: vec!(1,7),
                day_of_week: vec!(ANY),
            },
            "@quarterly" => TimeSpec {
                minute: vec!(0),
                hour: vec!(0),
                day_of_month: vec!(1),
                month: vec!(1,4,7,10),
                day_of_week: vec!(ANY),
            },
            "@yearly" | "@annually" => TimeSpec {
                minute: vec!(0),
                hour: vec!(0),
                day_of_month: vec!(1),
                month: vec!(1),
                day_of_week: vec!(ANY),
            },
            "@never" | _ => TimeSpec {
                minute: vec!(NEVER),
                hour: vec!(0),
                day_of_month: vec!(0),
                month: vec!(0),
                day_of_week: vec!(0),
            }
        };
    }

    /// parse "*" or "1 or "1,2,3" returning a vector of unsigned ints
    fn parse_field(field: &str) -> Vec<u8> {
        field.split(',').map(|f| { f.parse::<u8>().unwrap_or(255) }).collect()
    }
}


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

    #[test]
    fn test_parser_noop() {
        let data = "";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(0, cfg.cron_jobs.len());
        assert_eq!(0, cfg.file_jobs.len());
    }

    #[test]
    fn test_parser_comments() {
        let data = "# this is a comment";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(0, cfg.cron_jobs.len());
        assert_eq!(0, cfg.file_jobs.len());
    }

    #[test]
    fn test_take_cron_jobs() {
        let data = "# simple\n35 * * * * teknopaul /bin/foo arg";
        let mut cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.cron_jobs.len());
        assert_eq!(1, cfg.take_cron_jobs().len());
        assert_eq!(0, cfg.cron_jobs.len());
    }

    #[test]
    fn test_hours() {
        let data = "# simple\n0 2 * * * teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.cron_jobs.len());
    }

    #[test]
    fn test_days() {
        let data = "# simple\n0 * 10 * * teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.cron_jobs.len());
    }

    #[test]
    fn test_months() {
        let data = "# simple\n0 * * 6 * teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.cron_jobs.len());
    }

    #[test]
    fn test_parser_simple_cron_alias() {
        let data = "# simple\n@daily teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.cron_jobs.len());
        assert_eq!(String::from("/bin/foo"), cfg.cron_jobs[0].command);
        assert_eq!(String::from("arg"), cfg.cron_jobs[0].args[0]);
    }

    #[test]
    fn test_parser_simple_file() {
        let data = "# simple\n/etc/nginx.conf teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.file_jobs.len());
        assert_eq!(false, cfg.file_jobs[0].file_spec.as_ref().unwrap().is_dir);
        assert_eq!(String::from("/bin/foo"), cfg.file_jobs[0].command);
        assert_eq!(String::from("arg"), cfg.file_jobs[0].args[0]);
    }

    #[test]
    fn test_parser_simple_dir() {
        let data = "# simple\n/etc/nginx/ teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(1, cfg.file_jobs.len());
        assert_eq!(true, cfg.file_jobs[0].file_spec.as_ref().unwrap().is_dir);
        assert_eq!(String::from("/bin/foo"), cfg.file_jobs[0].command);
        assert_eq!(String::from("arg"), cfg.file_jobs[0].args[0]);
    }

    #[test]
    fn test_dir_has_files() {
        let data = "# simple\n/etc/ teknopaul /bin/foo arg";
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(true, (&cfg.file_jobs[0]).dir_has_files());
    }

    #[test]
    fn test_dir_has_no_files() {
        let data = "# simple\n/tmp/somerandomdirectory teknopaul /bin/foo \"aargh aargh\"";
        create_dir_all("/tmp/somerandomdirectory").ok();
        let cfg = Config::read_lines(data.split('\n').map(|s| String::from(s)));
        assert_eq!(false, (&cfg.file_jobs[0]).dir_has_files());
        assert_eq!(String::from("/bin/foo"), cfg.file_jobs[0].command);
        assert_eq!(String::from("aargh aargh"), cfg.file_jobs[0].args[0]);
    }
}