-
Notifications
You must be signed in to change notification settings - Fork 350
Commit
- Loading branch information
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import {TypedResponse} from '@actions/http-client/lib/interfaces'; | ||
Check failure on line 1 in src/classes/actions-cache/http-responses.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 1 in src/classes/actions-cache/http-responses.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
import {HttpClientError} from '@actions/http-client'; | ||
Check failure on line 2 in src/classes/actions-cache/http-responses.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 2 in src/classes/actions-cache/http-responses.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
|
||
export const isSuccessStatusCode = (statusCode?: number): boolean => { | ||
if (!statusCode) { | ||
return false; | ||
} | ||
return statusCode >= 200 && statusCode < 300; | ||
}; | ||
export function isServerErrorStatusCode(statusCode?: number): boolean { | ||
if (!statusCode) { | ||
return true; | ||
} | ||
return statusCode >= 500; | ||
} | ||
|
||
export interface TypedResponseWithError<T> extends TypedResponse<T> { | ||
error?: HttpClientError; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,234 @@ | ||
import * as core from '@actions/core'; | ||
import fs from 'fs'; | ||
import {HttpClient, HttpClientResponse} from '@actions/http-client'; | ||
Check warning on line 3 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 3 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check warning on line 3 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
Check failure on line 3 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
Check warning on line 3 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (macos-latest)
|
||
import {BearerCredentialHandler} from '@actions/http-client/lib/auth'; | ||
Check failure on line 4 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 4 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
import { | ||
RequestOptions, | ||
TypedResponse | ||
} from '@actions/http-client/lib/interfaces'; | ||
Check failure on line 8 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 8 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
import {ReserveCacheError, ValidationError} from '@actions/cache'; | ||
import {isSuccessStatusCode, TypedResponseWithError} from './http-responses'; | ||
import {retryHttpClientResponse, retryTypedResponse} from './retry'; | ||
import {IncomingMessage} from 'http'; | ||
import {getOctokit} from '@actions/github'; | ||
import {retry} from '@octokit/plugin-retry'; | ||
|
||
const uploadChunk = async ( | ||
httpClient: HttpClient, | ||
resourceUrl: string, | ||
openStream: () => NodeJS.ReadableStream, | ||
start: number, | ||
end: number | ||
): Promise<void> => { | ||
// Format: `bytes start-end/filesize | ||
// start and end are inclusive | ||
// filesize can be * | ||
// For a 200 byte chunk starting at byte 0: | ||
// Content-Range: bytes 0-199/* | ||
const contentRange = `bytes ${start}-${end}/*`; | ||
core.debug( | ||
`Uploading chunk of size ${ | ||
end - start + 1 | ||
} bytes at offset ${start} with content range: ${contentRange}` | ||
); | ||
const additionalHeaders = { | ||
'Content-Type': 'application/octet-stream', | ||
'Content-Range': contentRange | ||
}; | ||
|
||
const uploadChunkResponse = await retryHttpClientResponse( | ||
`uploadChunk (start: ${start}, end: ${end})`, | ||
async () => | ||
httpClient.sendStream( | ||
'PATCH', | ||
resourceUrl, | ||
openStream(), | ||
additionalHeaders | ||
) | ||
); | ||
|
||
if (!isSuccessStatusCode(uploadChunkResponse.message.statusCode)) { | ||
throw new Error( | ||
`Cache service responded with ${uploadChunkResponse.message.statusCode} during upload chunk.` | ||
); | ||
} | ||
}; | ||
|
||
const getCacheApiUrl = (resource: string): string => { | ||
const baseUrl: string = process.env['ACTIONS_CACHE_URL'] || ''; | ||
if (!baseUrl) { | ||
throw new Error('Cache Service Url not found, unable to restore cache.'); | ||
} | ||
|
||
const url = `${baseUrl}_apis/artifactcache/${resource}`; | ||
core.debug(`Resource Url: ${url}`); | ||
return url; | ||
}; | ||
|
||
const createAcceptHeader = (type: string, apiVersion: string): string => | ||
`${type};api-version=${apiVersion}`; | ||
|
||
const getRequestOptions = (): RequestOptions => ({ | ||
headers: { | ||
Accept: createAcceptHeader('application/json', '6.0-preview.1') | ||
} | ||
}); | ||
|
||
const createHttpClient = (): HttpClient => { | ||
const token = process.env['ACTIONS_RUNTIME_TOKEN'] || ''; | ||
const bearerCredentialHandler = new BearerCredentialHandler(token); | ||
|
||
return new HttpClient( | ||
'actions/cache', | ||
[bearerCredentialHandler], | ||
getRequestOptions() | ||
); | ||
}; | ||
|
||
const uploadFile = async ( | ||
httpClient: HttpClient, | ||
cacheId: number, | ||
filePath: string, | ||
fileSize: number | ||
): Promise<void> => { | ||
// Upload Chunks | ||
const resourceUrl = getCacheApiUrl(`caches/${cacheId.toString()}`); | ||
const fd = fs.openSync(filePath, 'r'); | ||
|
||
try { | ||
const chunkSize = fileSize; | ||
const start = 0; | ||
const end = chunkSize - 1; | ||
|
||
await uploadChunk( | ||
httpClient, | ||
resourceUrl, | ||
() => | ||
fs | ||
.createReadStream(filePath, { | ||
fd, | ||
start, | ||
end, | ||
autoClose: false | ||
}) | ||
.on('error', error => { | ||
throw new Error( | ||
`Cache upload failed because file read failed with ${error.message}` | ||
); | ||
}), | ||
start, | ||
end | ||
); | ||
} finally { | ||
fs.closeSync(fd); | ||
} | ||
return; | ||
}; | ||
|
||
const CACHE_KEY = '_state'; | ||
|
||
const resetCacheWithOctokit = async (): Promise<void> => { | ||
const token = core.getInput('repo-token'); | ||
const client = getOctokit(token, undefined, retry); | ||
const repo = process.env['GITHUB_REPOSITORY']; | ||
const result = await client.request( | ||
`DELETE /repos/${repo}/actions/caches?key=${CACHE_KEY}` | ||
); | ||
console.log('============> delete'); | ||
Check failure on line 137 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 137 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
console.log(result); | ||
Check failure on line 138 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 138 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
}; | ||
|
||
const resetCache = async (httpClient: HttpClient): Promise<void> => { | ||
Check warning on line 141 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check warning on line 141 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
await retryTypedResponse('resetCache', async () => { | ||
const result = await httpClient.del( | ||
getCacheApiUrl(`caches?key=${CACHE_KEY}`) | ||
); | ||
console.log('============> delete'); | ||
Check failure on line 146 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 146 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
console.log(result.message); | ||
Check failure on line 147 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 147 in src/classes/actions-cache/index.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
return result.message as unknown as TypedResponseWithError<IncomingMessage>; | ||
}); | ||
}; | ||
interface ReserveCacheResponse { | ||
cacheId: number; | ||
} | ||
|
||
const reserveCache = async ( | ||
httpClient: HttpClient, | ||
fileSize: number | ||
): Promise<number> => { | ||
const reserveCacheRequest = { | ||
key: CACHE_KEY, | ||
version: new Date().getDate().toString(), | ||
cacheSize: fileSize | ||
}; | ||
const response = (await retryTypedResponse('reserveCache', async () => | ||
httpClient.postJson<ReserveCacheResponse>( | ||
getCacheApiUrl('caches'), | ||
reserveCacheRequest | ||
) | ||
)) as TypedResponseWithError<ReserveCacheResponse>; | ||
|
||
if (response?.statusCode === 400) | ||
throw new Error( | ||
response?.error?.message ?? | ||
`Cache size of ~${Math.round( | ||
fileSize / (1024 * 1024) | ||
)} MB (${fileSize} B) is over the data cap limit, not saving cache.` | ||
); | ||
|
||
const cacheId = response?.result?.cacheId; | ||
|
||
if (cacheId === undefined) | ||
throw new ReserveCacheError( | ||
`Unable to reserve cache with key ${CACHE_KEY}, another job may be creating this cache. More details: ${response?.error?.message}` | ||
); | ||
return cacheId; | ||
}; | ||
|
||
interface CommitCacheRequest { | ||
size: number; | ||
} | ||
|
||
const commitCache = async ( | ||
httpClient: HttpClient, | ||
cacheId: number, | ||
filesize: number | ||
): Promise<void> => { | ||
const commitCacheRequest: CommitCacheRequest = {size: filesize}; | ||
const response = (await retryTypedResponse('commitCache', async () => | ||
httpClient.postJson<null>( | ||
getCacheApiUrl(`caches/${cacheId.toString()}`), | ||
commitCacheRequest | ||
) | ||
)) as TypedResponse<null>; | ||
if (!isSuccessStatusCode(response.statusCode)) { | ||
throw new Error( | ||
`Cache service responded with ${response.statusCode} during commit cache.` | ||
); | ||
} | ||
}; | ||
|
||
export const saveFileAsActionsCache = async (filePath: string) => { | ||
try { | ||
await resetCacheWithOctokit(); | ||
const httpClient = createHttpClient(); | ||
|
||
// await resetCache(httpClient); | ||
|
||
const fileSize = fs.statSync(filePath).size; | ||
const cacheId = await reserveCache(httpClient, fileSize); | ||
|
||
await uploadFile(httpClient, cacheId, filePath, fileSize); | ||
|
||
await commitCache(httpClient, cacheId, fileSize); | ||
} 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}`); | ||
} | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,127 @@ | ||
import { | ||
HttpClientError, | ||
HttpClientResponse, | ||
HttpCodes | ||
} from '@actions/http-client'; | ||
Check failure on line 5 in src/classes/actions-cache/retry.ts GitHub Actions / Basic validation / build (ubuntu-latest)
Check failure on line 5 in src/classes/actions-cache/retry.ts GitHub Actions / Basic validation / build (windows-latest)
|
||
import { | ||
isServerErrorStatusCode, | ||
TypedResponseWithError | ||
} from './http-responses'; | ||
import * as core from '@actions/core'; | ||
|
||
const isRetryableStatusCode = (statusCode?: number): boolean => { | ||
if (!statusCode) { | ||
return false; | ||
} | ||
const retryableStatusCodes = [ | ||
HttpCodes.BadGateway, | ||
HttpCodes.ServiceUnavailable, | ||
HttpCodes.GatewayTimeout | ||
]; | ||
return retryableStatusCodes.includes(statusCode); | ||
}; | ||
|
||
const sleep = (milliseconds: number): Promise<void> => | ||
new Promise(resolve => setTimeout(resolve, milliseconds)); | ||
// The default number of retry attempts. | ||
const DefaultRetryAttempts = 2; | ||
// The default delay in milliseconds between retry attempts. | ||
const DefaultRetryDelay = 5000; | ||
|
||
const retry = async <T>( | ||
name: string, | ||
method: () => Promise<T>, | ||
getStatusCode: (arg0: T) => number | undefined, | ||
maxAttempts = DefaultRetryAttempts, | ||
delay = DefaultRetryDelay, | ||
onError: ((arg0: Error) => T | undefined) | undefined = undefined | ||
): Promise<T> => { | ||
let errorMessage = ''; | ||
let attempt = 1; | ||
|
||
while (attempt <= maxAttempts) { | ||
let response: T | undefined = undefined; | ||
let statusCode: number | undefined = undefined; | ||
let isRetryable = false; | ||
|
||
try { | ||
response = await method(); | ||
} catch (error) { | ||
if (onError) { | ||
response = onError(error); | ||
} | ||
|
||
isRetryable = true; | ||
errorMessage = error.message; | ||
} | ||
|
||
if (response) { | ||
statusCode = getStatusCode(response); | ||
|
||
if (!isServerErrorStatusCode(statusCode)) { | ||
return response; | ||
} | ||
} | ||
|
||
if (statusCode) { | ||
isRetryable = isRetryableStatusCode(statusCode); | ||
errorMessage = `Cache service responded with ${statusCode}`; | ||
} | ||
|
||
core.debug( | ||
`${name} - Attempt ${attempt} of ${maxAttempts} failed with error: ${errorMessage}` | ||
); | ||
|
||
if (!isRetryable) { | ||
core.debug(`${name} - Error is not retryable`); | ||
break; | ||
} | ||
|
||
await sleep(delay); | ||
attempt++; | ||
} | ||
|
||
throw Error(`${name} failed: ${errorMessage}`); | ||
}; | ||
|
||
export const retryHttpClientResponse = async ( | ||
name: string, | ||
method: () => Promise<HttpClientResponse>, | ||
maxAttempts = DefaultRetryAttempts, | ||
delay = DefaultRetryDelay | ||
): Promise<HttpClientResponse> => { | ||
return await retry( | ||
name, | ||
method, | ||
(response: HttpClientResponse) => response.message.statusCode, | ||
maxAttempts, | ||
delay | ||
); | ||
}; | ||
export const retryTypedResponse = <T>( | ||
name: string, | ||
method: () => Promise<TypedResponseWithError<T>>, | ||
maxAttempts = DefaultRetryAttempts, | ||
delay = DefaultRetryDelay | ||
): Promise<TypedResponseWithError<T>> => | ||
retry( | ||
name, | ||
method, | ||
(response: TypedResponseWithError<T>) => response.statusCode, | ||
maxAttempts, | ||
delay, | ||
// If the error object contains the statusCode property, extract it and return | ||
// an TypedResponse<T> so it can be processed by the retry logic. | ||
(error: Error) => { | ||
if (error instanceof HttpClientError) { | ||
return { | ||
statusCode: error.statusCode, | ||
result: null, | ||
headers: {}, | ||
error | ||
}; | ||
} else { | ||
return undefined; | ||
} | ||
} | ||
); |