2020-05-06 11:10:18 -04:00
import * as core from '@actions/core'
2020-07-10 10:09:32 -05:00
import { HttpClient } from '@actions/http-client'
2022-05-11 17:14:25 -04:00
import { BearerCredentialHandler } from '@actions/http-client/lib/auth'
import {
RequestOptions ,
TypedResponse
} from '@actions/http-client/lib/interfaces'
2020-05-06 11:10:18 -04:00
import * as crypto from 'crypto'
import * as fs from 'fs'
2020-07-10 10:09:32 -05:00
import { URL } from 'url'
2020-05-06 11:10:18 -04:00
import * as utils from './cacheUtils'
2020-07-10 10:09:32 -05:00
import { CompressionMethod } from './constants'
2020-05-06 11:10:18 -04:00
import {
ArtifactCacheEntry ,
2020-05-12 14:47:31 -04:00
InternalCacheOptions ,
2020-05-06 11:10:18 -04:00
CommitCacheRequest ,
ReserveCacheRequest ,
2022-04-04 16:21:58 +05:30
ReserveCacheResponse ,
2022-11-14 05:24:09 +00:00
ITypedResponseWithError ,
ArtifactCacheList
2020-05-06 11:10:18 -04:00
} from './contracts'
2020-07-10 10:09:32 -05:00
import { downloadCacheHttpClient , downloadCacheStorageSDK } from './downloadUtils'
import {
DownloadOptions ,
UploadOptions ,
getDownloadOptions ,
getUploadOptions
} from '../options'
import {
isSuccessStatusCode ,
retryHttpClientResponse ,
retryTypedResponse
} from './requestUtils'
2020-05-06 11:10:18 -04:00
const versionSalt = '1.0'
function getCacheApiUrl ( resource : string ) : string {
2022-03-29 22:20:22 +00:00
const baseUrl : string = process . env [ 'ACTIONS_CACHE_URL' ] || ''
2020-05-06 11:10:18 -04:00
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
}
function createAcceptHeader ( type : string , apiVersion : string ) : string {
return ` ${ type } ;api-version= ${ apiVersion } `
}
2022-05-11 17:14:25 -04:00
function getRequestOptions () : RequestOptions {
const requestOptions : RequestOptions = {
2020-05-06 11:10:18 -04:00
headers : {
Accept : createAcceptHeader ( 'application/json' , '6.0-preview.1' )
}
}
return requestOptions
}
function createHttpClient () : HttpClient {
const token = process . env [ 'ACTIONS_RUNTIME_TOKEN' ] || ''
const bearerCredentialHandler = new BearerCredentialHandler ( token )
return new HttpClient (
'actions/cache' ,
[ bearerCredentialHandler ],
getRequestOptions ()
)
}
export function getCacheVersion (
2020-05-06 17:53:22 -04:00
paths : string [],
2020-05-06 11:10:18 -04:00
compressionMethod? : CompressionMethod
) : string {
2020-05-06 17:53:22 -04:00
const components = paths . concat (
2020-05-18 16:33:15 -04:00
! compressionMethod || compressionMethod === CompressionMethod . Gzip
? []
: [ compressionMethod ]
2020-05-06 11:10:18 -04:00
)
// Add salt to cache version to support breaking changes in cache entry
components . push ( versionSalt )
return crypto
. createHash ( 'sha256' )
. update ( components . join ( '|' ))
. digest ( 'hex' )
}
export async function getCacheEntry (
keys : string [],
2020-05-06 17:53:22 -04:00
paths : string [],
2020-05-12 14:47:31 -04:00
options? : InternalCacheOptions
2020-05-06 11:10:18 -04:00
) : Promise < ArtifactCacheEntry | null > {
const httpClient = createHttpClient ()
2020-05-06 17:53:22 -04:00
const version = getCacheVersion ( paths , options ? . compressionMethod )
2020-05-06 11:10:18 -04:00
const resource = `cache?keys= ${ encodeURIComponent (
keys . join ( ',' )
) } &version= ${ version } `
2020-05-12 12:37:03 -04:00
const response = await retryTypedResponse ( 'getCacheEntry' , async () =>
httpClient . getJson < ArtifactCacheEntry >( getCacheApiUrl ( resource ))
2020-05-06 11:10:18 -04:00
)
if ( response . statusCode === 204 ) {
2022-12-13 11:25:40 +00:00
// List cache for primary key only if cache miss occurs
if ( core . isDebug ()) {
await listCache ( keys [ 0 ], httpClient , version , response )
}
2020-05-06 11:10:18 -04:00
return null
}
if ( ! isSuccessStatusCode ( response . statusCode )) {
throw new Error ( `Cache service responded with ${ response . statusCode } ` )
}
const cacheResult = response . result
const cacheDownloadUrl = cacheResult ? . archiveLocation
if ( ! cacheDownloadUrl ) {
throw new Error ( 'Cache not found.' )
}
core . setSecret ( cacheDownloadUrl )
core . debug ( `Cache Result:` )
core . debug ( JSON . stringify ( cacheResult ))
return cacheResult
}
2022-11-15 10:32:24 +00:00
async function listCache (
key : string ,
httpClient : HttpClient ,
2022-11-15 11:04:24 +00:00
version : string ,
2022-12-13 11:25:40 +00:00
getCacheEntryResponse : any
2022-11-15 10:32:24 +00:00
) : Promise < void > {
2022-12-13 11:25:40 +00:00
const scope = getCacheEntryResponse . result ? . scope
2022-11-15 10:32:24 +00:00
const resource = `caches?key= ${ encodeURIComponent ( key ) } `
const response = await retryTypedResponse ( 'listCache' , async () =>
httpClient . getJson < ArtifactCacheList >( getCacheApiUrl ( resource ))
)
2022-12-13 11:25:40 +00:00
if ( response . statusCode === 200 ) {
2022-11-15 10:32:24 +00:00
const cacheListResult = response . result
const totalCount = cacheListResult ? . totalCount
if ( totalCount && totalCount > 0 ) {
2022-12-13 11:25:40 +00:00
core . debug (
`No matching cache found for cache key ' ${ key } ', version ' ${ version } and scope ${ scope } but there are ${ totalCount } existing version of the cache for this key. More info on versioning can be found here: https://github.com/actions/cache#cache-version \ nOther versions are as follows:`
2022-11-15 10:32:24 +00:00
)
2022-12-13 11:25:40 +00:00
for ( const cacheEntry of cacheListResult . artifactCaches || []) {
2022-11-15 10:32:24 +00:00
core . debug (
`Cache Key: ${ cacheEntry ? . cacheKey } , Cache Version: ${ cacheEntry ? . cacheVersion } , Cache Scope: ${ cacheEntry ? . scope } , Cache Created: ${ cacheEntry ? . creationTime } `
)
2022-12-13 11:25:40 +00:00
}
2022-11-15 10:32:24 +00:00
}
}
}
2020-05-06 11:10:18 -04:00
export async function downloadCache (
archiveLocation : string ,
2020-07-10 10:09:32 -05:00
archivePath : string ,
options? : DownloadOptions
2020-05-06 11:10:18 -04:00
) : Promise < void > {
2020-07-10 10:09:32 -05:00
const archiveUrl = new URL ( archiveLocation )
const downloadOptions = getDownloadOptions ( options )
if (
downloadOptions . useAzureSdk &&
archiveUrl . hostname . endsWith ( '.blob.core.windows.net' )
) {
// Use Azure storage SDK to download caches hosted on Azure to improve speed and reliability.
await downloadCacheStorageSDK ( archiveLocation , archivePath , downloadOptions )
2020-05-06 11:10:18 -04:00
} else {
2020-07-10 10:09:32 -05:00
// Otherwise, download using the Actions http-client.
await downloadCacheHttpClient ( archiveLocation , archivePath )
2020-05-06 11:10:18 -04:00
}
}
// Reserve Cache
export async function reserveCache (
key : string ,
2020-05-06 17:53:22 -04:00
paths : string [],
2020-05-12 14:47:31 -04:00
options? : InternalCacheOptions
2022-04-04 16:21:58 +05:30
) : Promise < ITypedResponseWithError < ReserveCacheResponse >> {
2020-05-06 11:10:18 -04:00
const httpClient = createHttpClient ()
2020-05-06 17:53:22 -04:00
const version = getCacheVersion ( paths , options ? . compressionMethod )
2020-05-06 11:10:18 -04:00
const reserveCacheRequest : ReserveCacheRequest = {
key ,
2022-04-04 16:21:58 +05:30
version ,
cacheSize : options?.cacheSize
2020-05-06 11:10:18 -04:00
}
2020-05-12 12:37:03 -04:00
const response = await retryTypedResponse ( 'reserveCache' , async () =>
httpClient . postJson < ReserveCacheResponse >(
getCacheApiUrl ( 'caches' ),
reserveCacheRequest
)
2020-05-06 11:10:18 -04:00
)
2022-04-04 16:21:58 +05:30
return response
2020-05-06 11:10:18 -04:00
}
function getContentRange ( start : number , end : number ) : string {
// 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/*
return `bytes ${ start } - ${ end } /*`
}
async function uploadChunk (
httpClient : HttpClient ,
resourceUrl : string ,
2020-05-12 12:37:03 -04:00
openStream : () => NodeJS . ReadableStream ,
2020-05-06 11:10:18 -04:00
start : number ,
end : number
) : Promise < void > {
core . debug (
`Uploading chunk of size ${ end -
start +
1 } bytes at offset ${ start } with content range: ${ getContentRange (
start ,
end
) } `
)
const additionalHeaders = {
'Content-Type' : 'application/octet-stream' ,
'Content-Range' : getContentRange ( start , end )
}
2020-11-03 09:53:44 -06:00
const uploadChunkResponse = await retryHttpClientResponse (
2020-05-12 12:37:03 -04:00
`uploadChunk (start: ${ start } , end: ${ end } )` ,
async () =>
httpClient . sendStream (
'PATCH' ,
resourceUrl ,
openStream (),
additionalHeaders
)
2020-05-06 11:10:18 -04:00
)
2020-11-03 09:53:44 -06:00
if ( ! isSuccessStatusCode ( uploadChunkResponse . message . statusCode )) {
throw new Error (
`Cache service responded with ${ uploadChunkResponse . message . statusCode } during upload chunk.`
)
}
2020-05-06 11:10:18 -04:00
}
async function uploadFile (
httpClient : HttpClient ,
cacheId : number ,
2020-05-12 12:37:03 -04:00
archivePath : string ,
options? : UploadOptions
2020-05-06 11:10:18 -04:00
) : Promise < void > {
// Upload Chunks
2021-05-03 18:09:44 +03:00
const fileSize = utils . getArchiveFileSizeInBytes ( archivePath )
2020-05-06 11:10:18 -04:00
const resourceUrl = getCacheApiUrl ( `caches/ ${ cacheId . toString () } ` )
const fd = fs . openSync ( archivePath , 'r' )
2020-07-10 10:09:32 -05:00
const uploadOptions = getUploadOptions ( options )
2020-05-06 11:10:18 -04:00
2020-07-10 10:09:32 -05:00
const concurrency = utils . assertDefined (
'uploadConcurrency' ,
uploadOptions . uploadConcurrency
)
const maxChunkSize = utils . assertDefined (
'uploadChunkSize' ,
uploadOptions . uploadChunkSize
)
2020-05-06 11:10:18 -04:00
const parallelUploads = [... new Array ( concurrency ). keys ()]
core . debug ( 'Awaiting all uploads' )
let offset = 0
try {
await Promise . all (
parallelUploads . map ( async () => {
while ( offset < fileSize ) {
2020-07-10 10:09:32 -05:00
const chunkSize = Math . min ( fileSize - offset , maxChunkSize )
2020-05-06 11:10:18 -04:00
const start = offset
const end = offset + chunkSize - 1
2020-07-10 10:09:32 -05:00
offset += maxChunkSize
2020-05-06 11:10:18 -04:00
2020-05-12 12:37:03 -04:00
await uploadChunk (
httpClient ,
resourceUrl ,
() =>
fs
. createReadStream ( archivePath , {
fd ,
start ,
end ,
autoClose : false
})
. on ( 'error' , error => {
throw new Error (
2020-07-10 10:09:32 -05:00
`Cache upload failed because file read failed with ${ error . message } `
2020-05-12 12:37:03 -04:00
)
}),
start ,
end
)
2020-05-06 11:10:18 -04:00
}
})
)
} finally {
fs . closeSync ( fd )
}
return
}
async function commitCache (
httpClient : HttpClient ,
cacheId : number ,
filesize : number
2022-05-11 17:14:25 -04:00
) : Promise < TypedResponse < null >> {
2020-05-06 11:10:18 -04:00
const commitCacheRequest : CommitCacheRequest = { size : filesize }
2020-05-12 12:37:03 -04:00
return await retryTypedResponse ( 'commitCache' , async () =>
httpClient . postJson < null >(
getCacheApiUrl ( `caches/ ${ cacheId . toString () } ` ),
commitCacheRequest
)
2020-05-06 11:10:18 -04:00
)
}
export async function saveCache (
cacheId : number ,
2020-05-12 12:37:03 -04:00
archivePath : string ,
options? : UploadOptions
2020-05-06 11:10:18 -04:00
) : Promise < void > {
const httpClient = createHttpClient ()
core . debug ( 'Upload cache' )
2020-05-12 12:37:03 -04:00
await uploadFile ( httpClient , cacheId , archivePath , options )
2020-05-06 11:10:18 -04:00
// Commit Cache
core . debug ( 'Commiting cache' )
2021-05-03 18:09:44 +03:00
const cacheSize = utils . getArchiveFileSizeInBytes ( archivePath )
2020-11-26 01:56:57 +03:00
core . info (
`Cache Size: ~ ${ Math . round ( cacheSize / ( 1024 * 1024 )) } MB ( ${ cacheSize } B)`
)
2020-05-06 11:10:18 -04:00
const commitCacheResponse = await commitCache ( httpClient , cacheId , cacheSize )
if ( ! isSuccessStatusCode ( commitCacheResponse . statusCode )) {
throw new Error (
`Cache service responded with ${ commitCacheResponse . statusCode } during commit cache.`
)
}
core . info ( 'Cache saved successfully' )
}