gha-cache-proof 1.0.0

GitHub Actions cache compatibility checker and local cache-store receipt tool for offline CI
Documentation
use anyhow::Result;
use camino::Utf8PathBuf;
use chrono::Utc;
use serde_json::{Map, Value};

use crate::model::{
    CacheOperationKind, CacheOperationReceipt, CacheProofReceipt, Check, Compression,
    ReceiptSummary, RunnerOs, SCHEMA_VERSION, ToolInfo,
};
use crate::store::{
    CacheRequest, CacheStore, accessible_scopes, cache_version, normalize_ref, scope_from_ref,
};
use crate::{TOOL_NAME, TOOL_VERSION};

#[derive(Debug, Clone)]
pub struct CommonOptions {
    pub store: Utf8PathBuf,
    pub workspace: Utf8PathBuf,
    pub ref_name: String,
    pub default_branch: String,
    pub base_ref: Option<String>,
    pub runner_os: RunnerOs,
    pub compression: Option<Compression>,
    pub enable_cross_os_archive: bool,
}

#[derive(Debug, Clone)]
pub struct RestoreOptions {
    pub common: CommonOptions,
    pub key: String,
    pub restore_keys: Vec<String>,
    pub paths: Vec<String>,
    pub lookup_only: bool,
    pub fail_on_cache_miss: bool,
}

