Skip to main content

gha_cache_proof/
engine.rs

1use anyhow::Result;
2use camino::Utf8PathBuf;
3use chrono::Utc;
4use serde_json::{Map, Value};
5
6use crate::model::{
7    CacheOperationKind, CacheOperationReceipt, CacheProofReceipt, Check, Compression,
8    ReceiptSummary, RunnerOs, SCHEMA_VERSION, ToolInfo,
9};
10use crate::store::{
11    CacheRequest, CacheStore, accessible_scopes, cache_version, normalize_ref, scope_from_ref,
12};
13use crate::{TOOL_NAME, TOOL_VERSION};
14
15#[derive(Debug, Clone)]
16pub struct CommonOptions {
17    pub store: Utf8PathBuf,
18    pub workspace: Utf8PathBuf,
19    pub ref_name: String,
20    pub default_branch: String,
21    pub base_ref: Option<String>,
22    pub runner_os: RunnerOs,
23    pub compression: Option<Compression>,
24    pub enable_cross_os_archive: bool,
25}
26
27#[derive(Debug, Clone)]
28pub struct RestoreOptions {
29    pub common: CommonOptions,
30    pub key: String,
31    pub restore_keys: Vec<String>,
32    pub paths: Vec<String>,
33    pub lookup_only: bool,
34    pub fail_on_cache_miss: bool,
35}
36
37#[derive(Debug, Clone)]
38pub struct SaveOptions {
39    pub common: CommonOptions,
40    pub key: String,
41    pub paths: Vec<String>,
42}
43
44#[derive(Debug, Clone)]
45pub struct CheckWorkflowOptions {
46    pub common: CommonOptions,
47    pub repo_root: Utf8PathBuf,
48    pub workflows: Vec<Utf8PathBuf>,
49    pub context: Value,
50}
51
52pub fn restore_cache(options: &RestoreOptions) -> Result<CacheProofReceipt> {
53    let mut store = CacheStore::open(options.common.store.clone())?;
54    let request = request_from_restore(options);
55    let operation = restore_operation(
56        &mut store,
57        &request,
58        CacheOperationKind::Restore,
59        options.lookup_only,
60        options.fail_on_cache_miss,
61        true,
62    )?;
63    Ok(receipt(
64        "restore",
65        options.common.store.clone(),
66        options.common.workspace.clone(),
67        vec![operation],
68        Vec::new(),
69    ))
70}
71
72pub fn save_cache(options: &SaveOptions) -> Result<CacheProofReceipt> {
73    let mut store = CacheStore::open(options.common.store.clone())?;
74    let request = request_from_save(options);
75    let operation = save_operation(&mut store, &request)?;
76    Ok(receipt(
77        "save",
78        options.common.store.clone(),
79        options.common.workspace.clone(),
80        vec![operation],
81        Vec::new(),
82    ))
83}
84
85pub fn check_workflows(options: &CheckWorkflowOptions) -> Result<CacheProofReceipt> {
86    let mut store = CacheStore::open(options.common.store.clone())?;
87    let reports = crate::workflow::check_workflows(options, &mut store)?;
88    let operations = reports
89        .iter()
90        .flat_map(|report| report.cache_steps.iter())
91        .map(|step| step.operation_receipt.clone())
92        .collect::<Vec<_>>();
93    Ok(receipt(
94        "check-workflow",
95        options.common.store.clone(),
96        options.common.workspace.clone(),
97        operations,
98        reports,
99    ))
100}
101
102pub(crate) fn request_from_parts(
103    common: &CommonOptions,
104    key: String,
105    restore_keys: Vec<String>,
106    paths: Vec<String>,
107) -> CacheRequest {
108    let scope = scope_from_ref(&common.ref_name);
109    let compression = common.compression.unwrap_or_else(|| {
110        Compression::default_for(common.runner_os, common.enable_cross_os_archive)
111    });
112    CacheRequest {
113        workspace: common.workspace.clone(),
114        key,
115        restore_keys,
116        paths,
117        scope: scope.clone(),
118        accessible_scopes: accessible_scopes(
119            &scope,
120            &common.default_branch,
121            common.base_ref.as_deref(),
122        ),
123        runner_os: common.runner_os,
124        compression,
125        enable_cross_os_archive: common.enable_cross_os_archive,
126    }
127}
128
129pub(crate) fn restore_operation(
130    store: &mut CacheStore,
131    request: &CacheRequest,
132    operation: CacheOperationKind,
133    lookup_only: bool,
134    fail_on_cache_miss: bool,
135    copy_files: bool,
136) -> Result<CacheOperationReceipt> {
137    let mut checks = validate_request(request);
138    let version = cache_version(
139        &request.paths,
140        request.compression,
141        request.enable_cross_os_archive,
142    );
143
144    let restore = store.restore(request, copy_files && !lookup_only)?;
145    let cache_hit = match restore.matched.as_ref().map(|matched| matched.match_kind) {
146        Some(crate::model::CacheMatchKind::ExactKey) => "true".to_owned(),
147        Some(_) => "false".to_owned(),
148        None => String::new(),
149    };
150
151    if let Some(matched) = &restore.matched {
152        checks.push(Check::pass(
153            "cache.restore.match",
154            format!("matched cache key {} in {}", matched.key, matched.scope),
155        ));
156        if lookup_only {
157            checks.push(Check::skip(
158                "cache.restore.copy",
159                "lookup-only requested; cache contents were not restored",
160            ));
161        } else {
162            checks.push(Check::pass(
163                "cache.restore.copy",
164                format!(
165                    "restored {} files and {} bytes",
166                    restore.restored_files, restore.restored_bytes
167                ),
168            ));
169            if restore.skipped_absolute_files > 0 {
170                checks.push(Check::warn(
171                    "cache.restore.absolute_skipped",
172                    format!(
173                        "{} cache file(s) originally outside the workspace were not restored; the runner must repopulate them",
174                        restore.skipped_absolute_files
175                    ),
176                ));
177            }
178        }
179    } else if fail_on_cache_miss {
180        checks.push(Check::fail(
181            "cache.restore.miss",
182            "cache miss and fail-on-cache-miss is enabled",
183        ));
184    } else {
185        checks.push(Check::warn(
186            "cache.restore.miss",
187            "no matching cache entry found",
188        ));
189    }
190
191    let path_records = store.inspect_paths(&request.workspace, &request.paths)?;
192
193    Ok(CacheOperationReceipt {
194        operation,
195        key: request.key.clone(),
196        restore_keys: request.restore_keys.clone(),
197        paths: request.paths.clone(),
198        version,
199        scope: request.scope.clone(),
200        accessible_scopes: request.accessible_scopes.clone(),
201        runner_os: request.runner_os,
202        compression: request.compression,
203        enable_cross_os_archive: request.enable_cross_os_archive,
204        lookup_only,
205        fail_on_cache_miss,
206        matched: restore.matched,
207        cache_hit,
208        restored_files: restore.restored_files,
209        restored_bytes: restore.restored_bytes,
210        skipped_absolute_files: restore.skipped_absolute_files,
211        saved_files: 0,
212        saved_bytes: 0,
213        path_records,
214        checks,
215    })
216}
217
218pub(crate) fn save_operation(
219    store: &mut CacheStore,
220    request: &CacheRequest,
221) -> Result<CacheOperationReceipt> {
222    let mut checks = validate_request(request);
223    let version = cache_version(
224        &request.paths,
225        request.compression,
226        request.enable_cross_os_archive,
227    );
228    let saved = store.save(request)?;
229
230    let (saved_files, saved_bytes) = if let Some(entry) = &saved.entry {
231        checks.push(Check::pass(
232            "cache.save.created",
233            format!("saved cache {} with {} files", entry.key, entry.files),
234        ));
235        (entry.files, entry.bytes)
236    } else if saved.skipped_existing {
237        checks.push(Check::skip(
238            "cache.save.exists",
239            "cache key, version, and scope already exist; save skipped",
240        ));
241        (0, 0)
242    } else {
243        checks.push(Check::fail(
244            "cache.save.empty",
245            "no files matched cache paths; empty cache was not saved",
246        ));
247        (0, 0)
248    };
249
250    Ok(CacheOperationReceipt {
251        operation: CacheOperationKind::Save,
252        key: request.key.clone(),
253        restore_keys: Vec::new(),
254        paths: request.paths.clone(),
255        version,
256        scope: request.scope.clone(),
257        accessible_scopes: request.accessible_scopes.clone(),
258        runner_os: request.runner_os,
259        compression: request.compression,
260        enable_cross_os_archive: request.enable_cross_os_archive,
261        lookup_only: false,
262        fail_on_cache_miss: false,
263        matched: None,
264        cache_hit: String::new(),
265        restored_files: 0,
266        restored_bytes: 0,
267        skipped_absolute_files: 0,
268        saved_files,
269        saved_bytes,
270        path_records: saved.path_records,
271        checks,
272    })
273}
274
275pub(crate) fn context_with_defaults(options: &CheckWorkflowOptions) -> Value {
276    let mut root = match options.context.clone() {
277        Value::Object(object) => object,
278        _ => Map::new(),
279    };
280
281    root.entry("runner".to_owned()).or_insert_with(|| {
282        let mut runner = Map::new();
283        runner.insert(
284            "os".to_owned(),
285            Value::String(options.common.runner_os.gha_name().to_owned()),
286        );
287        Value::Object(runner)
288    });
289
290    root.entry("github".to_owned()).or_insert_with(|| {
291        let mut github = Map::new();
292        github.insert(
293            "ref".to_owned(),
294            Value::String(normalize_ref(&options.common.ref_name)),
295        );
296        github.insert(
297            "ref_name".to_owned(),
298            Value::String(ref_name_only(&options.common.ref_name)),
299        );
300        github.insert("event_name".to_owned(), Value::String("push".to_owned()));
301        Value::Object(github)
302    });
303
304    Value::Object(root)
305}
306
307fn request_from_restore(options: &RestoreOptions) -> CacheRequest {
308    request_from_parts(
309        &options.common,
310        options.key.clone(),
311        options.restore_keys.clone(),
312        options.paths.clone(),
313    )
314}
315
316fn request_from_save(options: &SaveOptions) -> CacheRequest {
317    request_from_parts(
318        &options.common,
319        options.key.clone(),
320        Vec::new(),
321        options.paths.clone(),
322    )
323}
324
325fn validate_request(request: &CacheRequest) -> Vec<Check> {
326    let mut checks = Vec::new();
327    if request.key.is_empty() {
328        checks.push(Check::fail("cache.key", "cache key cannot be empty"));
329    } else if request.key.len() > 512 {
330        checks.push(Check::fail(
331            "cache.key.length",
332            "cache key exceeds GitHub's 512 character limit",
333        ));
334    } else {
335        checks.push(Check::pass(
336            "cache.key",
337            "cache key is present and within the 512 character limit",
338        ));
339    }
340
341    if request.paths.is_empty() {
342        checks.push(Check::fail(
343            "cache.path",
344            "at least one cache path is required",
345        ));
346    } else {
347        checks.push(Check::pass(
348            "cache.path",
349            "at least one cache path is configured",
350        ));
351    }
352
353    if request.accessible_scopes.is_empty() {
354        checks.push(Check::fail(
355            "cache.scope",
356            "no accessible cache scopes were computed",
357        ));
358    } else {
359        checks.push(Check::pass(
360            "cache.scope",
361            format!(
362                "computed {} accessible cache scopes",
363                request.accessible_scopes.len()
364            ),
365        ));
366    }
367
368    checks
369}
370
371fn receipt(
372    mode: &str,
373    store: Utf8PathBuf,
374    workspace: Utf8PathBuf,
375    operations: Vec<CacheOperationReceipt>,
376    workflows: Vec<crate::model::WorkflowCacheReport>,
377) -> CacheProofReceipt {
378    let mut checks = Vec::new();
379    let mut summary = ReceiptSummary::default();
380    for operation in &operations {
381        let op_summary = operation.summary();
382        summary.add(&op_summary);
383        checks.extend(operation.checks.clone());
384    }
385    for workflow in &workflows {
386        summary.add(&workflow.summary);
387        checks.extend(workflow.checks.clone());
388    }
389
390    CacheProofReceipt {
391        schema_version: SCHEMA_VERSION,
392        tool: ToolInfo {
393            name: TOOL_NAME.to_owned(),
394            version: TOOL_VERSION.to_owned(),
395        },
396        checked_at: Utc::now(),
397        mode: mode.to_owned(),
398        store,
399        workspace,
400        summary,
401        operations,
402        workflows,
403        checks,
404    }
405}
406
407fn ref_name_only(value: &str) -> String {
408    value
409        .trim()
410        .trim_start_matches("refs/heads/")
411        .trim_start_matches("refs/tags/")
412        .to_owned()
413}