cache/src/custom/cache.ts
Brenden Matthews a28af779d2
Add S3 cache download validation and retry logic
- Add empty file validation (0 bytes) and minimum size checks (512 bytes) for tar archives
- Implement download completeness validation (bytes downloaded = expected)
- Add retry logic with exponential backoff for validation failures (3 attempts: 1s/2s/4s delays)
- Create DownloadValidationError class for specific validation failures
- Add comprehensive test coverage for validation scenarios
- Maintain graceful degradation - validation failures log warnings but don't fail workflows
2025-06-20 16:35:40 -04:00

265 lines
8.4 KiB
TypeScript

// https://github.com/actions/toolkit/blob/%40actions/cache%403.2.2/packages/cache/src/cache.ts
import * as core from "@actions/core";
import * as path from "path";
import * as utils from "@actions/cache/lib/internal/cacheUtils";
import * as cacheHttpClient from "./backend";
import {
createTar,
extractTar,
listTar
} from "@actions/cache/lib/internal/tar";
import { DownloadOptions, UploadOptions } from "@actions/cache/lib/options";
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
Object.setPrototypeOf(this, ValidationError.prototype);
}
}
export class ReserveCacheError extends Error {
constructor(message: string) {
super(message);
this.name = "ReserveCacheError";
Object.setPrototypeOf(this, ReserveCacheError.prototype);
}
}
export class DownloadValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "DownloadValidationError";
Object.setPrototypeOf(this, DownloadValidationError.prototype);
}
}
function checkPaths(paths: string[]): void {
if (!paths || paths.length === 0) {
throw new ValidationError(
`Path Validation Error: At least one directory or file path is required`
);
}
}
function checkKey(key: string): void {
if (key.length > 512) {
throw new ValidationError(
`Key Validation Error: ${key} cannot be larger than 512 characters.`
);
}
const regex = /^[^,]*$/;
if (!regex.test(key)) {
throw new ValidationError(
`Key Validation Error: ${key} cannot contain commas.`
);
}
}
/**
* isFeatureAvailable to check the presence of Actions cache service
*
* @returns boolean return true if Actions cache service feature is available, otherwise false
*/
export function isFeatureAvailable(): boolean {
return !!process.env["ACTIONS_CACHE_URL"];
}
/**
* Restores cache from keys
*
* @param paths a list of file paths to restore from the cache
* @param primaryKey an explicit key for restoring the cache
* @param restoreKeys an optional ordered list of keys to use for restoring the cache if no cache hit occurred for key
* @param downloadOptions cache download options
* @param enableCrossOsArchive an optional boolean enabled to restore on windows any cache created on any platform
* @returns string returns the key for the cache hit, otherwise returns undefined
*/
export async function restoreCache(
paths: string[],
primaryKey: string,
restoreKeys?: string[],
options?: DownloadOptions,
enableCrossOsArchive = false
): Promise<string | undefined> {
checkPaths(paths);
restoreKeys = restoreKeys || [];
const keys = [primaryKey, ...restoreKeys];
core.debug("Resolved Keys:");
core.debug(JSON.stringify(keys));
if (keys.length > 10) {
throw new ValidationError(
`Key Validation Error: Keys are limited to a maximum of 10.`
);
}
for (const key of keys) {
checkKey(key);
}
const compressionMethod = await utils.getCompressionMethod();
let archivePath = "";
try {
// path are needed to compute version
const cacheEntry = await cacheHttpClient.getCacheEntry(keys, paths, {
compressionMethod,
enableCrossOsArchive
});
if (!cacheEntry?.archiveLocation) {
// Cache not found
return undefined;
}
if (options?.lookupOnly) {
core.info("Lookup only - skipping download");
return cacheEntry.cacheKey;
}
archivePath = path.join(
await utils.createTempDirectory(),
utils.getCacheFileName(compressionMethod)
);
core.debug(`Archive Path: ${archivePath}`);
// Download the cache from the cache entry
await cacheHttpClient.downloadCache(
cacheEntry.archiveLocation,
archivePath,
options
);
if (core.isDebug()) {
await listTar(archivePath, compressionMethod);
}
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath);
core.info(
`Cache Size: ~${Math.round(
archiveFileSize / (1024 * 1024)
)} MB (${archiveFileSize} B)`
);
// Validate downloaded archive
if (archiveFileSize === 0) {
throw new DownloadValidationError(
"Downloaded cache archive is empty (0 bytes). This may indicate a failed download or corrupted cache."
);
}
// Minimum size check - a valid tar archive needs at least 512 bytes for header
const MIN_ARCHIVE_SIZE = 512;
if (archiveFileSize < MIN_ARCHIVE_SIZE) {
throw new DownloadValidationError(
`Downloaded cache archive is too small (${archiveFileSize} bytes). Expected at least ${MIN_ARCHIVE_SIZE} bytes for a valid archive.`
);
}
await extractTar(archivePath, compressionMethod);
core.info("Cache restored successfully");
return cacheEntry.cacheKey;
} catch (error) {
const typedError = error as Error;
if (typedError.name === ValidationError.name) {
throw error;
} else if (typedError.name === DownloadValidationError.name) {
// Log download validation errors as warnings but don't fail the workflow
core.warning(
`Cache download validation failed: ${typedError.message}`
);
} else {
// Supress all non-validation cache related errors because caching should be optional
core.warning(`Failed to restore: ${(error as Error).message}`);
}
} finally {
// Try to delete the archive to save space
try {
await utils.unlinkFile(archivePath);
} catch (error) {
core.debug(`Failed to delete archive: ${error}`);
}
}
return undefined;
}
/**
* Saves a list of files with the specified key
*
* @param paths a list of file paths to be cached
* @param key an explicit key for restoring the cache
* @param enableCrossOsArchive an optional boolean enabled to save cache on windows which could be restored on any platform
* @param options cache upload options
* @returns number returns cacheId if the cache was saved successfully and throws an error if save fails
*/
export async function saveCache(
paths: string[],
key: string,
options?: UploadOptions,
enableCrossOsArchive = false
): Promise<number> {
checkPaths(paths);
checkKey(key);
const compressionMethod = await utils.getCompressionMethod();
let cacheId = -1;
const cachePaths = await utils.resolvePaths(paths);
core.debug("Cache Paths:");
core.debug(`${JSON.stringify(cachePaths)}`);
if (cachePaths.length === 0) {
throw new Error(
`Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.`
);
}
const archiveFolder = await utils.createTempDirectory();
const archivePath = path.join(
archiveFolder,
utils.getCacheFileName(compressionMethod)
);
core.debug(`Archive Path: ${archivePath}`);
try {
await createTar(archiveFolder, cachePaths, compressionMethod);
if (core.isDebug()) {
await listTar(archivePath, compressionMethod);
}
const archiveFileSize = utils.getArchiveFileSizeInBytes(archivePath);
core.debug(`File Size: ${archiveFileSize}`);
await cacheHttpClient.saveCache(key, paths, archivePath, {
compressionMethod,
enableCrossOsArchive,
cacheSize: archiveFileSize
});
// dummy cacheId, if we get there without raising, it means the cache has been saved
cacheId = 1;
} catch (error) {
const typedError = error as Error;
if (typedError.name === ValidationError.name) {
throw error;
} else if (typedError.name === ReserveCacheError.name) {
core.info(`Failed to save: ${typedError.message}`);
} else {
core.warning(`Failed to save: ${typedError.message}`);
}
} finally {
// Try to delete the archive to save space
try {
await utils.unlinkFile(archivePath);
} catch (error) {
core.debug(`Failed to delete archive: ${error}`);
}
}
return cacheId;
}