Last active
May 26, 2021 15:29
-
-
Save Pagebakers/addc7aab4f98082d346cc50504cae4bb to your computer and use it in GitHub Desktop.
A CLI script to test and deploy functions on Google Cloud Functions
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
/* | |
This CLI script makes it easy to manage, test and deploy | |
multiple functions/services in Typescript | |
Requirements | |
You will need the Google Cloud SDK installed and | |
authentication configured to support deployments. | |
Configuration | |
You can pass the project, runtime and region as cli args, | |
or use these environment variables in your .env file. | |
SERVICES_PROJECT=test-project-123 | |
SERVICES_RUNTIME=nodejs12 | |
SERVICES_REGION=europe-west3 | |
File structure | |
Default root directory: | |
./src/services | |
Each service has it's own folder and can have multiple functions. | |
./src/services/[service-name]/index.ts | |
Running services locally | |
node services.js run <services> --watch | |
This will transpile your services to es6 and run the output with | |
the functions framework. | |
You can run multiple services by passing a comma separated list. | |
eg: 'node services.js run email,webhooks' | |
Once running you can access the services through the http server | |
http://localhost:8080/[service]-[handler] | |
eg: http://localhost:8080/email-welcome | |
Deploying services | |
node services.js deploy <service> --entry-point handler --http --unauthenticated | |
This will transpile your service to es6 and use the Cloud SDK | |
to deploy the entry point to the Cloud Functions platform | |
http functions will be availabble at | |
https://<region>-<project>.cloudfunctions.net/[service]-[env]-[handler] | |
eg: https://europe-eu3-my-project-123.cloudfunctions.net/email-production-welcome | |
*/ | |
require('dotenv').config() | |
const path = require('path') | |
const fs = require('fs') | |
const yargs = require("yargs"); | |
const nodeExternals = require('webpack-node-externals'); | |
const webpack = require('webpack'); | |
const { spawn } = require("child_process"); | |
yargs | |
.command('run <services>', 'Run one or more services locally', (yargs) => { | |
return yargs.positional('services', { | |
describe: 'A list of services' | |
}) | |
.option('p', {alias: 'port', describe: 'Listen to this port', type: 'string'}) | |
.option('w', {alias: 'watch', describe: 'Watch for changes', type: 'boolean'}) | |
.option('r', {alias: 'root', describe: 'Root directory', type: 'string'}) | |
}, async (argv) => { | |
try { | |
const services = argv.services.split(',') | |
const options = { | |
watch: argv.w, | |
rootDir: argv.r || './src/services', | |
mode: argv.m || process.env.NODE_ENV || 'development', | |
port: argv.p || 8080 | |
} | |
await buildServices(services, options) | |
runLocally(options) | |
} catch (e) { | |
console.error('Whoops, that went sideways.', e) | |
} | |
}) | |
.command('deploy <service>', 'Deploy a service to Google Cloud Functions', (yargs) => { | |
return yargs.positional('service', { | |
describe: 'The service to deploy' | |
}) | |
.option('e', {alias: 'entry-point', describe: 'The function entry point', type: 'string'}) | |
.option('p', {alias: 'project', describe: 'Google Cloud project', type: 'string'}) | |
.option('r', {alias: 'region', describe: 'Google Cloud region', type: 'boolean'}) | |
.option('a', {alias: 'allow-unauthenticated', describe: 'Allow unauthenticated', type: 'boolean'}) | |
.option('h', {alias: 'http', describe: 'Trigger HTTP', type: 'boolean'}) | |
.option('r', {alias: 'root', describe: 'Root directory', type: 'string'}) | |
}, async (argv) => { | |
try { | |
const service = argv.service | |
const options = { | |
entry: argv.e || 'handler', | |
mode: argv.m || process.env.NODE_ENV || 'development', | |
runtime: argv.t || process.env.SERVICES_RUNTIME || 'nodejs14', | |
project: argv.p || process.env.SERVICES_PROJECT, | |
region: argv.p || process.env.SERVICES_REGION, | |
allowUnauthenticated: argv.a, | |
http: argv.h, | |
rootDir: argv.r || './src/services' | |
} | |
await buildServices([service], options) | |
await deployService(service, options) | |
} catch (e) { | |
console.error('Whoops, that went sideways.', e) | |
} | |
}) | |
.argv; | |
function getWebpackConfig({ | |
service, | |
mode, | |
rootDir, | |
webpackConfig | |
}) { | |
return { | |
mode, | |
entry: path.join(__dirname, rootDir, service, 'index.ts'), | |
output: { | |
path: path.resolve(__dirname, rootDir, '.build', service), | |
filename: 'index.js', | |
libraryTarget: 'this' | |
}, | |
target: 'node', | |
module: { | |
rules: [ | |
{ | |
test: /\.ts?$/, | |
loader: 'ts-loader', | |
options: { | |
transpileOnly: true | |
} | |
} | |
] | |
}, | |
resolve: { | |
extensions: [ '.ts', '.js' ] | |
}, | |
externals: [nodeExternals()], | |
...webpackConfig | |
} | |
} | |
async function buildServices(services = [], { | |
mode, | |
rootDir, | |
webpackConfig, | |
watch | |
}) { | |
return new Promise((resolve, reject) => { | |
try { | |
console.log(`Building, ${services.join(', ')}`) | |
const compiler = webpack(services.map((service) => getWebpackConfig({ | |
mode, | |
service, | |
rootDir, | |
webpackConfig | |
}))); | |
compiler.hooks.done.tap('CloudFunctions', () => { | |
const fullPath = path.join(__dirname, rootDir, '.build', 'index.js') | |
fs.writeFileSync(fullPath, DEV_TEMPLATE); | |
}) | |
if (watch) { | |
compiler.watch({}, (err, stats) => { | |
console.log('Watch changes') | |
if (err) { | |
console.error(err) | |
} | |
}) | |
resolve() | |
} else { | |
compiler.run((err, stats) => { | |
if (err) { | |
console.error(err.stack || err); | |
if (err.details) { | |
console.error(err.details); | |
} | |
return reject(err); | |
} | |
const info = stats.toJson(); | |
if (stats.hasErrors()) { | |
console.error(info.errors); | |
return reject(info.errors) | |
} | |
if (stats.hasWarnings()) { | |
console.warn(info.warnings); | |
} | |
resolve() | |
}) | |
} | |
} catch (e) { | |
reject(e) | |
} | |
}) | |
} | |
function deployService(service, { | |
mode, | |
entry, | |
runtime, | |
http, | |
allowUnauthenticated, | |
project, | |
region, | |
rootDir | |
}) { | |
const source = path.join(rootDir, '.build', service) | |
const args = [ | |
'functions', | |
'deploy', | |
`${service}-${mode}-${entry}`, | |
`--source=${source}`, | |
`--runtime=${runtime}`, | |
`--project=${project}`, | |
`--entry-point=${entry}`, | |
`--region=${region}` | |
] | |
if (http) { | |
args.push('--trigger-http') | |
} | |
if (allowUnauthenticated) { | |
args.push('--allow-unauthenticated') | |
} | |
const ls = spawn('gcloud', args, { | |
stdio: ['pipe', 'pipe', 'pipe'] | |
}) | |
ls.stderr.on("data", data => { | |
const out = data.toString() | |
if (out === '.') { | |
return process.stdout.write(".") | |
} else if (out.match(/y\/N/)) { | |
ls.stdin.write("y\n"); | |
} | |
process.stdout.write(out) | |
}); | |
ls.on('error', (error) => { | |
console.log(`error: ${error.message}`); | |
}); | |
} | |
function runLocally({ | |
port, | |
watch, | |
rootDir | |
}) { | |
const framework = 'node_modules/@google-cloud/functions-framework/build/src/index.js' | |
const args = [ | |
`--source=${path.join(__dirname, rootDir, '.build/index.js')}`, | |
'--target=dev', | |
`--port=${port}` | |
] | |
let ls | |
if (watch) { | |
const nodemonArgs = [ | |
'--watch', path.join(rootDir, '.build/**/*.js'), | |
'--exec', | |
framework, | |
'--' | |
].concat(args) | |
ls = spawn(`nodemon`, nodemonArgs, { | |
stdio: ['pipe', 'pipe', 'pipe', 'ipc'] | |
}) | |
} else { | |
ls = spawn(`node`, [framework].concat(args)) | |
} | |
ls.on('message', function (event) { | |
if (event.type === 'start') { | |
console.log('nodemon started'); | |
} else if (event.type === 'crash') { | |
console.log('script crashed for some reason'); | |
} | |
}); | |
ls.stderr.on("data", data => { | |
console.log(`stderr: ${data}`); | |
}); | |
ls.on('error', (error) => { | |
console.log(`error: ${error.message}`); | |
}); | |
ls.on("close", code => { | |
console.log(`child process exited with code ${code}`); | |
}); | |
console.log(`Your services can now be accessed via http://localhost:${port}/[service]-[target]`) | |
} | |
// This allows you to test all your functions locally on a single port | |
// @todo add eventType http/event | |
const DEV_TEMPLATE = ` | |
function dev(req, res) { | |
const handler = getHandler(req.path) | |
if (handler) { | |
return handler(req, res) | |
} | |
return res.send('No function found') | |
} | |
function getHandler(path) { | |
const [service, entry] = path.replace('/', '').split('-') | |
const functions = require(\`./\${service}\`) | |
return functions[entry] | |
} | |
exports.dev = dev | |
` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment