systemg 0.34.0

A simple process manager.
Documentation
#[path = "common/mod.rs"]
mod common;

use std::{
    fs, thread,
    time::{Duration, Instant},
};

use common::{HomeEnvGuard, wait_for_file_value};
use systemg::{
    config::load_config,
    cron::{CronExecutionStatus, CronStateFile},
    daemon::Daemon,
    ipc::{self, ControlCommand, ControlError},
};
use tempfile::tempdir;

#[test]
fn cron_services_not_started_during_bulk_start() {
    let temp = tempdir().expect("failed to create tempdir");
    let dir = temp.path();
    let home = dir.join("home");
    fs::create_dir_all(&home).expect("failed to create home dir");
    let _home = HomeEnvGuard::set(&home);

    let normal_marker = dir.join("normal.txt");
    let cron_marker = dir.join("cron.txt");
    let config_path = dir.join("config.yaml");
    fs::write(
        &config_path,
        format!(
            r#"version: "1"
services:
  normal:
    command: "sh -c 'echo normal > \"{normal}\"; sleep 1'"
    restart_policy: "never"
  cron_job:
    command: "sh -c 'echo cron > \"{cron}\"'"
    restart_policy: "never"
    cron:
      expression: "*/30 * * * *"
"#,
            normal = normal_marker.display(),
            cron = cron_marker.display()
        ),
    )
    .expect("failed to write config");

    let config = load_config(Some(config_path.to_str().unwrap())).expect("load config");
    let daemon = Daemon::from_config(config.clone(), false).expect("daemon from config");

    daemon.start_services().expect("start services");

    wait_for_file_value(&normal_marker, "normal");
    thread::sleep(Duration::from_millis(300));
    assert!(
        !cron_marker.exists(),
        "cron-managed service should not run during bulk start"
    );

    daemon.stop_services().expect("stop services");
}

#[test]
fn restart_registers_new_cron_jobs() {
    use systemg::supervisor::Supervisor;

    let temp = tempdir().expect("failed to create tempdir");
    let dir = temp.path();
    let home = dir.join("home");
    fs::create_dir_all(&home).expect("failed to create home dir");
    let _home = HomeEnvGuard::set(&home);

    let config_path_1 = dir.join("config1.yaml");
    fs::write(
        &config_path_1,
        r#"version: "1"
services:
  normal:
    command: "sleep 1000"
    restart_policy: "never"
"#,
    )
    .expect("failed to write initial config");

    let mut supervisor =
        Supervisor::new(config_path_1.clone(), false, None).expect("create supervisor");
    let initial_jobs = supervisor.get_cron_jobs();
    assert_eq!(initial_jobs.len(), 0, "Should start with no cron jobs");

    let config_path_2 = dir.join("config2.yaml");
    fs::write(
        &config_path_2,
        r#"version: "1"
services:
  normal:
    command: "sleep 1000"
    restart_policy: "never"
  new_cron_job:
    command: "echo 'cron job executed'"
    restart_policy: "never"
    cron:
      expression: "* * * * *"
"#,
    )
    .expect("failed to write new config");

    supervisor
        .reload_config_for_test(config_path_2.as_path())
        .expect("reload config should succeed");

    let updated_jobs = supervisor.get_cron_jobs();
    assert_eq!(
        updated_jobs.len(),
        1,
        "Should have 1 cron job after restart"
    );
    assert_eq!(
        updated_jobs[0].service_name, "new_cron_job",
        "The new cron job should be registered"
    );

    supervisor.shutdown_for_test().expect("shutdown supervisor");
}

#[test]
fn supervisor_persists_cron_execution_history() {
    use systemg::supervisor::Supervisor;

    let temp = tempdir().expect("failed to create tempdir");
    let dir = temp.path();
    let home = dir.join("home");
    fs::create_dir_all(&home).expect("failed to create home dir");
    let _home = HomeEnvGuard::set(&home);

    let marker = dir.join("cron-history-marker.txt");
    let config_path = dir.join("systemg.yaml");
    fs::write(
        &config_path,
        format!(
            r#"version: "1"
services:
  history_cron:
    command: "sh -c 'date +%s >> \"{marker}\"'"
    restart_policy: "never"
    cron:
      expression: "*/1 * * * * *"
"#,
            marker = marker.display()
        ),
    )
    .expect("failed to write config");

    let config =
        load_config(Some(config_path.to_string_lossy().as_ref())).expect("load config");
    let service_hash = config
        .services
        .get("history_cron")
        .expect("history cron service")
        .compute_hash();

    let config_for_thread = config_path.clone();
    let supervisor_thread = thread::spawn(move || {
        let mut supervisor =
            Supervisor::new(config_for_thread, false, None).expect("create supervisor");
        supervisor.run().expect("run supervisor");
    });

    wait_for_supervisor_socket();
    wait_for_cron_history_record(&service_hash);

    let content = fs::read_to_string(&marker).expect("read marker");
    assert!(
        !content.trim().is_empty(),
        "cron command should have written the marker"
    );

    let _ = ipc::send_command(&ControlCommand::Shutdown);
    supervisor_thread
        .join()
        .expect("supervisor thread should shut down cleanly");
}

fn wait_for_supervisor_socket() {
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        match ipc::send_command(&ControlCommand::Status { live: false }) {
            Ok(_) => return,
            Err(ControlError::NotAvailable) | Err(ControlError::Io(_)) => {}
            Err(err) => panic!("unexpected supervisor status error: {err}"),
        }

        if Instant::now() >= deadline {
            panic!("timed out waiting for supervisor socket");
        }

        thread::sleep(Duration::from_millis(100));
    }
}

fn wait_for_cron_history_record(service_hash: &str) {
    let deadline = Instant::now() + Duration::from_secs(10);
    loop {
        if let Ok(state) = CronStateFile::load()
            && let Some(job) = state.jobs().get(service_hash)
            && let Some(record) = job.execution_history.back()
            && record.completed_at.is_some()
            && matches!(record.status, Some(CronExecutionStatus::Success))
            && record.exit_code == Some(0)
        {
            return;
        }

        if Instant::now() >= deadline {
            let state = CronStateFile::load().ok();
            panic!(
                "timed out waiting for persisted cron execution history for {service_hash}; state={state:#?}"
            );
        }

        thread::sleep(Duration::from_millis(100));
    }
}