import { RequestOptions } from 'https' ; import { randomBytes } from 'crypto' ; import fs from 'fs' ; import * as path from 'path' ; import { tmpdir } from 'os' ; import { CredentialBody , ExternalAccountClientOptions , GoogleAuth , } from 'google-auth-library' ; import { errorMessage , request , removeFile , } from '@google-github-actions/actions-utils' ; import { zipDir } from './util' ; const { version : appVersion } = require ( '../package.json' ) ; const userAgent = `google-github-actions:deploy-cloud-functions/${ appVersion }` ; const defaultBaseURL = 'https://cloudfunctions.googleapis.com/v1' ; const defaultTimeout = 300 ; const cloudFunctionResourceNamePattern = new RegExp ( /^projects\/.+\/locations\/.+\/functions\/.+$/gi ) ; export type CloudFunctionClientOptions = { projectID ? : string ; location ? : string ; credentials ? : CredentialBody | ExternalAccountClientOptions ; baseURL ? : string ; } ; export type PollOperationOptions = { retries : number ; interval : number ; onPoll ? : OnFunction ; } ; export type Operation = { name : string ; metadata : Record < string , any > ; done : boolean ; error : OperationStatus ; response ? : CloudFunctionResponse ; } ; export type OperationStatus = { code : number ; message : string ; } ; export type KVPair = Record < string , string > ; export type CloudFunction = { name : string ; runtime : string ; description ? : string ; availableMemoryMb ? : number ; buildEnvironmentVariables ? : KVPair ; buildWorkerPool ? : string ; dockerRepository ? : string ; entryPoint ? : string ; environmentVariables ? : KVPair ; ingressSettings ? : string ; kmsKeyName ? : string ; labels ? : KVPair ; maxInstances ? : number ; minInstances ? : number ; network ? : string ; secretEnvironmentVariables ? : SecretEnvVar [ ] ; secretVolumes ? : SecretVolume [ ] ; serviceAccountEmail ? : string ; sourceToken ? : string ; timeout ? : string ; vpcConnector ? : string ; vpcConnectorEgressSettings ? : string ; sourceArchiveUrl ? : string ; sourceRepository ? : SourceRepository ; sourceUploadUrl ? : string ; httpsTrigger ? : HTTPSTrigger ; eventTrigger ? : EventTrigger ; } ; export type SecretEnvVar = { key : string ; projectId : string ; secret : string ; version : string ; } ; export type SecretVolume = { mountPath : string ; projectId : string ; secret : string ; versions : { path : string ; version : string ; } [ ] ; } ; export type CloudFunctionResponse = CloudFunction & { status : string ; updateTime : string ; versionId : string ; buildName ? : string ; buildId ? : string ; } ; export type SourceRepository = { url : string ; deployedUrl : string ; } ; export type HTTPSTrigger = { url ? : string ; securityLevel ? : string ; } ; export type EventTrigger = { eventType : string ; resource : string ; service ? : string ; failurePolicy ? : FailurePolicy ; } ; export type FailurePolicy = { retry : Record < string , string > ; } ; export type CreateOptions = { timeout ? : number ; onPoll ? : OnFunction ; } ; export type DeleteOptions = { timeout ? : number ; onPoll ? : OnFunction ; } ; export type PatchOptions = { timeout ? : number ; onPoll ? : OnFunction ; } ; export type DeployOptions = { timeout ? : number ; onPoll ? : OnFunction ; onZip ? : OnZipFunction ; onNew ? : OnFunction ; onExisting ? : OnFunction ; } ; export type OnFunction = ( ) => void ; export type OnZipFunction = ( sourceDir : string , zipPath : string ) => void ; export class CloudFunctionsClient { /**
     * request is a high-level helper that returns a promise from the executed
     * request.
     */ static async request ( method , url , data ? , opts ? ) { opts ||= { } ; opts . headers = Object . assign ( { 'User-Agent' : userAgent , } , opts . headers ) ; return await request ( method , url , data , opts ) ; } /**
     * baseURL is the cloud functions endpoint. By default, this is the public
     * cloud functions endpoint, but it can be overridden for testing.
     */ readonly #baseURL : string ; /**
     * auth is the authentication client.
     */ readonly #auth : GoogleAuth ; /**
     * projectID and location are hints to the client if a Cloud Function resource
     * name does not include the full resource name. If a full resource name is
     * given (e.g. `projects/p/locations/l/functions/f`), then that is used.
     * However, if just a name is given (e.g. `f`), these values will be used to
     * construct the full resource name.
     */ readonly #projectID ? : string ; readonly #location ? : string ; constructor ( opts ? ) { this . #baseURL = opts ?. baseURL || defaultBaseURL ; this . #auth = new GoogleAuth ( { scopes : [ 'https://www.googleapis.com/auth/cloud-platform' ] , credentials : opts ?. credentials , projectId : opts ?. projectID , } ) ; this . #projectID = opts ?. projectID ; this . #location = opts ?. location ; } async #request ( method , url , data ? , opts ? ) { const authToken = await this . #auth . getAccessToken ( ) ; if ( ! authToken ) { throw new Error ( `Failed to get auth token for ${ method } ${ url }` ) ; } opts ||= { } ; opts . headers = Object . assign ( { 'Authorization' : `Bearer ${ authToken }` , 'Accept' : 'application/json' , 'Content-Type' : 'application/json' , } , opts . headers ) ; try { const resp = await request ( method , url , data , opts ) ; return JSON . parse ( resp ) ; } catch ( err ) { const msg = errorMessage ( err ) ; throw new Error ( `Failed to ${ method } ${ url }: ${ msg }` ) ; } } param name param opts async #pollOperation ( name , opts = { interval : 5 , retries : 60 } ) { const intervalMs = + opts . interval * 1000 ; for ( let i = 0 ; i < opts . retries ; i ++ ) { if ( opts . onPoll ) opts . onPoll ( ) ; const resp = await this . getOperation ( name ) ; if ( resp . error ) { throw new Error ( `Operation failed: ${ resp . error . message }` ) ; } if ( resp . done ) { return resp ; } await new Promise ( ( resolve ) => setTimeout ( resolve , intervalMs ) ) ; } throw new Error ( `Operation timed out` ) ; } param name async getOperation ( name ) { if ( ! name . startsWith ( 'operations/' ) ) { name = `operations/${ name }` ; } const u = `${ this . #baseURL }/${ name }` ; const resp = await this . #request ( 'GET' , u ) ; return resp ; } param cf async create ( cf , opts ? ) { const timeout = opts ?. timeout || defaultTimeout ; const resourceName = this . fullResourceName ( cf . name ) ; cf . name = resourceName ; const parent = this . parentFromName ( resourceName ) ; const u = `${ this . #baseURL }/${ parent }/functions` ; const body = JSON . stringify ( cf ) ; const resp = await this . #request ( 'POST' , u , body ) ; const op = await this . #pollOperation ( resp . name , { interval : 5 , retries : timeout / 5 , onPoll : opts ?. onPoll , } ) ; if ( ! op . response ) { throw new Error ( `create operation result did not include function` ) ; } return op . response ; } param name async delete ( name , opts ? ) { const timeout = opts ?. timeout || defaultTimeout ; const resourceName = this . fullResourceName ( name ) ; const u = `${ this . #baseURL }/${ resourceName }` ; const resp = await this . #request ( 'DELETE' , u ) ; return await this . #pollOperation ( resp . name , { interval : 5 , retries : timeout / 5 , onPoll : opts ?. onPoll , } ) ; } param parent async generateUploadURL ( parent ) { const u = `${ this . #baseURL }/${ parent }/functions:generateUploadUrl` ; const resp = await this . #request ( 'POST' , u ) ; return resp . uploadUrl ; } param name async get ( name ) { const resourceName = this . fullResourceName ( name ) ; const u = `${ this . #baseURL }/${ resourceName }` ; const resp = await this . #request ( 'GET' , u ) ; return resp ; } param cf async patch ( cf , opts ? ) { const timeout = opts ?. timeout || defaultTimeout ; const updateMasks = [ 'sourceUploadUrl' , 'name' , 'environmentVariables' , 'entryPoint' , 'runtime' , 'vpcConnector' , 'vpcConnectorEgressSettings' , 'ingressSettings' , 'serviceAccountEmail' , 'timeout' , 'minInstances' , 'maxInstances' , 'eventTrigger.eventType' , 'eventTrigger.resource' , 'eventTrigger.service' , 'labels' , 'secretEnvironmentVariables' , ] . join ( ',' ) ; const resourceName = this . fullResourceName ( cf . name ) ; cf . name = resourceName ; const u = `${ this . #baseURL }/${ resourceName }?updateMask=${ updateMasks }` ; const body = JSON . stringify ( cf ) ; const resp = await this . #request ( 'PATCH' , u , body ) ; const op = await this . #pollOperation ( resp . name , { interval : 5 , retries : timeout / 5 , onPoll : opts ?. onPoll , } ) ; if ( ! op . response ) { throw new Error ( `patch operation result did not include function` ) ; } return op . response ; } param cf param sourceDir async deployFromLocalSource ( cf , sourceDir , opts ? ) { const timeout = opts ?. timeout || defaultTimeout ; const randomName = randomBytes ( 12 ) . toString ( 'hex' ) ; const zipPath = path . join ( tmpdir ( ) , `cfsrc-${ randomName }.zip` ) ; try { await zipDir ( sourceDir , zipPath ) ; if ( opts ?. onZip ) opts . onZip ( sourceDir , zipPath ) ; } catch ( err ) { throw new Error ( `Zip file ${ zipPath } creation failed: ${ err }` ) ; } const resourceName = this . fullResourceName ( cf . name ) ; cf . name = resourceName ; const parent = this . parentFromName ( resourceName ) ; let uploadURL ; try { uploadURL = await this . generateUploadURL ( parent ) ; await this . uploadSource ( uploadURL , zipPath ) ; } catch ( err ) { throw new Error ( `Failed to upload zip file: ${ err }` ) ; } removeFile ( zipPath ) ; cf . sourceUploadUrl = uploadURL ; const existingFunction = await ( async ( ) : Promise < CloudFunction | null > => { try { return await this . get ( resourceName ) ; } catch ( err ) { return null ; } } ) ( ) ; if ( existingFunction ) { if ( opts ?. onExisting ) opts . onExisting ( ) ; const resp = await this . patch ( cf , { timeout : timeout , onPoll : opts ?. onPoll , } ) ; return resp ; } else { if ( opts ?. onNew ) opts . onNew ( ) ; const resp = await this . create ( cf , { timeout : timeout , onPoll : opts ?. onPoll , } ) ; return resp ; } } param uploadURL param zipPath returns async uploadSource ( uploadURL , zipPath ) { const zipFile = fs . createReadStream ( zipPath ) ; try { await CloudFunctionsClient . request ( 'PUT' , uploadURL , zipFile , { headers : { 'content-type' : 'application/zip' , 'x-goog-content-length-range' : '0,104857600' , } , } ) ; } catch ( err ) { const msg = errorMessage ( err ) ; throw new Error ( `Failed to upload source: ${ msg }` ) ; } } fullResourceName ( name ) { if ( ! name ) { name = '' ; } name = name . trim ( ) ; if ( ! name ) { throw new Error ( `Failed to parse resource name: name cannot be empty` ) ; } if ( name . includes ( '/' ) ) { if ( name . match ( cloudFunctionResourceNamePattern ) ) { return name ; } else { throw new Error ( `Invalid resource name '${ name }'` ) ; } } const projectID = this . #projectID ; if ( ! projectID ) { throw new Error ( `Failed to get project ID to build resource name. Try setting 'project_id'.` ) ; } const location = this . #location ; if ( ! location ) { throw new Error ( `Failed to get location (region) to build resource name. Try setting 'region'.` ) ; } return `projects/${ projectID }/locations/${ location }/functions/${ name }` ; } parentFromName ( name ) { const parts = name . split ( '/' ) ; if ( parts . length < 3 ) { throw new Error ( `Invalid or missing name '${ name }' (expected 'projects/p/locations/l/functions/f')` ) ; } const parent = parts . slice ( 0 , parts . length - 2 ) . join ( '/' ) ; return parent ; } }