runnerctl 0.2.0

Manage independent ephemeral GitHub Actions runner pools on one Docker host
use crate::{docker::Docker, github::RunnerRelease};
use anyhow::Result;
use serde_json::{Value, json};
use std::sync::LazyLock;

static MANIFEST: LazyLock<Value> = LazyLock::new(|| {
    serde_json::from_str(include_str!("../runner/version.json")).expect("valid runner manifest")
});
pub fn runner_version() -> &'static str {
    MANIFEST["version"].as_str().expect("runner version")
}
fn version(s: &str) -> Option<Vec<u64>> {
    let numbers: Option<Vec<u64>> = s
        .trim_start_matches('v')
        .split('.')
        .map(|s| s.parse().ok())
        .collect();
    numbers.filter(|v| v.len() == 3)
}
pub fn version_check(
    image: &str,
    installed: Option<&str>,
    latest: Option<&RunnerRelease>,
) -> Value {
    let mut check =
        json!({"check":format!("runner-version:{image}"),"ok":true,"installed":installed});
    match (installed.and_then(version), latest) {
        (Some(current), Some(latest)) => {
            check["latest"] = json!(latest.tag_name.trim_start_matches('v'));
            if let Some(new) = version(&latest.tag_name) {
                if current < new {
                    check["update_available"] = json!(true);
                    check["warning"] = json!(
                        "Rebuild or pull an updated runner image, then use runnerctl upgrade. Earlier releases may have an earlier deadline."
                    );
                    if let Ok(published) = humantime::parse_rfc3339(&latest.published_at) {
                        let deadline = published + std::time::Duration::from_secs(30 * 86400);
                        check["deadline"] = json!(humantime::format_rfc3339(deadline).to_string());
                        check["deadline_basis"] =
                            json!("latest upstream release; this is an upper bound");
                        if std::time::SystemTime::now() >= deadline {
                            check["ok"] = json!(false);
                            check["error"] = json!(
                                "Runner is past the upstream update deadline and may no longer receive jobs"
                            );
                        }
                    }
                }
            }
        }
        (None, _) => {
            check["warning"] =
                json!("Custom image must set io.runnerctl.runner.version to enable update checks");
        }
        (_, None) => {
            check["warning"] =
                json!("Upstream version unavailable; check runner update requirements manually");
        }
    }
    check
}
pub async fn pull(image: Option<String>) -> Result<Value> {
    let image = image.unwrap_or_else(crate::config::default_image);
    crate::config::validate_image(&image)?;
    Docker::pull(&image).await?;
    Ok(
        json!({"pulled":image,"runner_version":Docker::image_version(&image).await?,"bundled_runner_version":runner_version()}),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn update_checks_distinguish_missing_current_and_expired_images() {
        let expired = RunnerRelease {
            tag_name: "v2.338.0".into(),
            published_at: "2020-01-01T00:00:00Z".into(),
        };
        assert_eq!(version_check("custom:1", None, Some(&expired))["ok"], true);
        assert_eq!(
            version_check("runner:1", Some("2.338.0"), Some(&expired))["ok"],
            true
        );
        assert_eq!(
            version_check("runner:1", Some("2.337.0"), Some(&expired))["ok"],
            false
        );
        assert_eq!(version_check("runner:1", Some("2.338.0"), None)["ok"], true);
        let fresh = RunnerRelease {
            published_at: humantime::format_rfc3339(std::time::SystemTime::now()).to_string(),
            ..expired
        };
        assert_eq!(
            version_check("runner:1", Some("2.337.0"), Some(&fresh))["update_available"],
            true
        );
        assert_eq!(
            version_check("runner:1", Some("2.337.0"), Some(&fresh))["ok"],
            true
        );
    }
}