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        }
170    } else if fail_on_cache_miss {
171        checks.push(Check::fail(
172            "cache.restore.miss",
173            "cache miss and fail-on-cache-miss is enabled",
174        ));
175    } else {
176        checks.push(Check::warn(
177            "cache.restore.miss",
178            "no matching cache entry found",
179        ));
180    }
181
182    let path_records = store.inspect_paths(&request.workspace, &request.paths)?;
183
184    Ok(CacheOperationReceipt {
185        operation,
186        key: request.key.clone(),
187        restore_keys: request.restore_keys.clone(),
188        paths: request.paths.clone(),
189        version,
190        scope: request.scope.clone(),
191        accessible_scopes: request.accessible_scopes.clone(),
192        runner_os: request.runner_os,
193        compression: request.compression,
194        enable_cross_os_archive: request.enable_cross_os_archive,
195        lookup_only,
196        fail_on_cache_miss,
197        matched: restore.matched,
198        cache_hit,
199        restored_files: restore.restored_files,
200        restored_bytes: restore.restored_bytes,
201        saved_files: 0,
202        saved_bytes: 0,
203        path_records,
204        checks,
205    })
206}
207
208pub(crate) fn save_operation(
209    store: &mut CacheStore,
210    request: &CacheRequest,
211) -> Result<CacheOperationReceipt> {
212    let mut checks = validate_request(request);
213    let version = cache_version(
214        &request.paths,
215        request.compression,
216        request.enable_cross_os_archive,
217    );
218    let saved = store.save(request)?;
219
220    let (saved_files, saved_bytes) = if let Some(entry) = &saved.entry {
221        checks.push(Check::pass(
222            "cache.save.created",
223            format!("saved cache {} with {} files", entry.key, entry.files),
224        ));
225        (entry.files, entry.bytes)
226    } else if saved.skipped_existing {
227        checks.push(Check::skip(
228            "cache.save.exists",
229            "cache key, version, and scope already exist; save skipped",
230        ));
231        (0, 0)
232    } else {
233        checks.push(Check::fail(
234            "cache.save.empty",
235            "no files matched cache paths; empty cache was not saved",
236        ));
237        (0, 0)
238    };
239
240    Ok(CacheOperationReceipt {
241        operation: CacheOperationKind::Save,
242        key: request.key.clone(),
243        restore_keys: Vec::new(),
244        paths: request.paths.clone(),
245        version,
246        scope: request.scope.clone(),
247        accessible_scopes: request.accessible_scopes.clone(),
248        runner_os: request.runner_os,
249        compression: request.compression,
250        enable_cross_os_archive: request.enable_cross_os_archive,
251        lookup_only: false,
252        fail_on_cache_miss: false,
253        matched: None,
254        cache_hit: String::new(),
255        restored_files: 0,
256        restored_bytes: 0,
257        saved_files,
258        saved_bytes,
259        path_records: saved.path_records,
260        checks,
261    })
262}
263
264pub(crate) fn context_with_defaults(options: &CheckWorkflowOptions) -> Value {
265    let mut root = match options.context.clone() {
266        Value::Object(object) => object,
267        _ => Map::new(),
268    };
269
270    root.entry("runner".to_owned()).or_insert_with(|| {
271        let mut runner = Map::new();
272        runner.insert(
273            "os".to_owned(),
274            Value::String(options.common.runner_os.gha_name().to_owned()),
275        );
276        Value::Object(runner)
277    });
278
279    root.entry("github".to_owned()).or_insert_with(|| {
280        let mut github = Map::new();
281        github.insert(
282            "ref".to_owned(),
283            Value::String(normalize_ref(&options.common.ref_name)),
284        );
285        github.insert(
286            "ref_name".to_owned(),
287            Value::String(ref_name_only(&options.common.ref_name)),
288        );
289        github.insert("event_name".to_owned(), Value::String("push".to_owned()));
290        Value::Object(github)
291    });
292
293    Value::Object(root)
294}
295
296fn request_from_restore(options: &RestoreOptions) -> CacheRequest {
297    request_from_parts(
298        &options.common,
299        options.key.clone(),
300        options.restore_keys.clone(),
301        options.paths.clone(),
302    )
303}
304
305fn request_from_save(options: &SaveOptions) -> CacheRequest {
306    request_from_parts(
307        &options.common,
308        options.key.clone(),
309        Vec::new(),
310        options.paths.clone(),
311    )
312}
313
314fn validate_request(request: &CacheRequest) -> Vec<Check> {
315    let mut checks = Vec::new();
316    if request.key.is_empty() {
317        checks.push(Check::fail("cache.key", "cache key cannot be empty"));
318    } else if request.key.len() > 512 {
319        checks.push(Check::fail(
320            "cache.key.length",
321            "cache key exceeds GitHub's 512 character limit",
322        ));
323    } else {
324        checks.push(Check::pass(
325            "cache.key",
326            "cache key is present and within the 512 character limit",
327        ));
328    }
329
330    if request.paths.is_empty() {
331        checks.push(Check::fail(
332            "cache.path",
333            "at least one cache path is required",
334        ));
335    } else {
336        checks.push(Check::pass(
337            "cache.path",
338            "at least one cache path is configured",
339        ));
340    }
341
342    if request.accessible_scopes.is_empty() {
343        checks.push(Check::fail(
344            "cache.scope",
345            "no accessible cache scopes were computed",
346        ));
347    } else {
348        checks.push(Check::pass(
349            "cache.scope",
350            format!(
351                "computed {} accessible cache scopes",
352                request.accessible_scopes.len()
353            ),
354        ));
355    }
356
357    checks
358}
359
360fn receipt(
361    mode: &str,
362    store: Utf8PathBuf,
363    workspace: Utf8PathBuf,
364    operations: Vec<CacheOperationReceipt>,
365    workflows: Vec<crate::model::WorkflowCacheReport>,
366) -> CacheProofReceipt {
367    let mut checks = Vec::new();
368    let mut summary = ReceiptSummary::default();
369    for operation in &operations {
370        let op_summary = operation.summary();
371        summary.add(&op_summary);
372        checks.extend(operation.checks.clone());
373    }
374    for workflow in &workflows {
375        summary.add(&workflow.summary);
376        checks.extend(workflow.checks.clone());
377    }
378
379    CacheProofReceipt {
380        schema_version: SCHEMA_VERSION,
381        tool: ToolInfo {
382            name: TOOL_NAME.to_owned(),
383            version: TOOL_VERSION.to_owned(),
384        },
385        checked_at: Utc::now(),
386        mode: mode.to_owned(),
387        store,
388        workspace,
389        summary,
390        operations,
391        workflows,
392        checks,
393    }
394}
395
396fn ref_name_only(value: &str) -> String {
397    value
398        .trim()
399        .trim_start_matches("refs/heads/")
400        .trim_start_matches("refs/tags/")
401        .to_owned()
402}