svgen 0.2.0

Runit service generator
// Copyright 2019 Urs Schulz
//
// This file is part of svgen.
//
// svgen is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// svgen is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with svgen.  If not, see <http://www.gnu.org/licenses/>.


//! This program generates services from templates and instances in `/etc/svgen`.  Templates are
//! stored in `/etc/svgen/templates/` and instances are configured in the file
//! `/etc/svgen/instances`.  The templates directory contains subdirectories that must contain a
//! `run` file other optional files.  The instances file contains lines of the
//! format `<template-name>@<instance-name>`. If a `log` file exists, this will be handled
//! specially.
//!
//! This program reads the instance file line by line and for each line searches the corresponding
//! template directory. If found:
//!
//! 1. generate a new service directory under `/etc/sv/generated` named
//!    `<template-name>@<instance-name>`
//! 2. replace all occourences of `__INSTANCE__` in the run and other optional files and write
//!    the output to corresponding files in the service directory. The access mode is copied for
//!    each file.
//! 3. create a new `supervise` symlink to `/run/runit/<template-name>@<instance-name>`
//! 4. create a log directory in the service directory
//!   * If a file named `log` exists in the template directory, process it in the same way as above
//!   and install as `log/run`
//!   * else create a symlink named run to `/usr/local/bin/templog`
//! 5. symlink the service directory in `/service/` using an absolute path
//!
//! If the service directory already exists, it is skipped.
//!
//! In addition, for each service directory that is found in `/etc/sv/generated/` that has no
//! corresponding instance in the `instances` file, it does:
//! 1. stop the corresponding service and wait for it to finish (`sv stop || sv kill`)
//! 2. remove the symlink in `/service/`
//! 3. remove the service directory and all of it's contents in `/etc/sv/generated/`
//!
//! Empty lines in the instance file or lines starting with a `#` are ignored.


use lazy_static::lazy_static;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};


lazy_static! {
    static ref INSTANCE_FILE: PathBuf = PathBuf::from("/etc/svgen/instances");
    static ref TEMPLATE_DIR: PathBuf = PathBuf::from("/etc/svgen/templates/");
    static ref GENERATED_DIR: PathBuf = PathBuf::from("/etc/sv/generated/");
    static ref SERVICE_DIR: PathBuf = PathBuf::from("/service/");
    static ref DEFAULT_LOG: PathBuf = PathBuf::from("/usr/local/bin/templog");
    static ref SUPERVISE_TARGET: PathBuf = PathBuf::from("/run/runit/");
}

const SUPERVISE_PREFIX: &'static str = "supervise.";
static INSTANCE_STR: &'static str = "__INSTANCE__";


fn supervise_target_name(instance_name: &str) -> PathBuf {
    SUPERVISE_TARGET.join([SUPERVISE_PREFIX, instance_name].join(""))
}


fn split_instance(instance: &str) -> Option<(&str, &str)> {
    let mut sp = instance.splitn(2, '@');
    match (sp.next(), sp.next()) {
        (Some(a), Some(b)) => Some((a, b)),
        _ => None,
    }
}


fn copy_mode<P: AsRef<Path>, Q: AsRef<Path>>(src: P, dst: Q) -> io::Result<()> {
    use std::fs::Permissions;
    use std::os::unix::fs::PermissionsExt;

    let meta = std::fs::metadata(src)?;
    let mode = meta.permissions().mode();
    let perm = Permissions::from_mode(mode);
    std::fs::set_permissions(dst, perm)
}


fn process_file<P: AsRef<Path>, Q: AsRef<Path>>(
    in_file: P,
    out_file: Q,
    instance_name: &str,
) -> Result<(), String> {
    use std::fs::read_to_string;
    use std::fs::write;

    let in_file = in_file.as_ref();
    let out_file = out_file.as_ref();

    let data = read_to_string(in_file)
        .map_err(|e| format!("Failed to read file '{}': {}", in_file.display(), e))?;
    let data = data.replace(INSTANCE_STR, instance_name);
    write(out_file, data)
        .map_err(|e| format!("Failed to write file '{}': {}", out_file.display(), e))?;

    copy_mode(in_file, out_file).map_err(|e| format!("Failed to copy access mode: {}", e))
}


