UNPKG

now-flow

Version:

Add deployment workflows to Zeit now

339 lines (289 loc) 10.7 kB
// @flow // Packages const ms = require('ms') const fetch = require('node-fetch') const mri = require('mri') const { gray, bold } = require('chalk') const uid = require('uid-promise') const bytes = require('bytes') const sleep = require('then-sleep') const debug = require('debug')('now:gcp:deploy') // Utilities const ok = require('../../util/output/ok') const info = require('../../util/output/info') const wait = require('../../util/output/wait') const link = require('../../util/output/link') const success = require('../../util/output/success') const humanPath = require('../../util/humanize-path') const resolve = require('../../resolve') const error = require('../../util/output/error') const param = require('../../util/output/param') const build = require('../../serverless/build') const getToken = require('./util/get-access-token') const describeProject = require('../../describe-project') const copyToClipboard = require('../../util/copy-to-clipboard') const generateBucketName = require('./util/generate-bucket-name') const { writeToConfigFile } = require('../../util/config-files') /*eslint-disable */ const getProcess = () => process /*eslint-enable */ const TRIGGER_TYPES = { 'https': true, 'cloud.pubsub': true, 'cloud.storage': true, 'google.firebase.database': true } const EVENT_TYPES = { 'cloud.pubsub': 'topic.publish', 'cloud.storage': 'object.change', 'google.firebase.database': 'ref.write' } /** * [description] * @param {Object} ctx.authConfig JSON object stored under ~/.now/auth.json * @param {Object} ctx.config JSON object stored under ~/.now/config.json * @param {Object} ctx.argv JSON object stored under now.json in the root of your project */ const deploy = async (ctx={}) => { const { project } = ctx.authConfig.credentials.find(p => p.provider === 'gcp') || {} if (!project) { console.error(error('Missing required \'gcp\' project configuration. Run \'now gcp login\' and choose a project.')) return 1 } // Example now.json for gcpConfig // { // functionName: String, // timeout: String, // memory: Number, // region: String, // trigger: { // type: String, (https || cloud.pubsub || cloud.storage || google.firebase.database) // topic: String, // bucket: String // } // } const gcpConfig = (ctx.argv || {}).gcp || {} const region = gcpConfig.region || 'us-central1' const deploymentId = gcpConfig.functionName || 'now-' + desc.name + '-' + (await uid(10)) const _timeout = gcpConfig.timeout || '15s' const memory = gcpConfig.memory || 512 if (gcpConfig.trigger && gcpConfig.trigger.type && !TRIGGER_TYPES[gcpConfig.trigger.type]) { console.error(error(`Invalid trigger type '${gcpConfig.trigger.type}'. Valid values: 'https', 'cloud.pubsub', 'cloud.storage' and 'google.firebase.database'.`)) return 1 } const triggerType = gcpConfig.trigger && gcpConfig.trigger.type ? gcpConfig.trigger.type : 'https' if (triggerType == 'cloud.pubsub' && !gcpConfig.trigger.topic) { console.error(error('Missing required property \'topic\'. When defining a \'cloud.pubsub\' trigger, a \'topic\' must be provided in the now.json file.')) return 1 } if (triggerType == 'cloud.storage' && !gcpConfig.trigger.bucket) { console.error(error('Missing required property \'bucket\'. When defining a \'cloud.storage\' trigger, a \'bucket\' must be provided in the now.json file.')) return 1 } const resource = triggerType == 'cloud.pubsub' || triggerType == 'cloud.storage' ? { resource: triggerType == 'cloud.pubsub' ? `projects/${project.id}/topics/${gcpConfig.trigger.topic}` : `projects/${project.id}/buckets/${gcpConfig.trigger.bucket}` } : {} const trigger = triggerType != 'https' ? { eventTrigger: Object.assign(resource, { eventType: `providers/${triggerType}/eventTypes/${EVENT_TYPES[triggerType]}` }) } : { httpsTrigger: { url: null } } const { argv: argv_ } = ctx const argv = mri(argv_, { boolean: ['help'], alias: { help: 'h' } }) const token = await getToken(ctx) // `now [provider] [deploy] [target]` const [cmdOrTarget = null, target_ = null] = argv._.slice(2).slice(-2) let target if (cmdOrTarget === 'gcp' || cmdOrTarget === 'deploy') { target = target_ === null ? getProcess().cwd() : target_ } else { if (target_) { console.error(error('Unexpected number of arguments for deploy command')) return 1 } else { target = cmdOrTarget === null ? getProcess().cwd() : cmdOrTarget } } const start = Date.now() const resolved = await resolve(target) if (resolved === null) { console.error(error(`Could not resolve deployment target ${param(target)}`)) return 1 } let desc = null try { desc = await describeProject(resolved) } catch (err) { if (err.code === 'AMBIGOUS_CONFIG') { console.error( error(`There is more than one source of \`now\` config: ${err.files}`) ) return 1 } else { throw err } } console.log(info(`Deploying ${param(humanPath(resolved))} to gcp project ${param(project.id)} ${gray(`(${region})`)}`)) console.log(info(`${gray('NOTE: To deploy to a different gcp project, run: now gcp login')}`)) const buildStart = Date.now() const stopBuildSpinner = wait('Building and bundling your app…') const zipFile = await build(resolved, desc) stopBuildSpinner() if (zipFile.length > 100 * 1024 * 1024) { console.error(error('The build exceeds the 100mb GCP Functions limit')) return 1 } console.log( ok( `Build generated a ${bold(bytes(zipFile.length))} zip ${gray( `[${ms(Date.now() - buildStart)}]` )}` ) ) const zipFileName = `${deploymentId}.zip` const resourcesStart = Date.now() debug('checking gcp function check') const fnCheckExistsRes = () => fetch( `https://cloudfunctions.googleapis.com/v1beta2/projects/${project.id}/locations/${region}/functions/${deploymentId}`, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ) const checkExistsRes = await fnCheckExistsRes() const fnExists = checkExistsRes.status !== 404 const stopResourcesSpinner = wait(`${fnExists ? 'Updating' : 'Creating'} API resources`) if (!ctx.config.gcp) ctx.config.gcp = {} if (!ctx.config.gcp.bucketName) { ctx.config.gcp.bucketName = generateBucketName() writeToConfigFile(ctx.config) } const { bucketName } = ctx.config.gcp debug('creating gcp storage bucket') const bucketRes = await fetch( `https://www.googleapis.com/storage/v1/b?project=${project.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: bucketName }) } ) if ( bucketRes.status !== 200 && bucketRes.status !== 409 /* already exists */ ) { console.error( error( `Error while creating GCP Storage bucket: ${await bucketRes.text()}` ) ) return 1 } debug('creating gcp storage file') const fileRes = await fetch(`https://www.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucketName)}/o?uploadType=media&name=${encodeURIComponent(zipFileName)}&project=${encodeURIComponent(project.id)}`, { method: 'POST', headers: { 'Content-Type': 'application/zip', 'Content-Length': zipFile.length, Authorization: `Bearer ${token}` }, body: zipFile } ) try { await assertSuccessfulResponse(fileRes) } catch (err) { console.error(error(err.message)) return 1 } // API documented at https://cloud.google.com/functions/docs/reference/rest/v1beta2/projects.locations.functions#CloudFunction debug('creating gcp function create') const fnCreateRes = await fetch(`https://cloudfunctions.googleapis.com/v1beta2/projects/${project.id}/locations/${region}/functions${fnExists ? `/${deploymentId}` : ''}`, { method: fnExists ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(Object.assign({ name: `projects/${project.id}/locations/${region}/functions/${deploymentId}`, timeout: _timeout, availableMemoryMb: memory, sourceArchiveUrl: `gs://${encodeURIComponent(bucketName)}/${zipFileName}`, entryPoint: 'handler' }, trigger)) } ) if (403 === fnCreateRes.status) { const url = `https://console.cloud.google.com/apis/api/cloudfunctions.googleapis.com/overview?project=${project.id}` console.error(error(`GCP Permission Denied error. Make sure the "Google Cloud Functions API" is enabled in the API Manager\n ${bold('API Manager URL')}: ${link(url)}`)) return 1 } try { await assertSuccessfulResponse(fnCreateRes) } catch (err) { console.error(error(err.message)) return 1 } let retriesLeft = 10 let url = '' let status, httpsTrigger do { if (status === 'FAILED') { console.error(error('API resources seem to have failed to deploy. Double-check your Google Cloud account to gather more details.')) stopResourcesSpinner() return 1 } else if (!--retriesLeft) { console.error( error('Could not determine status of the deployment: ' + String(url)) ) stopResourcesSpinner() return 1 } else { await sleep(5000) } const checkExistsRes = await fnCheckExistsRes() try { await assertSuccessfulResponse(checkExistsRes) } catch (err) { console.error(error(err.message)) return 1 } ({ status, httpsTrigger } = await checkExistsRes.json()) if (httpsTrigger) url = httpsTrigger.url } while (status !== 'READY') stopResourcesSpinner() console.log( ok( `API resources ${fnExists ? 'updated' : 'created'} (id: ${param(deploymentId)}) ${gray( `[${ms(Date.now() - resourcesStart)}]` )}` ) ) const copied = copyToClipboard(url, true) const successMsg = triggerType == 'cloud.pubsub' ? success(`Function ready to respond to 'cloud.pubsub' event on topic '${resource.resource}' ${gray(`[${ms(Date.now() - start)}]`)}`) : triggerType == 'cloud.storage' ? success(`Function ready to respond to 'object.change' event on bucket '${resource.resource}' ${gray(`[${ms(Date.now() - start)}]`)}`) : triggerType == 'google.firebase.database' ? success(`Function ready to respond to 'ref.write' event on firebase ${gray(`[${ms(Date.now() - start)}]`)}`) : success(`${link(url)} ${copied ? gray('(in clipboard)') : ''} ${gray(`[${ms(Date.now() - start)}]`)}`) console.log(successMsg) return 0 } const assertSuccessfulResponse = async res => { if (!res.ok) { let msg let body try { body = await res.json() } catch (err) { msg = `An API error was returned (${res.status}), but the error code could not be diagnosed` } if (body && body.error) msg = body.error.message throw new Error(msg) } } module.exports = deploy