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
),
));
if restore.skipped_absolute_files > 0 {
checks.push(Check::warn(
"cache.restore.absolute_skipped",
format!(
"{} cache file(s) originally outside the workspace were not restored; the runner must repopulate them",
restore.skipped_absolute_files
),
));
}
}
} 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,
skipped_absolute_files: restore.skipped_absolute_files,
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,
skipped_absolute_files: 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()
}