fn gen_service(instance: &str) -> Result<(), String> {
    use std::fs::create_dir_all;
    use std::fs::read_dir;
    use std::os::unix::fs::symlink;

    let (template_name, instance_name) =
        split_instance(instance).ok_or(format!("Invalid instance line: {}", instance))?;

    let template = TEMPLATE_DIR.join(template_name);
    let service_dir = GENERATED_DIR.join(instance);
    let target_link = SERVICE_DIR.join(instance);

    if service_dir.exists() || target_link.exists() {
        println!(
            "♻  Service '{}' already present, nothing to do.",
            instance
        );

        return Ok(());
    }

    println!("➤  Generating service for instance {}", instance);

    create_dir_all(&service_dir).unwrap();
    let log_dir = service_dir.join("log");
    create_dir_all(&log_dir).unwrap();

    let runfile = template.join("run");
    let logfile = template.join("log");

    if !runfile.exists() {
        return Err(format!("Template '{}' has no 'run' file", template_name));
    }

    let dst_runfile = service_dir.join("run");
    let dst_logfile = log_dir.join("run");

    process_file(runfile, &dst_runfile, instance_name)?;

    if logfile.exists() {
        // template/log exists, install it
        process_file(logfile, &dst_logfile, instance_name)?;
    } else {
        // otherwise symlink the default
        symlink(DEFAULT_LOG.as_path(), dst_logfile).unwrap();
    }

    for file in read_dir(&template).unwrap() {
        let file = file.unwrap();
        let file_name = file.file_name();
        if !file.file_type().unwrap().is_file()
            || file_name.as_bytes() == b"run"
            || file_name.as_bytes() == b"log"
        {
            continue;
        }

        let src = file.path();
        let dst = service_dir.join(file.file_name());

        process_file(src, dst, instance_name)?;
    }

    // create supervise symlink
    let supervise = supervise_target_name(instance_name);
    symlink(supervise, service_dir.join("supervise")).unwrap();

    // service directory generated, now symlink it
    symlink(service_dir, target_link).unwrap();

    Ok(())
}


fn del_service(instance: &str) {
    use std::fs::remove_dir_all;
    use std::fs::remove_file;
    use std::process::Command;

    let service_dir = GENERATED_DIR.join(instance);
    let target_link = SERVICE_DIR.join(instance);

    // 1. stop the corresponding service and wait for it to finish (sv stop || sv kill)
    if !Command::new("sv")
        .args(&["down", instance])
        .status()
        .unwrap()
        .success()
    {
        Command::new("sv")
            .args(&["kill", instance])
            .status()
            .unwrap();
        std::thread::sleep(std::time::Duration::from_secs(1));
    }

    // 2. remove the symlink in /service/
    remove_file(target_link).unwrap();

    // sleep for 7 sec so that runsvdir can shutdown the runsv process
    std::thread::sleep(std::time::Duration::from_secs(7));

    // 3. remove the service directory and all of it's contents in /etc/sv/generated/
    remove_dir_all(service_dir).unwrap();
}


fn main() {
    use std::fs::read_dir;
    use std::fs::read_to_string;

    let instances = read_to_string(INSTANCE_FILE.as_path()).unwrap();
    let instances = instances
        .split('\n')
        .filter(|l| !(l.is_empty() || l.starts_with('#')))
        .collect::<Vec<_>>();

    for instance in &instances {
        if let Err(e) = gen_service(instance) {
            println!("{}", e);
            continue;
        }
    }

    for service_dir in read_dir(GENERATED_DIR.as_path()).unwrap() {
        let service_dir = service_dir.unwrap();
        let name = service_dir.file_name().into_string().unwrap();
        if !instances.contains(&name.as_ref()) {
            println!("🗑  Removing instance '{}'", name);
            del_service(&name);
        }
    }
}