#[derive(Debug, Clone)]
pub struct SaveOptions {
    pub common: CommonOptions,
    pub key: String,
    pub paths: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct CheckWorkflowOptions {
    pub common: CommonOptions,
    pub repo_root: Utf8PathBuf,
    pub workflows: Vec<Utf8PathBuf>,
    pub context: Value,
}

pub fn restore_cache(options: &RestoreOptions) -> Result<CacheProofReceipt> {
    let mut store = CacheStore::open(options.common.store.clone())?;
    let request = request_from_restore(options);
    let operation = restore_operation(
        &mut store,
        &request,
        CacheOperationKind::Restore,
        options.lookup_only,
        options.fail_on_cache_miss,
        true,
    )?;
    Ok(receipt(
        "restore",
        options.common.store.clone(),
        options.common.workspace.clone(),
        vec![operation],
        Vec::new(),
    ))
}

pub fn save_cache(options: &SaveOptions) -> Result<CacheProofReceipt> {
    let mut store = CacheStore::open(options.common.store.clone())?;
    let request = request_from_save(options);
    let operation = save_operation(&mut store, &request)?;
    Ok(receipt(
        "save",
        options.common.store.clone(),
        options.common.workspace.clone(),
        vec![operation],
        Vec::new(),
    ))
}

pub fn check_workflows(options: &CheckWorkflowOptions) -> Result<CacheProofReceipt> {
    let mut store = CacheStore::open(options.common.store.clone())?;
    let reports = crate::workflow::check_workflows(options, &mut store)?;
    let operations = reports
        .iter()
        .flat_map(|report| report.cache_steps.iter())
        .map(|step| step.operation_receipt.clone())
        .collect::<Vec<_>>();
    Ok(receipt(
        "check-workflow",
        options.common.store.clone(),
        options.common.workspace.clone(),
        operations,
        reports,
    ))
}

pub(crate) fn request_from_parts(
    common: &CommonOptions,
    key: String,
    restore_keys: Vec<String>,
    paths: Vec<String>,
) -> CacheRequest {
    let scope = scope_from_ref(&common.ref_name);
    let compression = common.compression.unwrap_or_else(|| {
        Compression::default_for(common.runner_os, common.enable_cross_os_archive)
    });
    CacheRequest {
        workspace: common.workspace.clone(),
        key,
        restore_keys,
        paths,
        scope: scope.clone(),
        accessible_scopes: accessible_scopes(
            &scope,
            &common.default_branch,
            common.base_ref.as_deref(),
        ),
        runner_os: common.runner_os,
        compression,
        enable_cross_os_archive: common.enable_cross_os_archive,
    }
}

pub(crate) fn restore_operation(
    store: &mut CacheStore,
    request: &CacheRequest,
    operation: CacheOperationKind,
    lookup_only: bool,
    fail_on_cache_miss: bool,
    copy_files: bool,
) -> Result<CacheOperationReceipt> {
    let mut checks = validate_request(request);
    let version = cache_version(
        &request.paths,
        request.compression,
        request.enable_cross_os_archive,
    );

    let restore = store.restore(request, copy_files && !lookup_only)?;
    let cache_hit = match restore.matched.as_ref().map(|matched| matched.match_kind) {
        Some(crate::model::CacheMatchKind::ExactKey) => "true".to_owned(),
        Some(_) => "false".to_owned(),
        None => String::new(),
    };

    if let Some(matched) = &restore.matched {
        checks.push(Check::pass(
            "cache.restore.match",
            format!("matched cache key {} in {}", matched.key, matched.scope),
        ));
        if lookup_only {
            checks.push(Check::skip(
                "cache.restore.copy",
                "lookup-only requested; cache contents were not restored",
            ));
        } else {
            checks.push(Check::pass(
                "cache.restore.copy",
                format!(
                    "restored {} files and {} bytes",
                    restore.restored_files, restore.restored_bytes
                ),
            ));
        }
    } else if fail_on_cache_miss {
        checks.push(Check::fail(
            "cache.restore.miss",
            "cache miss and fail-on-cache-miss is enabled",
        ));
    } else {
        checks.push(Check::warn(
            "cache.restore.miss",
            "no matching cache entry found",
        ));
    }

    let path_records = store.inspect_paths(&request.workspace, &request.paths)?;

    Ok(CacheOperationReceipt {
        operation,
        key: request.key.clone(),
        restore_keys: request.restore_keys.clone(),
        paths: request.paths.clone(),
        version,
        scope: request.scope.clone(),
        accessible_scopes: request.accessible_scopes.clone(),
        runner_os: request.runner_os,
        compression: request.compression,
        enable_cross_os_archive: request.enable_cross_os_archive,
        lookup_only,
        fail_on_cache_miss,
        matched: restore.matched,
        cache_hit,
        restored_files: restore.restored_files,
        restored_bytes: restore.restored_bytes,
        saved_files: 0,
        saved_bytes: 0,
        path_records,
        checks,
    })
}

pub(crate) fn save_operation(
    store: &mut CacheStore,
    request: &CacheRequest,
) -> Result<CacheOperationReceipt> {
    let mut checks = validate_request(request);
    let version = cache_version(
        &request.paths,
        request.compression,
        request.enable_cross_os_archive,
    );
    let saved = store.save(request)?;

    let (saved_files, saved_bytes) = if let Some(entry) = &saved.entry {
        checks.push(Check::pass(
            "cache.save.created",
            format!("saved cache {} with {} files", entry.key, entry.files),
        ));
        (entry.files, entry.bytes)
    } else if saved.skipped_existing {
        checks.push(Check::skip(
            "cache.save.exists",
            "cache key, version, and scope already exist; save skipped",
        ));
        (0, 0)
    } else {
        checks.push(Check::fail(
            "cache.save.empty",
            "no files matched cache paths; empty cache was not saved",
        ));
        (0, 0)
    };

    Ok(CacheOperationReceipt {
        operation: CacheOperationKind::Save,
        key: request.key.clone(),
        restore_keys: Vec::new(),
        paths: request.paths.clone(),
        version,
        scope: request.scope.clone(),
        accessible_scopes: request.accessible_scopes.clone(),
        runner_os: request.runner_os,
        compression: request.compression,
        enable_cross_os_archive: request.enable_cross_os_archive,
        lookup_only: false,
        fail_on_cache_miss: false,
        matched: None,
        cache_hit: String::new(),
        restored_files: 0,
        restored_bytes: 0,
        saved_files,
        saved_bytes,
        path_records: saved.path_records,
        checks,
    })
}

pub(crate) fn context_with_defaults(options: &CheckWorkflowOptions) -> Value {
    let mut root = match options.context.clone() {
        Value::Object(object) => object,
        _ => Map::new(),
    };

    root.entry("runner".to_owned()).or_insert_with(|| {
        let mut runner = Map::new();
        runner.insert(
            "os".to_owned(),
            Value::String(options.common.runner_os.gha_name().to_owned()),
        );
        Value::Object(runner)
    });

    root.entry("github".to_owned()).or_insert_with(|| {
        let mut github = Map::new();
        github.insert(
            "ref".to_owned(),
            Value::String(normalize_ref(&options.common.ref_name)),
        );
        github.insert(
            "ref_name".to_owned(),
            Value::String(ref_name_only(&options.common.ref_name)),
        );
        github.insert("event_name".to_owned(), Value::String("push".to_owned()));
        Value::Object(github)
    });

    Value::Object(root)
}

fn request_from_restore(options: &RestoreOptions) -> CacheRequest {
    request_from_parts(
        &options.common,
        options.key.clone(),
        options.restore_keys.clone(),
        options.paths.clone(),
    )
}

fn request_from_save(options: &SaveOptions) -> CacheRequest {
    request_from_parts(
        &options.common,
        options.key.clone(),
        Vec::new(),
        options.paths.clone(),
    )
}

fn validate_request(request: &CacheRequest) -> Vec<Check> {
    let mut checks = Vec::new();
    if request.key.is_empty() {
        checks.push(Check::fail("cache.key", "cache key cannot be empty"));
    } else if request.key.len() > 512 {
        checks.push(Check::fail(
            "cache.key.length",
            "cache key exceeds GitHub's 512 character limit",
        ));
    } else {
        checks.push(Check::pass(
            "cache.key",
            "cache key is present and within the 512 character limit",
        ));
    }

    if request.paths.is_empty() {
        checks.push(Check::fail(
            "cache.path",
            "at least one cache path is required",
        ));
    } else {
        checks.push(Check::pass(
            "cache.path",
            "at least one cache path is configured",
        ));
    }

    if request.accessible_scopes.is_empty() {
        checks.push(Check::fail(
            "cache.scope",
            "no accessible cache scopes were computed",
        ));
    } else {
        checks.push(Check::pass(
            "cache.scope",
            format!(
                "computed {} accessible cache scopes",
                request.accessible_scopes.len()
            ),
        ));
    }

    checks
}

fn receipt(
    mode: &str,
    store: Utf8PathBuf,
    workspace: Utf8PathBuf,
    operations: Vec<CacheOperationReceipt>,
    workflows: Vec<crate::model::WorkflowCacheReport>,
) -> CacheProofReceipt {
    let mut checks = Vec::new();
    let mut summary = ReceiptSummary::default();
    for operation in &operations {
        let op_summary = operation.summary();
        summary.add(&op_summary);
        checks.extend(operation.checks.clone());
    }
    for workflow in &workflows {
        summary.add(&workflow.summary);
        checks.extend(workflow.checks.clone());
    }

    CacheProofReceipt {
        schema_version: SCHEMA_VERSION,
        tool: ToolInfo {
            name: TOOL_NAME.to_owned(),
            version: TOOL_VERSION.to_owned(),
        },
        checked_at: Utc::now(),
        mode: mode.to_owned(),
        store,
        workspace,
        summary,
        operations,
        workflows,
        checks,
    }
}

fn ref_name_only(value: &str) -> String {
    value
        .trim()
        .trim_start_matches("refs/heads/")
        .trim_start_matches("refs/tags/")
        .to_owned()
}