diff --git a/.kokoro/functions/scheduleinstance.cfg b/.kokoro/functions/scheduleinstance.cfg new file mode 100644 index 0000000000..ca23c97712 --- /dev/null +++ b/.kokoro/functions/scheduleinstance.cfg @@ -0,0 +1,13 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Set the folder in which the tests are run +env_vars: { + key: "PROJECT" + value: "functions/scheduleinstance" +} + +# Tell the trampoline which build file to use. +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/nodejs-docs-samples/.kokoro/build.sh" +} diff --git a/functions/scheduleinstance/README.md b/functions/scheduleinstance/README.md new file mode 100644 index 0000000000..01132b6b77 --- /dev/null +++ b/functions/scheduleinstance/README.md @@ -0,0 +1,31 @@ +Google Cloud Platform logo + +# Google Cloud Functions - Scheduling GCE Instances sample + +## Deploy and run the sample + +See the [Scheduling Instances with Cloud Scheduler tutorial][tutorial]. + +[tutorial]: https://cloud.google.com/scheduler/docs/scheduling-instances-with-cloud-scheduler + +## Run the tests + +1. Read and follow the [prerequisites](../../#how-to-run-the-tests). + +1. Install dependencies: + + npm install + +1. Run the tests: + + npm test + +## Additional resources + +* [Cloud Scheduler documentation][docs] +* [HTTP Cloud Functions documentation][http_docs] +* [HTTP Cloud Functions tutorial][http_tutorial] + +[docs]: https://cloud.google.com/scheduler/docs/ +[http_docs]: https://cloud.google.com/functions/docs/writing/http +[http_tutorial]: https://cloud.google.com/functions/docs/tutorials/http diff --git a/functions/scheduleinstance/index.js b/functions/scheduleinstance/index.js new file mode 100644 index 0000000000..ab05cae493 --- /dev/null +++ b/functions/scheduleinstance/index.js @@ -0,0 +1,156 @@ +/** + * Copyright 2018, Google, Inc. + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// [START functions_start_instance_http] +// [START functions_stop_instance_http] +const Compute = require('@google-cloud/compute'); +const compute = new Compute(); + +// [END functions_stop_instance_http] +/** + * Starts a Compute Engine instance. + * + * Expects an HTTP POST request with a request body containing the following + * attributes: + * zone - the GCP zone the instance is located in. + * instance - the name of the instance. + * + * @param {!object} req Cloud Function HTTP request data. + * @param {!object} res Cloud Function HTTP response data. + * @returns {!object} Cloud Function response data with status code and message. + */ +exports.startInstance = (req, res) => { + try { + const reqBody = _validateReqBody(_parseReqBody(_validateReq(req))); + compute.zone(reqBody.zone) + .vm(reqBody.instance) + .start() + .then(data => { + // Operation pending. + const operation = data[0]; + return operation.promise(); + }) + .then(() => { + // Operation complete. Instance successfully started. + const message = 'Successfully started instance ' + reqBody.instance; + console.log(message); + res.status(200).send(message); + }) + .catch(err => { + console.log(err); + res.status(500).send({error: err.message}); + }); + } catch (err) { + console.log(err); + res.status(400).send({error: err.message}); + } + return res; +}; +// [END functions_start_instance_http] + +// [START functions_stop_instance_http] +/** + * Stops a Compute Engine instance. + * + * Expects an HTTP POST request with a request body containing the following + * attributes: + * zone - the GCP zone the instance is located in. + * instance - the name of the instance. + * + * @param {!object} req Cloud Function HTTP request data. + * @param {!object} res Cloud Function HTTP response data. + * @returns {!object} Cloud Function response data with status code and message. + */ +exports.stopInstance = (req, res) => { + try { + const reqBody = _validateReqBody(_parseReqBody(_validateReq(req))); + compute.zone(reqBody.zone) + .vm(reqBody.instance) + .stop() + .then(data => { + // Operation pending. + const operation = data[0]; + return operation.promise(); + }) + .then(() => { + // Operation complete. Instance successfully stopped. + const message = 'Successfully stopped instance ' + reqBody.instance; + console.log(message); + res.status(200).send(message); + }) + .catch(err => { + console.log(err); + res.status(500).send({error: err.message}); + }); + } catch (err) { + console.log(err); + res.status(400).send({error: err.message}); + } + return res; +}; +// [START functions_start_instance_http] + +/** + * Parses the request body attributes of an HTTP request based on content-type. + * + * @param {!object} req a Cloud Functions HTTP request object. + * @returns {!object} an object with attributes matching the HTTP request body. + */ +function _parseReqBody (req) { + const contentType = req.get('content-type'); + if (contentType === 'application/json') { + // Request.body automatically parsed as an object. + return req.body; + } else if (contentType === 'application/octet-stream') { + // Convert buffer to a string and parse as JSON string. + return JSON.parse(req.body.toString()); + } else { + throw new Error('Unsupported HTTP content-type ' + req.get('content-type') + + '; use application/json or application/octet-stream'); + } +} + +/** + * Validates that a request body contains the expected fields. + * + * @param {!object} reqBody the request body to validate. + * @returns {!object} the request body object. + */ +function _validateReqBody (reqBody) { + if (!reqBody.zone) { + throw new Error(`Attribute 'zone' missing from POST request`); + } else if (!reqBody.instance) { + throw new Error(`Attribute 'instance' missing from POST request`); + } + return reqBody; +} + +/** + * Validates that a HTTP request contains the expected fields. + * + * @param {!object} req the request to validate. + * @returns {!object} the request object. + */ +function _validateReq (req) { + if (req.method !== 'POST') { + throw new Error('Unsupported HTTP method ' + req.method + + '; use method POST'); + } else if (typeof req.get('content-type') === 'undefined') { + throw new Error('HTTP content-type missing'); + } + return req; +} +// [END functions_start_instance_http] +// [END functions_stop_instance_http] diff --git a/functions/scheduleinstance/package.json b/functions/scheduleinstance/package.json new file mode 100644 index 0000000000..8a5b57dd86 --- /dev/null +++ b/functions/scheduleinstance/package.json @@ -0,0 +1,29 @@ +{ + "name": "schedule-instance", + "version": "0.0.1", + "private": true, + "license": "Apache-2.0", + "author": "Google Inc.", + "repository": { + "type": "git", + "url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git" + }, + "engines": { + "node": ">=6.0" + }, + "scripts": { + "lint": "repo-tools lint", + "pretest": "npm run lint", + "test": "ava -T 20s --verbose test/*.test.js" + }, + "devDependencies": { + "@google-cloud/nodejs-repo-tools": "2.2.1", + "ava": "0.25.0", + "proxyquire": "2.0.0", + "sinon": "4.4.2" + }, + "dependencies": { + "@google-cloud/compute": "^0.10.0", + "safe-buffer": "5.1.1" + } +} diff --git a/functions/scheduleinstance/test/index.test.js b/functions/scheduleinstance/test/index.test.js new file mode 100644 index 0000000000..8085e73d8a --- /dev/null +++ b/functions/scheduleinstance/test/index.test.js @@ -0,0 +1,295 @@ + +/** + * Copyright 2018, Google, Inc. + * Licensed 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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Buffer = require('safe-buffer').Buffer; +const proxyquire = require(`proxyquire`).noCallThru(); +const sinon = require(`sinon`); +const test = require(`ava`); +const tools = require(`@google-cloud/nodejs-repo-tools`); + +function getSample () { + const requestPromise = sinon.stub().returns(new Promise((resolve) => resolve(`request sent`))); + + return { + program: proxyquire(`../`, { + 'request-promise': requestPromise + }), + mocks: { + requestPromise: requestPromise + } + }; +} + +function getMocks () { + const req = { + headers: {}, + body: {}, + get: function (header) { + return this.headers[header]; + } + }; + sinon.spy(req, `get`); + + const res = { + set: sinon.stub().returnsThis(), + send: function (message) { + this.message = message; + return this; + }, + json: sinon.stub().returnsThis(), + end: sinon.stub().returnsThis(), + status: function (statusCode) { + this.statusCode = statusCode; + return this; + } + }; + sinon.spy(res, 'status'); + sinon.spy(res, 'send'); + + return { + req: req, + res: res + }; +} + +test.beforeEach(tools.stubConsole); +test.afterEach.always(tools.restoreConsole); + +test(`startInstance: should accept application/json`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {zone: `test-zone`, instance: `test-instance`}; + sample.program.startInstance(mocks.req, mocks.res); + + sample.mocks.requestPromise() + .then((data) => { + // The request was successfully sent. + t.deepEqual(data, 'request sent'); + }); +}); + +test(`startInstance: should accept application/octect-stream`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/octet-stream`; + mocks.req.body = Buffer.from(`{'zone': 'test-zone', 'instance': 'test-instance'}`); + sample.program.startInstance(mocks.req, mocks.res); + + sample.mocks.requestPromise() + .then((data) => { + // The request was successfully sent. + t.deepEqual(data, 'request sent'); + }); +}); + +test(`startInstance: should fail missing HTTP request method`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.startInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'Unsupported HTTP method undefined; use method POST'}); +}); + +test(`startInstance: should reject HTTP GET request`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `GET`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.startInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'Unsupported HTTP method GET; use method POST'}); +}); + +test(`startInstance: should fail missing content-type header`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.startInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'HTTP content-type missing'}); +}); + +test(`startInstance: should reject unsupported HTTP content-type`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `text/plain`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.startInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'Unsupported HTTP content-type text/plain; use application/json or application/octet-stream'}); +}); + +test(`startInstance: should fail with missing 'zone' attribute`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'instance': 'test-instance'}; + sample.program.startInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: `Attribute 'zone' missing from POST request`}); +}); + +test(`startInstance: should fail with missing 'instance' attribute`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'zone': 'test-zone'}; + sample.program.startInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: `Attribute 'instance' missing from POST request`}); +}); + +test(`stopInstance: should accept application/json`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {zone: `test-zone`, instance: `test-instance`}; + sample.program.stopInstance(mocks.req, mocks.res); + + sample.mocks.requestPromise() + .then((data) => { + // The request was successfully sent. + t.deepEqual(data, 'request sent'); + }); +}); + +test(`stopInstance: should accept application/octect-stream`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/octet-stream`; + mocks.req.body = Buffer.from(`{'zone': 'test-zone', 'instance': 'test-instance'}`); + sample.program.stopInstance(mocks.req, mocks.res); + + sample.mocks.requestPromise() + .then((data) => { + // The request was successfully sent. + t.deepEqual(data, 'request sent'); + }); +}); + +test(`stopInstance: should fail missing HTTP request method`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.stopInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'Unsupported HTTP method undefined; use method POST'}); +}); + +test(`stopInstance: should reject HTTP GET request`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `GET`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.stopInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'Unsupported HTTP method GET; use method POST'}); +}); + +test(`stopInstance: should fail missing content-type header`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.stopInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'HTTP content-type missing'}); +}); + +test(`stopInstance: should reject unsupported HTTP content-type`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `text/plain`; + mocks.req.body = {'zone': 'test-zone', 'instance': 'test-instance'}; + sample.program.stopInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: 'Unsupported HTTP content-type text/plain; use application/json or application/octet-stream'}); +}); + +test(`stopInstance: should fail with missing 'zone' attribute`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'instance': 'test-instance'}; + sample.program.stopInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: `Attribute 'zone' missing from POST request`}); +}); + +test(`stopInstance: should fail with missing 'instance' attribute`, async (t) => { + const mocks = getMocks(); + const sample = getSample(); + mocks.req.method = `POST`; + mocks.req.headers[`content-type`] = `application/json`; + mocks.req.body = {'zone': 'test-zone'}; + sample.program.stopInstance(mocks.req, mocks.res); + + t.true(mocks.res.status.calledOnce); + t.is(mocks.res.status.firstCall.args[0], 400); + t.true(mocks.res.send.calledOnce); + t.deepEqual(mocks.res.send.firstCall.args[0], {error: `Attribute 'instance' missing from POST request`}); +});