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}