UNPKG

gcx

Version:

An API and CLI for deploying Google Cloud Functions in Node.js.

328 lines (327 loc) 11.2 kB
import { EventEmitter } from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import archiver from 'archiver'; import { globby } from 'globby'; import { GoogleAuth } from 'google-auth-library'; import { google } from 'googleapis'; import fetch from 'node-fetch'; import { v4 as uuid } from 'uuid'; export var ProgressEvent; (function (ProgressEvent) { ProgressEvent["STARTING"] = "STARTING"; ProgressEvent["PACKAGING"] = "PACKAGING"; ProgressEvent["UPLOADING"] = "UPLOADING"; ProgressEvent["DEPLOYING"] = "DEPLOYING"; ProgressEvent["CALLING"] = "CALLING"; ProgressEvent["COMPLETE"] = "COMPLETE"; })(ProgressEvent || (ProgressEvent = {})); /** * A generic client for GCX. */ export class GCXClient extends EventEmitter { auth; _gcf; constructor(options) { super(); this.auth = new GoogleAuth(options); } /** * Provides an authenticated GCF api client. * @private */ // eslint-disable-next-line @typescript-eslint/naming-convention async _getGCFClient() { if (!this._gcf) { google.options({ auth: this.auth }); this._gcf = google.cloudfunctions('v1'); } return this._gcf; } } /** * Class that provides the `deploy` method. */ export class Deployer extends GCXClient { _options; constructor(options) { super(); this._validateOptions(options); if (options.project) { options.projectId = options.project; } this._options = options; if (!options.targetDir) { this._options.targetDir = process.cwd(); } options.scopes = ['https://www.googleapis.com/auth/cloud-platform']; this.auth = new GoogleAuth(options); } /** * Deploy the current application using the given opts. */ async deploy() { this.emit(ProgressEvent.STARTING); const gcf = await this._getGCFClient(); const projectId = await this.auth.getProjectId(); const region = this._options.region || 'us-central1'; const parent = `projects/${projectId}/locations/${region}`; const name = `${parent}/functions/${this._options.name}`; const fns = gcf.projects.locations.functions; const response = await fns.generateUploadUrl({ parent }); const sourceUploadUrl = response.data.uploadUrl; if (!sourceUploadUrl) { throw new Error('Source Upload URL not available.'); } this.emit(ProgressEvent.PACKAGING); const zipPath = await this._pack(); this.emit(ProgressEvent.UPLOADING); await this._upload(zipPath, sourceUploadUrl); this.emit(ProgressEvent.DEPLOYING); const body = this._buildRequest(parent, sourceUploadUrl); const exists = await this._exists(name); let operation; if (exists) { const updateMask = this._getUpdateMask(); const result = await fns.patch({ name, updateMask, requestBody: body }); operation = result.data; } else { const result = await fns.create({ location: parent, requestBody: body }); operation = result.data; } if (operation.name == null) { throw new Error('Operation name not available.'); } await this._poll(operation.name); this.emit(ProgressEvent.COMPLETE); } /** * Given an operation, poll it until complete. * @private * @param name Fully qualified name of the operation. */ async _poll(name) { const gcf = await this._getGCFClient(); const response = await gcf.operations.get({ name }); const operation = response.data; if (operation.error) { const message = JSON.stringify(operation.error); throw new Error(message); } if (operation.done) { return; } await new Promise((r) => { setTimeout(r, 5000); }); await this._poll(name); } /** * Get a list of fields that have been changed. * @private */ _getUpdateMask() { const fields = ['sourceUploadUrl']; const options = this._options; if (options.memory) fields.push('availableMemoryMb'); if (options.description) fields.push('description'); if (options.entryPoint) fields.push('entryPoint'); if (options.maxInstances) fields.push('maxInstances'); if (options.vpcConnector) fields.push('vpcConnector'); if (options.network) fields.push('network'); if (options.runtime) fields.push('runtime'); if (options.timeout) fields.push('timeout'); if (options.triggerHTTP) fields.push('httpsTrigger'); if (options.triggerBucket || options.triggerTopic) { fields.push('eventTrigger.eventType', 'eventTrigger.resource'); } return fields.join(','); } /** * Validate the options passed in by the user. * @private * @param options */ _validateOptions(options) { if (!options.name) { throw new Error('The `name` option is required.'); } const trigggerProps = [ 'triggerHTTP', 'triggerBucket', 'triggerTopic', ]; const triggerCount = trigggerProps.filter((property) => Boolean(options[property])).length; if (triggerCount > 1) { throw new Error('At most 1 trigger may be defined.'); } } /** * Build a request schema that can be used to create or patch the function * @private * @param parent Path to the cloud function resource container * @param sourceUploadUrl Url where the blob was pushed */ _buildRequest(parent, sourceUploadUrl) { const requestBody = { name: `${parent}/functions/${this._options.name}`, description: this._options.description, sourceUploadUrl, entryPoint: this._options.entryPoint, network: this._options.network, runtime: this._options.runtime || 'nodejs14', timeout: this._options.timeout, availableMemoryMb: this._options.memory, maxInstances: this._options.maxInstances, vpcConnector: this._options.vpcConnector, }; if (this._options.triggerTopic) { requestBody.eventTrigger = { eventType: this._options.triggerEvent || 'providers/cloud.pubsub/eventTypes/topic.publish', resource: this._options.triggerTopic, }; } else if (this._options.triggerBucket) { requestBody.eventTrigger = { eventType: this._options.triggerEvent || 'providers/cloud.storage/eventTypes/object.change', resource: this._options.triggerBucket, }; } else { requestBody.httpsTrigger = {}; } return requestBody; } /** * Check to see if a cloud function already exists. * @private * @param name Fully qualified name of the function. */ async _exists(name) { const gcf = await this._getGCFClient(); try { await gcf.projects.locations.functions.get({ name }); return true; } catch { return false; } } /** * Upload a local file to GCS given a signed url * @private * @param localPath Fully qualified path to the zip on disk. * @param remotePath Signed url used to put the file to */ async _upload(localPath, remotePath) { const stream = fs.createReadStream(localPath); await fetch(remotePath, { method: 'PUT', body: stream, headers: { 'Content-Type': 'application/zip', 'x-goog-content-length-range': '0,104857600', }, }); } /** * Package all of the sources into a zip file. * @private */ async _pack() { // biome-ignore lint/suspicious/noAsyncPromiseExecutor: it needs to be async return new Promise(async (resolve, reject) => { const zipPath = `${path.join(os.tmpdir(), uuid())}.zip`; const output = fs.createWriteStream(zipPath); const archive = archiver('zip'); output.on('close', () => { resolve(zipPath); }); archive.on('error', reject); archive.pipe(output); const ignorePatterns = await this._getIgnoreRules(); const files = await globby('**/**', { ignore: ignorePatterns, cwd: this._options.targetDir, }); for (const f of files) { if (this._options.targetDir == null) { throw new Error('targetDir is required'); } const fullPath = path.join(this._options.targetDir, f); archive.append(fs.createReadStream(fullPath), { name: f }); } await archive.finalize(); }); } /** * Look in the CWD for a `.gcloudignore` file. If one is present, parse it, * and return the ignore rules as an array of strings. * @private */ async _getIgnoreRules() { if (this._options.targetDir == null) { throw new Error('targetDir is required'); } const ignoreFile = path.join(this._options.targetDir, '.gcloudignore'); let ignoreRules = []; try { const contents = await fs.promises.readFile(ignoreFile, 'utf8'); ignoreRules = contents.split('\n').filter((line) => { return !line.startsWith('#') && line.trim() !== ''; }); } catch { // Blergh } return ignoreRules; } } /** * Class that provides the `call` method. */ export class Caller extends GCXClient { /** * Synchronously call a function. * @param {string} functionName The function to call. */ async call(options) { this.emit(ProgressEvent.STARTING); const gcf = await this._getGCFClient(); const projectId = await this.auth.getProjectId(); const region = options.region || 'us-central1'; const name = `projects/${projectId}/locations/${region}/function/${options.functionName}`; const fns = gcf.projects.locations.functions; this.emit(ProgressEvent.CALLING); const response = await fns.call({ name, requestBody: { data: options.data, }, }); this.emit(ProgressEvent.COMPLETE); return response; } } export async function deploy(options) { const deployer = new Deployer(options); return deployer.deploy(); } export async function call(options) { const caller = new Caller(options); return caller.call(options); }