gcx
Version:
An API and CLI for deploying Google Cloud Functions in Node.js.
328 lines (327 loc) • 11.2 kB
JavaScript
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);
}