Merge pull request #2100 from actions/copilot/fix-2099
Improve cache service availability determination and implement conditional error logging
This commit is contained in:
+63
-8
@@ -1,14 +1,69 @@
|
|||||||
import * as cache from '../src/cache'
|
import * as cache from '../src/cache'
|
||||||
|
|
||||||
test('isFeatureAvailable returns true if server url is set', () => {
|
describe('isFeatureAvailable', () => {
|
||||||
try {
|
const originalEnv = process.env
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules()
|
||||||
|
process.env = {...originalEnv}
|
||||||
|
// Clean cache-related environment variables
|
||||||
|
delete process.env['ACTIONS_CACHE_URL']
|
||||||
|
delete process.env['ACTIONS_RESULTS_URL']
|
||||||
|
delete process.env['ACTIONS_CACHE_SERVICE_V2']
|
||||||
|
delete process.env['GITHUB_SERVER_URL']
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for cache service v1 when ACTIONS_CACHE_URL is set', () => {
|
||||||
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
|
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
|
||||||
expect(cache.isFeatureAvailable()).toBe(true)
|
expect(cache.isFeatureAvailable()).toBe(true)
|
||||||
} finally {
|
})
|
||||||
delete process.env['ACTIONS_CACHE_URL']
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('isFeatureAvailable returns false if server url is not set', () => {
|
test('returns false for cache service v1 when only ACTIONS_RESULTS_URL is set', () => {
|
||||||
expect(cache.isFeatureAvailable()).toBe(false)
|
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for cache service v1 when both URLs are set', () => {
|
||||||
|
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
|
||||||
|
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for cache service v2 when ACTIONS_RESULTS_URL is set', () => {
|
||||||
|
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
|
||||||
|
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for cache service v2 when only ACTIONS_CACHE_URL is set', () => {
|
||||||
|
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
|
||||||
|
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when no cache URLs are set', () => {
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for cache service v2 when no URLs are set', () => {
|
||||||
|
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns true for GHES with v1 even when v2 flag is set', () => {
|
||||||
|
process.env['GITHUB_SERVER_URL'] = 'https://my-enterprise.github.com'
|
||||||
|
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
|
||||||
|
process.env['ACTIONS_CACHE_URL'] = 'http://cache.com'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false for GHES with only ACTIONS_RESULTS_URL', () => {
|
||||||
|
process.env['GITHUB_SERVER_URL'] = 'https://my-enterprise.github.com'
|
||||||
|
process.env['ACTIONS_RESULTS_URL'] = 'http://results.com'
|
||||||
|
expect(cache.isFeatureAvailable()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+18
-6
@@ -6,6 +6,8 @@ import * as cacheUtils from '../src/internal/cacheUtils'
|
|||||||
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
||||||
import {ArtifactCacheEntry} from '../src/internal/contracts'
|
import {ArtifactCacheEntry} from '../src/internal/contracts'
|
||||||
import * as tar from '../src/internal/tar'
|
import * as tar from '../src/internal/tar'
|
||||||
|
import {HttpClientError} from '@actions/http-client'
|
||||||
|
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
|
||||||
|
|
||||||
jest.mock('../src/internal/cacheHttpClient')
|
jest.mock('../src/internal/cacheHttpClient')
|
||||||
jest.mock('../src/internal/cacheUtils')
|
jest.mock('../src/internal/cacheUtils')
|
||||||
@@ -73,18 +75,28 @@ test('restore with no cache found', async () => {
|
|||||||
test('restore with server error should fail', async () => {
|
test('restore with server error should fail', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
const logWarningMock = jest.spyOn(core, 'warning')
|
const logErrorMock = jest.spyOn(core, 'error')
|
||||||
|
|
||||||
jest.spyOn(cacheHttpClient, 'getCacheEntry').mockImplementation(() => {
|
// Set cache service to V2 to test error logging for server errors
|
||||||
throw new Error('HTTP Error Occurred')
|
process.env['ACTIONS_CACHE_SERVICE_V2'] = 'true'
|
||||||
})
|
process.env['ACTIONS_RESULTS_URL'] = 'https://results.local/'
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(CacheServiceClientJSON.prototype, 'GetCacheEntryDownloadURL')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
throw new HttpClientError('HTTP Error Occurred', 500)
|
||||||
|
})
|
||||||
|
|
||||||
const cacheKey = await restoreCache(paths, key)
|
const cacheKey = await restoreCache(paths, key)
|
||||||
expect(cacheKey).toBe(undefined)
|
expect(cacheKey).toBe(undefined)
|
||||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
expect(logErrorMock).toHaveBeenCalledTimes(1)
|
||||||
expect(logWarningMock).toHaveBeenCalledWith(
|
expect(logErrorMock).toHaveBeenCalledWith(
|
||||||
'Failed to restore: HTTP Error Occurred'
|
'Failed to restore: HTTP Error Occurred'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Clean up environment
|
||||||
|
delete process.env['ACTIONS_CACHE_SERVICE_V2']
|
||||||
|
delete process.env['ACTIONS_RESULTS_URL']
|
||||||
})
|
})
|
||||||
|
|
||||||
test('restore with restore keys and no cache found', async () => {
|
test('restore with restore keys and no cache found', async () => {
|
||||||
|
|||||||
+5
-4
@@ -8,6 +8,7 @@ import {restoreCache} from '../src/cache'
|
|||||||
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
||||||
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
|
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
|
||||||
import {DownloadOptions} from '../src/options'
|
import {DownloadOptions} from '../src/options'
|
||||||
|
import {HttpClientError} from '@actions/http-client'
|
||||||
|
|
||||||
jest.mock('../src/internal/cacheHttpClient')
|
jest.mock('../src/internal/cacheHttpClient')
|
||||||
jest.mock('../src/internal/cacheUtils')
|
jest.mock('../src/internal/cacheUtils')
|
||||||
@@ -95,18 +96,18 @@ test('restore with no cache found', async () => {
|
|||||||
test('restore with server error should fail', async () => {
|
test('restore with server error should fail', async () => {
|
||||||
const paths = ['node_modules']
|
const paths = ['node_modules']
|
||||||
const key = 'node-test'
|
const key = 'node-test'
|
||||||
const logWarningMock = jest.spyOn(core, 'warning')
|
const logErrorMock = jest.spyOn(core, 'error')
|
||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(CacheServiceClientJSON.prototype, 'GetCacheEntryDownloadURL')
|
.spyOn(CacheServiceClientJSON.prototype, 'GetCacheEntryDownloadURL')
|
||||||
.mockImplementation(() => {
|
.mockImplementation(() => {
|
||||||
throw new Error('HTTP Error Occurred')
|
throw new HttpClientError('HTTP Error Occurred', 500)
|
||||||
})
|
})
|
||||||
|
|
||||||
const cacheKey = await restoreCache(paths, key)
|
const cacheKey = await restoreCache(paths, key)
|
||||||
expect(cacheKey).toBe(undefined)
|
expect(cacheKey).toBe(undefined)
|
||||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
expect(logErrorMock).toHaveBeenCalledTimes(1)
|
||||||
expect(logWarningMock).toHaveBeenCalledWith(
|
expect(logErrorMock).toHaveBeenCalledWith(
|
||||||
'Failed to restore: HTTP Error Occurred'
|
'Failed to restore: HTTP Error Occurred'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+30
-28
@@ -7,11 +7,12 @@ import * as config from '../src/internal/config'
|
|||||||
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
import {CacheFilename, CompressionMethod} from '../src/internal/constants'
|
||||||
import * as tar from '../src/internal/tar'
|
import * as tar from '../src/internal/tar'
|
||||||
import {TypedResponse} from '@actions/http-client/lib/interfaces'
|
import {TypedResponse} from '@actions/http-client/lib/interfaces'
|
||||||
|
import {HttpClientError} from '@actions/http-client'
|
||||||
import {
|
import {
|
||||||
ReserveCacheResponse,
|
ReserveCacheResponse,
|
||||||
ITypedResponseWithError
|
ITypedResponseWithError
|
||||||
} from '../src/internal/contracts'
|
} from '../src/internal/contracts'
|
||||||
import {HttpClientError} from '@actions/http-client'
|
import {CacheServiceClientJSON} from '../src/generated/results/api/v1/cache.twirp-client'
|
||||||
|
|
||||||
jest.mock('../src/internal/cacheHttpClient')
|
jest.mock('../src/internal/cacheHttpClient')
|
||||||
jest.mock('../src/internal/cacheUtils')
|
jest.mock('../src/internal/cacheUtils')
|
||||||
@@ -223,45 +224,48 @@ test('save with reserve cache failure should fail', async () => {
|
|||||||
test('save with server error should fail', async () => {
|
test('save with server error should fail', async () => {
|
||||||
const filePath = 'node_modules'
|
const filePath = 'node_modules'
|
||||||
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
const primaryKey = 'Linux-node-bb828da54c148048dd17899ba9fda624811cfb43'
|
||||||
const cachePaths = [path.resolve(filePath)]
|
const logErrorMock = jest.spyOn(core, 'error')
|
||||||
const logWarningMock = jest.spyOn(core, 'warning')
|
const logWarningMock = jest.spyOn(core, 'warning')
|
||||||
const cacheId = 4
|
|
||||||
const reserveCacheMock = jest
|
// Mock cache service version to V2
|
||||||
.spyOn(cacheHttpClient, 'reserveCache')
|
const getCacheServiceVersionMock = jest.spyOn(config, 'getCacheServiceVersion').mockReturnValue('v2')
|
||||||
.mockImplementation(async () => {
|
|
||||||
const response: TypedResponse<ReserveCacheResponse> = {
|
// Mock V2 CreateCacheEntry to succeed
|
||||||
statusCode: 500,
|
const createCacheEntryMock = jest
|
||||||
result: {cacheId},
|
.spyOn(CacheServiceClientJSON.prototype, 'CreateCacheEntry')
|
||||||
headers: {}
|
.mockReturnValue(
|
||||||
}
|
Promise.resolve({ok: true, signedUploadUrl: 'https://blob-storage.local?signed=true'})
|
||||||
return response
|
)
|
||||||
})
|
|
||||||
|
// Mock the FinalizeCacheEntryUpload to succeed (since the error should happen in saveCache)
|
||||||
|
const finalizeCacheEntryMock = jest
|
||||||
|
.spyOn(CacheServiceClientJSON.prototype, 'FinalizeCacheEntryUpload')
|
||||||
|
.mockReturnValue(Promise.resolve({ok: true, entryId: '4'}))
|
||||||
|
|
||||||
const createTarMock = jest.spyOn(tar, 'createTar')
|
const createTarMock = jest.spyOn(tar, 'createTar')
|
||||||
|
|
||||||
|
// Mock the saveCache call to throw a server error
|
||||||
const saveCacheMock = jest
|
const saveCacheMock = jest
|
||||||
.spyOn(cacheHttpClient, 'saveCache')
|
.spyOn(cacheHttpClient, 'saveCache')
|
||||||
.mockImplementationOnce(() => {
|
.mockImplementationOnce(() => {
|
||||||
throw new Error('HTTP Error Occurred')
|
throw new HttpClientError('HTTP Error Occurred', 500)
|
||||||
})
|
})
|
||||||
|
|
||||||
const compression = CompressionMethod.Zstd
|
const compression = CompressionMethod.Zstd
|
||||||
const getCompressionMock = jest
|
const getCompressionMock = jest
|
||||||
.spyOn(cacheUtils, 'getCompressionMethod')
|
.spyOn(cacheUtils, 'getCompressionMethod')
|
||||||
.mockReturnValueOnce(Promise.resolve(compression))
|
.mockReturnValueOnce(Promise.resolve(compression))
|
||||||
|
|
||||||
await saveCache([filePath], primaryKey)
|
await saveCache([filePath], primaryKey)
|
||||||
expect(logWarningMock).toHaveBeenCalledTimes(1)
|
|
||||||
expect(logWarningMock).toHaveBeenCalledWith(
|
expect(logErrorMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(logErrorMock).toHaveBeenCalledWith(
|
||||||
'Failed to save: HTTP Error Occurred'
|
'Failed to save: HTTP Error Occurred'
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(reserveCacheMock).toHaveBeenCalledTimes(1)
|
expect(createCacheEntryMock).toHaveBeenCalledTimes(1)
|
||||||
expect(reserveCacheMock).toHaveBeenCalledWith(primaryKey, [filePath], {
|
|
||||||
cacheSize: undefined,
|
|
||||||
compressionMethod: compression,
|
|
||||||
enableCrossOsArchive: false
|
|
||||||
})
|
|
||||||
const archiveFolder = '/foo/bar'
|
const archiveFolder = '/foo/bar'
|
||||||
|
const cachePaths = [path.resolve(filePath)]
|
||||||
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
|
const archiveFile = path.join(archiveFolder, CacheFilename.Zstd)
|
||||||
expect(createTarMock).toHaveBeenCalledTimes(1)
|
expect(createTarMock).toHaveBeenCalledTimes(1)
|
||||||
expect(createTarMock).toHaveBeenCalledWith(
|
expect(createTarMock).toHaveBeenCalledWith(
|
||||||
@@ -270,13 +274,11 @@ test('save with server error should fail', async () => {
|
|||||||
compression
|
compression
|
||||||
)
|
)
|
||||||
expect(saveCacheMock).toHaveBeenCalledTimes(1)
|
expect(saveCacheMock).toHaveBeenCalledTimes(1)
|
||||||
expect(saveCacheMock).toHaveBeenCalledWith(
|
|
||||||
cacheId,
|
|
||||||
archiveFile,
|
|
||||||
'',
|
|
||||||
undefined
|
|
||||||
)
|
|
||||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Restore the getCacheServiceVersion mock to its original state
|
||||||
|
getCacheServiceVersionMock.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save with valid inputs uploads a cache', async () => {
|
test('save with valid inputs uploads a cache', async () => {
|
||||||
|
|||||||
Vendored
+54
-6
@@ -13,6 +13,7 @@ import {
|
|||||||
GetCacheEntryDownloadURLRequest
|
GetCacheEntryDownloadURLRequest
|
||||||
} from './generated/results/api/v1/cache'
|
} from './generated/results/api/v1/cache'
|
||||||
import {CacheFileSizeLimit} from './internal/constants'
|
import {CacheFileSizeLimit} from './internal/constants'
|
||||||
|
import {HttpClientError} from '@actions/http-client'
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message)
|
super(message)
|
||||||
@@ -57,7 +58,18 @@ function checkKey(key: string): void {
|
|||||||
* @returns boolean return true if Actions cache service feature is available, otherwise false
|
* @returns boolean return true if Actions cache service feature is available, otherwise false
|
||||||
*/
|
*/
|
||||||
export function isFeatureAvailable(): boolean {
|
export function isFeatureAvailable(): boolean {
|
||||||
return !!process.env['ACTIONS_CACHE_URL']
|
const cacheServiceVersion = getCacheServiceVersion()
|
||||||
|
|
||||||
|
// Check availability based on cache service version
|
||||||
|
switch (cacheServiceVersion) {
|
||||||
|
case 'v2':
|
||||||
|
// For v2, we need ACTIONS_RESULTS_URL
|
||||||
|
return !!process.env['ACTIONS_RESULTS_URL']
|
||||||
|
case 'v1':
|
||||||
|
default:
|
||||||
|
// For v1, we only need ACTIONS_CACHE_URL
|
||||||
|
return !!process.env['ACTIONS_CACHE_URL']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,8 +198,17 @@ async function restoreCacheV1(
|
|||||||
if (typedError.name === ValidationError.name) {
|
if (typedError.name === ValidationError.name) {
|
||||||
throw error
|
throw error
|
||||||
} else {
|
} else {
|
||||||
// Supress all non-validation cache related errors because caching should be optional
|
// warn on cache restore failure and continue build
|
||||||
core.warning(`Failed to restore: ${(error as Error).message}`)
|
// Log server errors (5xx) as errors, all other errors as warnings
|
||||||
|
if (
|
||||||
|
typedError instanceof HttpClientError &&
|
||||||
|
typeof typedError.statusCode === 'number' &&
|
||||||
|
typedError.statusCode >= 500
|
||||||
|
) {
|
||||||
|
core.error(`Failed to restore: ${(error as Error).message}`)
|
||||||
|
} else {
|
||||||
|
core.warning(`Failed to restore: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Try to delete the archive to save space
|
// Try to delete the archive to save space
|
||||||
@@ -310,7 +331,16 @@ async function restoreCacheV2(
|
|||||||
throw error
|
throw error
|
||||||
} else {
|
} else {
|
||||||
// Supress all non-validation cache related errors because caching should be optional
|
// Supress all non-validation cache related errors because caching should be optional
|
||||||
core.warning(`Failed to restore: ${(error as Error).message}`)
|
// Log server errors (5xx) as errors, all other errors as warnings
|
||||||
|
if (
|
||||||
|
typedError instanceof HttpClientError &&
|
||||||
|
typeof typedError.statusCode === 'number' &&
|
||||||
|
typedError.statusCode >= 500
|
||||||
|
) {
|
||||||
|
core.error(`Failed to restore: ${(error as Error).message}`)
|
||||||
|
} else {
|
||||||
|
core.warning(`Failed to restore: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
@@ -442,7 +472,16 @@ async function saveCacheV1(
|
|||||||
} else if (typedError.name === ReserveCacheError.name) {
|
} else if (typedError.name === ReserveCacheError.name) {
|
||||||
core.info(`Failed to save: ${typedError.message}`)
|
core.info(`Failed to save: ${typedError.message}`)
|
||||||
} else {
|
} else {
|
||||||
core.warning(`Failed to save: ${typedError.message}`)
|
// Log server errors (5xx) as errors, all other errors as warnings
|
||||||
|
if (
|
||||||
|
typedError instanceof HttpClientError &&
|
||||||
|
typeof typedError.statusCode === 'number' &&
|
||||||
|
typedError.statusCode >= 500
|
||||||
|
) {
|
||||||
|
core.error(`Failed to save: ${typedError.message}`)
|
||||||
|
} else {
|
||||||
|
core.warning(`Failed to save: ${typedError.message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Try to delete the archive to save space
|
// Try to delete the archive to save space
|
||||||
@@ -581,7 +620,16 @@ async function saveCacheV2(
|
|||||||
} else if (typedError.name === ReserveCacheError.name) {
|
} else if (typedError.name === ReserveCacheError.name) {
|
||||||
core.info(`Failed to save: ${typedError.message}`)
|
core.info(`Failed to save: ${typedError.message}`)
|
||||||
} else {
|
} else {
|
||||||
core.warning(`Failed to save: ${typedError.message}`)
|
// Log server errors (5xx) as errors, all other errors as warnings
|
||||||
|
if (
|
||||||
|
typedError instanceof HttpClientError &&
|
||||||
|
typeof typedError.statusCode === 'number' &&
|
||||||
|
typedError.statusCode >= 500
|
||||||
|
) {
|
||||||
|
core.error(`Failed to save: ${typedError.message}`)
|
||||||
|
} else {
|
||||||
|
core.warning(`Failed to save: ${typedError.message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Try to delete the archive to save space
|
// Try to delete the archive to save space
|
||||||
|
|||||||
Reference in New Issue
Block a user