UNPKG

@adobe/helix-deploy

Version:

Library and Commandline Tools to build and deploy OpenWhisk Actions

353 lines (314 loc) 11.3 kB
/* * Copyright 2021 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import { CloudFunctionsServiceClient } from '@google-cloud/functions'; import { SecretManagerServiceClient } from '@google-cloud/secret-manager'; import path from 'path'; import fs from 'fs'; import semver from 'semver'; import chalk from 'chalk-template'; import BaseDeployer from './BaseDeployer.js'; import GoogleConfig from './GoogleConfig.js'; import { filterActions } from '../utils.js'; export default class GoogleDeployer extends BaseDeployer { constructor(baseConfig, config) { super(baseConfig); Object.assign(this, { id: 'google', name: 'Google', _cfg: config, _client: null, _keyFilename: '', }); } ready() { if (this._cfg.keyFile) { this._keyFilename = path.resolve(process.cwd(), this._cfg.keyFile); if (!fs.existsSync(this._keyFilename)) { return false; } } return !!this._cfg.projectID && !!this._cfg.keyFile; } validate() { if (!this.ready()) { throw new Error('Google target needs key file, email, and project ID'); } } async init() { if (this.ready() && !this._client) { try { this._client = new CloudFunctionsServiceClient({ email: this._cfg.email, keyFilename: this._keyFilename, projectId: this._cfg.projectID, }); this._secretclient = new SecretManagerServiceClient({ email: this._cfg.email, keyFilename: this._keyFilename, projectId: this._cfg.projectID, }); } catch (e) { this.log.error(chalk`{red error:} Unable to authenticate with Google: ${e.message}`); throw e; } } } async updatePackage() { this.log.info('--: updating app (package) parameters ...'); // Create the secret with automation replication. const secretId = `helix-deploy--${this.cfg.packageName.replace(/\./g, '_')}`; const parent = `projects/${this._cfg.projectID}`; let secret; try { [secret] = await this._secretclient.createSecret({ parent, secret: { name: secretId, replication: { automatic: {}, }, }, secretId, }); this.log.info(`--: Created secret ${secret.name}`); } catch { this.log.info('--: Using existing secret'); [secret] = await this._secretclient.getSecret({ name: `${parent}/secrets/${secretId}`, }); } // Add a version with a payload onto the secret. const [version] = await this._secretclient.addSecretVersion({ parent: secret.name, payload: { data: Buffer.from(JSON.stringify(this.cfg.packageParams), 'utf8'), }, }); this.log.info(chalk`{green ok}: Added secret version ${version.name}`); /* const [retversion] = await this._secretclient.accessSecretVersion({ name: `${parent}/secrets/${secretId}/versions/latest`, }); const payload = JSON.parse(retversion.payload.data.toString()); this.log.info(payload); */ } async uploadZIP() { const [{ uploadUrl }] = await this._client.generateUploadUrl({ parent: `projects/${this._cfg.projectID}/locations/${this._cfg.region}`, }); const body = fs.createReadStream(this.cfg.zipFile); // upload await this.fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/zip', 'x-goog-content-length-range': '0,104857600', }, body, }); this._uploadURL = uploadUrl; } get fullFunctionName() { return `${this.cfg.packageName}--${this.cfg.name}` .replace(/\./g, '_') .replace('@', '_'); } async createFunction() { const name = `projects/${this._cfg.projectID}/locations/${this._cfg.region}/functions/${this.fullFunctionName}`; let exists = false; try { await this._client.getFunction({ name, }); exists = true; } catch { exists = false; } try { const func = { name, serviceAccountEmail: this._cfg.email, description: this.cfg.pkgJson.description, entryPoint: 'google', runtime: `nodejs${this.cfg.nodeVersion}`, // timeout: `${Math.floor(this.cfg.timeout / 1000)}s`, availableMemoryMb: this.cfg.memory, labels: { /* * Each resource can have multiple labels, up to a maximum of 64. * - Each label must be a key-value pair. * - Keys have a minimum length of 1 character and a maximum length of 63 * characters, and cannot be empty. Values can be empty, and have a maximum * length of 63 characters. * - Keys and values can contain only lowercase letters, numeric characters, * underscores, and dashes. All characters must use UTF-8 encoding, and * international characters are allowed. * - The key portion of a label must be unique. However, you can use the same key * with multiple resources. * - Keys must start with a lowercase letter or international character. */ // not worth the effort, I think pkgversion: `${encodeURIComponent(this.cfg.version.replace(/\./g, '_'))}`, // dependencies: this.cfg.dependencies.main // .map((dep) => `${dep.name}:${dep.version}`).join(','), // repository: encodeURIComponent(this.cfg.gitUrl).replace(/%/g, '_'), // git: `${this.cfg.gitOrigin}#${this.cfg.gitRef}`, updated: `${this.cfg.updatedAt}`, }, environmentVariables: this.cfg.params, httpsTrigger: {}, sourceUploadUrl: this._uploadURL, }; if (exists) { const [op] = await this._client.updateFunction({ // location: `projects/${this._cfg.projectID}/locations/${this._cfg.region}`, function: func, }); this.log.info('--: updating existing function'); const [res] = await op.promise(); this._function = res; this.log.info(chalk`{green ok:} function deployed`); } else { const [op] = await this._client.createFunction({ location: `projects/${this._cfg.projectID}/locations/${this._cfg.region}`, function: func, }); this.log.info('--: creating function, please wait (Google deployments are slow).'); const [res] = await op.promise(); this._function = res; this.log.info(chalk`{green ok:} function deployed`); } this.log.info('--: enabling unauthenticated requests'); await this._client.setIamPolicy({ resource: name, policy: { bindings: [ { role: 'roles/cloudfunctions.invoker', members: [ 'allUsers', ], }, ], }, }); } catch (err) { this.log.error(chalk`{red error:} bad request: ${err.metadata.internalRepr?.get('google.rpc.badrequest-bin')?.toString()}`); this.log.error(chalk`{red error:} details: ${err.metadata.internalRepr?.get('grpc-status-details-bin')?.toString()}`); throw err; } this._functionURL = this._function.httpsTrigger.url; } async deploy() { try { await this.uploadZIP(); await this.createFunction(); } catch (err) { const message = err.metadata ? err.metadata.get('grpc-status-details-bin')[0].toString() : err.message; this.log.error(chalk`{red error:} Unable to deploy Google Cloud function: ${message}`, err.metadata); throw err; } } get host() { return `${this._cfg.region}-${this._cfg.projectID}.cloudfunctions.net`; } // eslint-disable-next-line class-methods-use-this get urlVCL() { return '"/" + var.package + "--" + var.action + regsuball(var._version, "\\.", "_") + var.rest'; } get basePath() { return `/${this.fullFunctionName}`; } async test() { let url = this._functionURL; if (!url) { url = `https://${this._cfg.region}-${this._cfg.projectID}.cloudfunctions.net/${this.fullFunctionName}`; } return this.testRequest({ url, idHeader: 'X-Cloud-Trace-Context', retry404: 1, }); } async cleanup() { if (!this._client) { return; } try { const [allfns] = await this._client.listFunctions({ parent: `projects/${this._cfg.projectID}/locations/${this._cfg.region}`, }); const versionspec = {}; const sver = semver.parse(this.cfg.version); if (sver) { versionspec.patchVersion = sver.patch; versionspec.minorVersion = sver.minor; versionspec.majorVersion = sver.major; } await Promise.all(GoogleDeployer.filterFunctions( allfns, this.cfg.baseName, Date.now(), { ciAge: this.cfg.cleanupCiAge, patchAge: this.cfg.cleanupPatchAge, minorAge: this.cfg.cleanupMinorAge, majorAge: this.cfg.cleanupMajorAge, ciNum: this.cfg.cleanupCiNum, patchNum: this.cfg.cleanupPatchNum, minorNum: this.cfg.cleanupMinorNum, majorNum: this.cfg.cleanupMajorNum, }, versionspec, ).map((fn) => { this.log.info(`--: Cleaning up outdated function '${fn.fqName}`); return this._client.deleteFunction({ name: fn.fqName, }); })); } catch (e) { this.log.error(chalk`{red error:} Cleanup failed, proceeding anyway.`, e); } } static filterFunctions(fns, name, now, rangespec, versionspec) { const namedfns = fns.map((fn) => { const re = /(ci\d+)|(\d+_\d+_\d+)$/; const updated = fn.labels && fn.labels.updated ? fn.labels.updated : fn.updateTime.seconds; const versionstr = fn.labels && fn.labels.pkgversion ? fn.labels.pkgversion : fn.name.match(re) && fn.name.match(re)[0]; const version = {}; if (versionstr && versionstr.startsWith('ci')) { version.ci = versionstr.substr(2); } else if (versionstr) { const cleanversionstr = versionstr .replace(/^(\d+)[-_](\d+)[-_](\d+)[-_]/, '$1.$2.$3-') .replace(/^(\d+)[-_](\d+)[-_](\d+)/, '$1.$2.$3'); const ver = semver.parse(cleanversionstr); version.major = ver.major; version.minor = ver.minor; version.patch = ver.patch; } return { fqName: fn.name, name: fn.name.replace(/^.*--/, '').replace(re, '').replace(/_$/, ''), updated: new Date(Number(updated)), version, }; }).filter((fn) => fn.name === name); return filterActions(namedfns, now, rangespec, versionspec); } } GoogleDeployer.Config = GoogleConfig;