gradle-build-action/src/cache-extract-entries.ts
2024-01-02 14:28:29 -07:00

467 lines
19 KiB
TypeScript

import path from 'path'
import fs from 'fs'
import * as core from '@actions/core'
import * as glob from '@actions/glob'
import * as semver from 'semver'
import * as params from './input-params'
import {META_FILE_DIR} from './cache-base'
import {CacheEntryListener, CacheListener} from './cache-reporting'
import {cacheDebug, getCacheKeyPrefix, hashFileNames, restoreCache, saveCache, tryDelete} from './cache-utils'
import {BuildResult, loadBuildResults} from './build-results'
const SKIP_RESTORE_VAR = 'GRADLE_BUILD_ACTION_SKIP_RESTORE'
/**
* Represents the result of attempting to load or store an extracted cache entry.
* An undefined cacheKey indicates that the operation did not succeed.
* The collected results are then used to populate the `cache-metadata.json` file for later use.
*/
class ExtractedCacheEntry {
artifactType: string
pattern: string
cacheKey: string | undefined
constructor(artifactType: string, pattern: string, cacheKey: string | undefined) {
this.artifactType = artifactType
this.pattern = pattern
this.cacheKey = cacheKey
}
}
/**
* Representation of all of the extracted cache entries for this Gradle User Home.
* This object is persisted to JSON file in the Gradle User Home directory for storing,
* and subsequently used to restore the Gradle User Home.
*/
class ExtractedCacheEntryMetadata {
entries: ExtractedCacheEntry[] = []
}
/**
* The specification for a type of extracted cache entry.
*/
class ExtractedCacheEntryDefinition {
artifactType: string
pattern: string
bundle: boolean
uniqueFileNames = true
notCacheableReason: string | undefined
constructor(artifactType: string, pattern: string, bundle: boolean) {
this.artifactType = artifactType
this.pattern = pattern
this.bundle = bundle
}
/**
* Indicate that the file names matching the cache entry pattern are NOT sufficient to uniquely identify the contents.
* If the file names are sufficient, then we use a hash of the file names to identify the entry.
* With non-unique-file-names, we hash the file contents to identify the cache entry.
*/
withNonUniqueFileNames(): ExtractedCacheEntryDefinition {
this.uniqueFileNames = false
return this
}
/**
* Specify that the cache entry, should not be saved for some reason, even though the contents exist.
* This is used to prevent configuration-cache entries being cached when they were generated by Gradle < 8.6,
*/
notCacheableBecause(reason: string): ExtractedCacheEntryDefinition {
this.notCacheableReason = reason
return this
}
}
/**
* Caches and restores the entire Gradle User Home directory, extracting entries containing common artifacts
* for more efficient storage.
*/
abstract class AbstractEntryExtractor {
protected readonly gradleUserHome: string
private extractorName: string
constructor(gradleUserHome: string, extractorName: string) {
this.gradleUserHome = gradleUserHome
this.extractorName = extractorName
}
/**
* Restores any artifacts that were cached separately, based on the information in the `cache-metadata.json` file.
* Each extracted cache entry is restored in parallel, except when debugging is enabled.
*/
async restore(listener: CacheListener): Promise<void> {
const previouslyExtractedCacheEntries = this.loadExtractedCacheEntries()
const processes: Promise<ExtractedCacheEntry>[] = []
for (const cacheEntry of previouslyExtractedCacheEntries) {
const artifactType = cacheEntry.artifactType
const entryListener = listener.entry(cacheEntry.pattern)
// Handle case where the extracted-cache-entry definitions have been changed
const skipRestore = process.env[SKIP_RESTORE_VAR] || ''
if (skipRestore.includes(artifactType)) {
core.info(`Not restoring extracted cache entry for ${artifactType}`)
entryListener.markRequested('SKIP_RESTORE')
} else {
processes.push(
this.awaitForDebugging(
this.restoreExtractedCacheEntry(
artifactType,
cacheEntry.cacheKey!,
cacheEntry.pattern,
entryListener
)
)
)
}
}
this.saveMetadataForCacheResults(await Promise.all(processes))
}
private async restoreExtractedCacheEntry(
artifactType: string,
cacheKey: string,
pattern: string,
listener: CacheEntryListener
): Promise<ExtractedCacheEntry> {
const restoredEntry = await restoreCache([pattern], cacheKey, [], listener)
if (restoredEntry) {
core.info(`Restored ${artifactType} with key ${cacheKey} to ${pattern}`)
return new ExtractedCacheEntry(artifactType, pattern, cacheKey)
} else {
core.info(`Did not restore ${artifactType} with key ${cacheKey} to ${pattern}`)
return new ExtractedCacheEntry(artifactType, pattern, undefined)
}
}
/**
* Saves any artifacts that are configured to be cached separately, based on the extracted cache entry definitions.
* Each entry is extracted and saved in parallel, except when debugging is enabled.
*/
async extract(listener: CacheListener): Promise<void> {
// Load the cache entry definitions (from config) and the previously restored entries (from persisted metadata file)
const cacheEntryDefinitions = this.getExtractedCacheEntryDefinitions()
cacheDebug(
`Extracting cache entries for ${this.extractorName}: ${JSON.stringify(cacheEntryDefinitions, null, 2)}`
)
const previouslyRestoredEntries = this.loadExtractedCacheEntries()
const cacheActions: Promise<ExtractedCacheEntry>[] = []
// For each cache entry definition, determine if it has already been restored, and if not, extract it
for (const cacheEntryDefinition of cacheEntryDefinitions) {
const artifactType = cacheEntryDefinition.artifactType
const pattern = cacheEntryDefinition.pattern
if (cacheEntryDefinition.notCacheableReason) {
listener.entry(pattern).markNotSaved(cacheEntryDefinition.notCacheableReason)
continue
}
// Find all matching files for this cache entry definition
const globber = await glob.create(pattern, {
implicitDescendants: false
})
const matchingFiles = await globber.glob()
if (matchingFiles.length === 0) {
cacheDebug(`No files found to cache for ${artifactType}`)
continue
}
if (cacheEntryDefinition.bundle) {
// For an extracted "bundle", use the defined pattern and cache all matching files in a single entry.
cacheActions.push(
this.awaitForDebugging(
this.saveExtractedCacheEntry(
matchingFiles,
artifactType,
pattern,
cacheEntryDefinition.uniqueFileNames,
previouslyRestoredEntries,
listener.entry(pattern)
)
)
)
} else {
// Otherwise cache each matching file in a separate entry, using the complete file path as the cache pattern.
for (const cacheFile of matchingFiles) {
cacheActions.push(
this.awaitForDebugging(
this.saveExtractedCacheEntry(
[cacheFile],
artifactType,
cacheFile,
cacheEntryDefinition.uniqueFileNames,
previouslyRestoredEntries,
listener.entry(cacheFile)
)
)
)
}
}
}
this.saveMetadataForCacheResults(await Promise.all(cacheActions))
}
private async saveExtractedCacheEntry(
matchingFiles: string[],
artifactType: string,
pattern: string,
uniqueFileNames: boolean,
previouslyRestoredEntries: ExtractedCacheEntry[],
entryListener: CacheEntryListener
): Promise<ExtractedCacheEntry> {
const cacheKey = uniqueFileNames
? this.createCacheKeyFromFileNames(artifactType, matchingFiles)
: await this.createCacheKeyFromFileContents(artifactType, pattern)
const previouslyRestoredKey = previouslyRestoredEntries.find(
x => x.artifactType === artifactType && x.pattern === pattern
)?.cacheKey
if (previouslyRestoredKey === cacheKey) {
cacheDebug(`No change to previously restored ${artifactType}. Not saving.`)
entryListener.markNotSaved('contents unchanged')
} else {
core.info(`Caching ${artifactType} with path '${pattern}' and cache key: ${cacheKey}`)
await saveCache([pattern], cacheKey, entryListener)
}
for (const file of matchingFiles) {
tryDelete(file)
}
return new ExtractedCacheEntry(artifactType, pattern, cacheKey)
}
protected createCacheKeyFromFileNames(artifactType: string, files: string[]): string {
const cacheKeyPrefix = getCacheKeyPrefix()
const relativeFiles = files.map(x => path.relative(this.gradleUserHome, x))
const key = hashFileNames(relativeFiles)
cacheDebug(`Generating cache key for ${artifactType} from file names: ${relativeFiles}`)
return `${cacheKeyPrefix}${artifactType}-${key}`
}
protected async createCacheKeyFromFileContents(artifactType: string, pattern: string): Promise<string> {
const cacheKeyPrefix = getCacheKeyPrefix()
const key = await glob.hashFiles(pattern)
cacheDebug(`Generating cache key for ${artifactType} from files matching: ${pattern}`)
return `${cacheKeyPrefix}${artifactType}-${key}`
}
// Run actions sequentially if debugging is enabled
private async awaitForDebugging(p: Promise<ExtractedCacheEntry>): Promise<ExtractedCacheEntry> {
if (params.isCacheDebuggingEnabled()) {
await p
}
return p
}
/**
* Load information about the extracted cache entries previously restored/saved. This is loaded from the 'cache-metadata.json' file.
*/
protected loadExtractedCacheEntries(): ExtractedCacheEntry[] {
const cacheMetadataFile = this.getCacheMetadataFile()
if (!fs.existsSync(cacheMetadataFile)) {
return []
}
const filedata = fs.readFileSync(cacheMetadataFile, 'utf-8')
cacheDebug(`Loaded cache metadata for ${this.extractorName}: ${filedata}`)
const extractedCacheEntryMetadata = JSON.parse(filedata) as ExtractedCacheEntryMetadata
return extractedCacheEntryMetadata.entries
}
/**
* Saves information about the extracted cache entries into the 'cache-metadata.json' file.
*/
protected saveMetadataForCacheResults(results: ExtractedCacheEntry[]): void {
const extractedCacheEntryMetadata = new ExtractedCacheEntryMetadata()
extractedCacheEntryMetadata.entries = results.filter(x => x.cacheKey !== undefined)
const filedata = JSON.stringify(extractedCacheEntryMetadata)
cacheDebug(`Saving cache metadata for ${this.extractorName}: ${filedata}`)
fs.writeFileSync(this.getCacheMetadataFile(), filedata, 'utf-8')
}
private getCacheMetadataFile(): string {
const actionMetadataDirectory = path.resolve(this.gradleUserHome, META_FILE_DIR)
fs.mkdirSync(actionMetadataDirectory, {recursive: true})
return path.resolve(actionMetadataDirectory, `${this.extractorName}-entry-metadata.json`)
}
protected abstract getExtractedCacheEntryDefinitions(): ExtractedCacheEntryDefinition[]
}
export class GradleHomeEntryExtractor extends AbstractEntryExtractor {
constructor(gradleUserHome: string) {
super(gradleUserHome, 'gradle-home')
}
async extract(listener: CacheListener): Promise<void> {
await this.deleteWrapperZips()
return super.extract(listener)
}
/**
* Delete any downloaded wrapper zip files that are not needed after extraction.
* These files are cleaned up by Gradle >= 7.5, but for older versions we remove them manually.
*/
private async deleteWrapperZips(): Promise<void> {
const wrapperZips = path.resolve(this.gradleUserHome, 'wrapper/dists/*/*/*.zip')
const globber = await glob.create(wrapperZips, {
implicitDescendants: false
})
for (const wrapperZip of await globber.glob()) {
cacheDebug(`Deleting wrapper zip: ${wrapperZip}`)
await tryDelete(wrapperZip)
}
}
/**
* Return the extracted cache entry definitions, which determine which artifacts will be cached
* separately from the rest of the Gradle User Home cache entry.
*/
protected getExtractedCacheEntryDefinitions(): ExtractedCacheEntryDefinition[] {
const entryDefinition = (
artifactType: string,
patterns: string[],
bundle: boolean
): ExtractedCacheEntryDefinition => {
const resolvedPatterns = patterns
.map(x => {
const isDir = x.endsWith('/')
const resolved = path.resolve(this.gradleUserHome, x)
return isDir ? `${resolved}/` : resolved // Restore trailing '/' removed by path.resolve()
})
.join('\n')
return new ExtractedCacheEntryDefinition(artifactType, resolvedPatterns, bundle)
}
return [
entryDefinition('generated-gradle-jars', ['caches/*/generated-gradle-jars/*.jar'], false),
entryDefinition('wrapper-zips', ['wrapper/dists/*/*/'], false), // Each wrapper directory cached separately
entryDefinition('java-toolchains', ['jdks/*/'], false), // Each extracted JDK cached separately
entryDefinition('dependencies', ['caches/modules-*/files-*/*/*/*/*'], true),
entryDefinition('instrumented-jars', ['caches/jars-*/*'], true),
entryDefinition('kotlin-dsl', ['caches/*/kotlin-dsl/*/*'], true)
]
}
}
export class ConfigurationCacheEntryExtractor extends AbstractEntryExtractor {
constructor(gradleUserHome: string) {
super(gradleUserHome, 'configuration-cache')
}
/**
* Handle the case where Gradle User Home has not been fully restored, so that the configuration-cache
* entry is not reusable.
*/
async restore(listener: CacheListener): Promise<void> {
if (!listener.fullyRestored) {
this.markNotRestored(listener, 'Gradle User Home was not fully restored')
return
}
if (!params.getCacheEncryptionKey()) {
this.markNotRestored(listener, 'Encryption Key was not provided')
return
}
return await super.restore(listener)
}
private markNotRestored(listener: CacheListener, reason: string): void {
const cacheEntries = this.loadExtractedCacheEntries()
if (cacheEntries.length > 0) {
core.info(`Not restoring configuration-cache state, as ${reason}`)
for (const cacheEntry of cacheEntries) {
listener.entry(cacheEntry.pattern).markNotRestored(reason)
}
// Update the results file based on no entries restored
this.saveMetadataForCacheResults([])
}
}
async extract(listener: CacheListener): Promise<void> {
if (!params.getCacheEncryptionKey()) {
const cacheEntryDefinitions = this.getExtractedCacheEntryDefinitions()
if (cacheEntryDefinitions.length > 0) {
core.info('Not saving configuration-cache state, as no encryption key was provided')
for (const cacheEntry of cacheEntryDefinitions) {
listener.entry(cacheEntry.pattern).markNotSaved('No encryption key provided')
}
}
return
}
await super.extract(listener)
}
/**
* Extract cache entries for the configuration cache in each project.
*/
protected getExtractedCacheEntryDefinitions(): ExtractedCacheEntryDefinition[] {
// Group BuildResult by existing configCacheDir
const groupedResults = this.getConfigCacheDirectoriesWithAssociatedBuildResults()
return Object.entries(groupedResults).map(([configCachePath, pathResults]) => {
// Create a entry definition for each unique configuration cache directory
const definition = new ExtractedCacheEntryDefinition(
'configuration-cache',
configCachePath,
true
).withNonUniqueFileNames()
// If any associated build result used Gradle < 8.6, then mark it as not cacheable
if (
pathResults.find(result => {
const gradleVersion = semver.coerce(result.gradleVersion)
return gradleVersion && semver.lt(gradleVersion, '8.6.0')
})
) {
core.info(
`Not saving config-cache data for ${configCachePath}. Configuration cache data is only saved for Gradle 8.6+`
)
definition.notCacheableBecause('Configuration cache data only saved for Gradle 8.6+')
}
return definition
})
}
private getConfigCacheDirectoriesWithAssociatedBuildResults(): Record<string, BuildResult[]> {
return loadBuildResults().reduce(
(acc, buildResult) => {
// For each build result, find the config-cache dir
const configCachePath = path.resolve(buildResult.rootProjectDir, '.gradle/configuration-cache')
// Ignore case where config-cache dir doesn't exist
if (!fs.existsSync(configCachePath)) {
return acc
}
// Group by unique config cache directories and collect associated build results
if (!acc[configCachePath]) {
acc[configCachePath] = []
}
acc[configCachePath].push(buildResult)
return acc
},
{} as Record<string, BuildResult[]>
)
}
}