diff --git a/functions/scheduleinstance/README.md b/functions/scheduleinstance/README.md index 3605201d940..efaaacc30e5 100644 --- a/functions/scheduleinstance/README.md +++ b/functions/scheduleinstance/README.md @@ -1,12 +1,12 @@ Google Cloud Platform logo -# Google Cloud Functions - Scheduling GCE Instances sample +# Cloud Run functions - Scheduling Cloud SQL instances sample ## Deploy and run the sample -See the [Scheduling Instances with Cloud Scheduler tutorial][tutorial]. +See the [Schedule a Cloud SQL instance to start or stop][tutorial] tutorial. -[tutorial]: https://cloud.google.com/scheduler/docs/scheduling-instances-with-cloud-scheduler +[tutorial]: https://docs.cloud.google.com/scheduler/docs/start-and-stop-sql-server-instances-on-a-schedule ## Run the tests @@ -20,8 +20,8 @@ See the [Scheduling Instances with Cloud Scheduler tutorial][tutorial]. ## Additional resources -* [GCE NodeJS Client Library documentation][compute_nodejs_docs] -* [Background Cloud Functions documentation][background_functions_docs] +* [Cloud Run functions client libraries][functions_nodejs_docs] +* [Write Cloud Run functions][background_functions_docs] -[compute_nodejs_docs]: https://cloud.google.com/compute/docs/tutorials/nodejs-guide -[background_functions_docs]: https://cloud.google.com/functions/docs/writing/background +[functions_nodejs_docs]: https://docs.cloud.google.com/functions/docs/apis/libraries +[background_functions_docs]: https://docs.cloud.google.com/run/docs/write-functions diff --git a/functions/scheduleinstance/index.js b/functions/scheduleinstance/index.js index 66c5312b90e..8532d2f82b8 100644 --- a/functions/scheduleinstance/index.js +++ b/functions/scheduleinstance/index.js @@ -14,133 +14,155 @@ // [START functions_start_instance_pubsub] // [START functions_stop_instance_pubsub] -const compute = require('@google-cloud/compute'); -const instancesClient = new compute.InstancesClient(); -const operationsClient = new compute.ZoneOperationsClient(); - -async function waitForOperation(projectId, operation) { - while (operation.status !== 'DONE') { - [operation] = await operationsClient.wait({ - operation: operation.name, - project: projectId, - zone: operation.zone.split('/').pop(), - }); - } -} +const functions = require('@google-cloud/functions-framework'); +const {SqlInstancesServiceClient} = require('@google-cloud/sql'); +const sqlInstancesClient = new SqlInstancesServiceClient({fallback: true}); // [END functions_stop_instance_pubsub] /** - * Starts Compute Engine instances. + * Starts Cloud SQL instances. * - * Expects a PubSub message with JSON-formatted event data containing the - * following attributes: - * zone - the GCP zone the instances are located in. - * label - the label of instances to start. + * Expects a PubSub message with JSON-formatted event data containing + * the label of instances to start. * - * @param {!object} event Cloud Function PubSub message event. - * @param {!object} callback Cloud Function PubSub callback indicating - * completion. */ -exports.startInstancePubSub = async (event, context, callback) => { +functions.cloudEvent('startInstanceEvent', async cloudEvent => { try { - const project = await instancesClient.getProjectId(); - const payload = _validatePayload(event); - const options = { - filter: `labels.${payload.label}`, + const payload = _validatePayload(cloudEvent); + const project = await sqlInstancesClient.getProjectId(); + const [labelKey, labelValue] = payload.label.split('='); + const filter = `settings.userLabels.${labelKey}:${labelValue}`; + + console.log( + `Attempting to start instances. Project ID resolved to: '${project}'. Filter applied: '${filter}'` + ); + + // Fetch the response object + const [response] = await sqlInstancesClient.list({ project, - zone: payload.zone, - }; + filter, + }); - const [instances] = await instancesClient.list(options); + // Extract the array from the 'items' property (default to empty array if undefined) + const instances = response.items || []; + + console.log( + `Raw instances array retrieved. Found ${instances.length} matching instances.` + ); + + if (instances.length === 0) { + console.log( + `No SQL instances found in project '${project}' matching filter '${filter}'.` + ); + return; + } await Promise.all( instances.map(async instance => { - const [response] = await instancesClient.start({ + console.log(`Starting Cloud SQL instance: ${instance.name}`); + const request = { project, - zone: payload.zone, instance: instance.name, - }); - - return waitForOperation(project, response.latestResponse); + body: { + settings: { + activationPolicy: 'ALWAYS', + }, + }, + }; + const [operation] = await sqlInstancesClient.patch(request); + console.log( + `Patch operation started for ${instance.name}: ${operation.name}` + ); }) ); - - // Operation complete. Instance successfully started. - const message = 'Successfully started instance(s)'; - console.log(message); - callback(null, message); } catch (err) { - console.log(err); - callback(err); + console.error(err); + throw err; } -}; +}); // [END functions_start_instance_pubsub] // [START functions_stop_instance_pubsub] /** - * Stops Compute Engine instances. + * Stops Cloud SQL instances. * - * Expects a PubSub message with JSON-formatted event data containing the - * following attributes: - * zone - the GCP zone the instances are located in. - * label - the label of instances to stop. + * Expects a PubSub message with JSON-formatted event data containing + * the label of instances to stop. * - * @param {!object} event Cloud Function PubSub message event. - * @param {!object} callback Cloud Function PubSub callback indicating completion. */ -exports.stopInstancePubSub = async (event, context, callback) => { +functions.cloudEvent('stopInstanceEvent', async cloudEvent => { try { - const project = await instancesClient.getProjectId(); - const payload = _validatePayload(event); - const options = { - filter: `labels.${payload.label}`, + const payload = _validatePayload(cloudEvent); + const project = await sqlInstancesClient.getProjectId(); + const [labelKey, labelValue] = payload.label.split('='); + const filter = `settings.userLabels.${labelKey}:${labelValue}`; + + console.log( + `Attempting to stop instances. Project ID resolved to: '${project}'. Filter applied: '${filter}'` + ); + + // Fetch the response object + const [response] = await sqlInstancesClient.list({ project, - zone: payload.zone, - }; + filter, + }); + + // Extract the array from the 'items' property (default to empty array if undefined) + const instances = response.items || []; + + console.log( + `Raw instances array retrieved. Found ${instances.length} matching instances.` + ); - const [instances] = await instancesClient.list(options); + if (instances.length === 0) { + console.log( + `No SQL instances found in project '${project}' matching filter '${filter}'.` + ); + return; + } await Promise.all( instances.map(async instance => { - const [response] = await instancesClient.stop({ + console.log(`Stopping Cloud SQL instance: ${instance.name}`); + const request = { project, - zone: payload.zone, instance: instance.name, - }); - - return waitForOperation(project, response.latestResponse); + body: { + settings: { + activationPolicy: 'NEVER', + }, + }, + }; + const [operation] = await sqlInstancesClient.patch(request); + console.log( + `Patch operation started for ${instance.name}: ${operation.name}` + ); }) ); - - // Operation complete. Instance successfully stopped. - const message = 'Successfully stopped instance(s)'; - console.log(message); - callback(null, message); } catch (err) { - console.log(err); - callback(err); + console.error(err); + throw err; } -}; +}); // [START functions_start_instance_pubsub] /** * Validates that a request payload contains the expected fields. - * - * @param {!object} payload the request payload to validate. - * @return {!object} the payload object. */ -const _validatePayload = event => { +const _validatePayload = cloudEvent => { let payload; try { - payload = JSON.parse(Buffer.from(event.data, 'base64').toString()); + const base64Data = cloudEvent.data.message.data; + payload = JSON.parse(Buffer.from(base64Data, 'base64').toString()); } catch (err) { - throw new Error('Invalid Pub/Sub message: ' + err); + throw new Error('Invalid CloudEvent / Pub/Sub message: ' + err); } - if (!payload.zone) { - throw new Error("Attribute 'zone' missing from payload"); - } else if (!payload.label) { + if (!payload.label) { throw new Error("Attribute 'label' missing from payload"); } + if (typeof payload.label !== 'string' || !payload.label.includes('=')) { + throw new Error("Attribute 'label' must be in the format 'key=value'"); + } return payload; }; // [END functions_start_instance_pubsub] diff --git a/functions/scheduleinstance/package.json b/functions/scheduleinstance/package.json index d050b4e0167..be46d624a11 100644 --- a/functions/scheduleinstance/package.json +++ b/functions/scheduleinstance/package.json @@ -1,5 +1,5 @@ { - "name": "cloud-functions-schedule-instance", + "name": "cloud-functions-schedule-sql", "version": "0.1.0", "private": true, "license": "Apache-2.0", @@ -21,6 +21,7 @@ "sinon": "^18.0.0" }, "dependencies": { - "@google-cloud/compute": "^4.0.0" + "@google-cloud/functions-framework": "^3.0.0", + "@google-cloud/sql": "^0.20.1" } } diff --git a/functions/scheduleinstance/test/index.test.js b/functions/scheduleinstance/test/index.test.js index 274c0581199..ddc57fc6579 100644 --- a/functions/scheduleinstance/test/index.test.js +++ b/functions/scheduleinstance/test/index.test.js @@ -19,210 +19,161 @@ const proxyquire = require('proxyquire').noCallThru(); const assert = require('assert'); const getSample = () => { - const requestPromise = sinon - .stub() - .returns(new Promise(resolve => resolve('request sent'))); + const getProjectIdStub = sinon.stub().resolves('test-project'); + const listStub = sinon.stub().resolves([{items: [{name: 'test-instance'}]}]); + const patchStub = sinon.stub().resolves([{name: 'test-operation'}]); + const registeredFunctions = {}; + + proxyquire('../index', { + '@google-cloud/functions-framework': { + cloudEvent: (name, handler) => { + registeredFunctions[name] = handler; + }, + }, + '@google-cloud/sql': { + SqlInstancesServiceClient: function () { + this.getProjectId = getProjectIdStub; + this.list = listStub; + this.patch = patchStub; + }, + }, + }); return { - program: proxyquire('../', { - '@google-cloud/compute': { - InstancesClient: function client() { - this.list = () => new Promise(resolve => resolve([[]])); - this.start = () => new Promise(resolve => resolve('request sent')); - this.getProjectId = () => 'project'; - }, - ZoneOperationsClient: function client() { - this.wait = () => new Promise(resolve => resolve('request sent')); - }, - }, - }), + registeredFunctions, mocks: { - requestPromise: requestPromise, + getProjectIdStub, + listStub, + patchStub, }, }; }; -const getMocks = () => { - const event = { - data: {}, - }; - - const callback = sinon.spy(); - - return { - event: event, - context: {}, - callback: callback, - }; -}; +const getCloudEvent = data => ({ + data: { + message: { + data: Buffer.from(JSON.stringify(data)).toString('base64'), + }, + }, +}); const stubConsole = function () { sinon.stub(console, 'error'); sinon.stub(console, 'log'); }; -//Restore console const restoreConsole = function () { console.log.restore(); console.error.restore(); }; + beforeEach(stubConsole); afterEach(restoreConsole); -/** Tests for startInstancePubSub */ -describe('functions_start_instance_pubsub', () => { - it('startInstancePubSub: should accept JSON-formatted event payload with label', async () => { - const mocks = getMocks(); +describe('functions_start_instance_event', () => { + it('startInstanceEvent: should accept CloudEvent payload with label', async () => { const sample = getSample(); - const pubsubData = {zone: 'test-zone', label: 'testkey=value'}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.startInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); - - assert.deepStrictEqual( - mocks.callback.firstCall.args[1], - 'Successfully started instance(s)' - ); + const cloudEvent = getCloudEvent({label: 'env=dev'}); + + await sample.registeredFunctions.startInstanceEvent(cloudEvent); + + assert.strictEqual(sample.mocks.getProjectIdStub.calledOnce, true); + assert.strictEqual(sample.mocks.listStub.calledOnce, true); + assert.deepStrictEqual(sample.mocks.listStub.firstCall.args[0], { + project: 'test-project', + filter: 'settings.userLabels.env:dev', + }); + assert.strictEqual(sample.mocks.patchStub.calledOnce, true); + assert.deepStrictEqual(sample.mocks.patchStub.firstCall.args[0], { + project: 'test-project', + instance: 'test-instance', + body: { + settings: { + activationPolicy: 'ALWAYS', + }, + }, + }); }); - it("startInstancePubSub: should fail with missing 'zone' attribute", async () => { - const mocks = getMocks(); + it("startInstanceEvent: should fail with missing 'label' attribute", async () => { const sample = getSample(); - const pubsubData = {label: 'testkey=value'}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.startInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); + const cloudEvent = getCloudEvent({other: 'value'}); - assert.deepStrictEqual( - mocks.callback.firstCall.args[0], - new Error("Attribute 'zone' missing from payload") + await assert.rejects( + sample.registeredFunctions.startInstanceEvent(cloudEvent), + /Attribute 'label' missing from payload/ ); }); - it("startInstancePubSub: should fail with missing 'label' attribute", async () => { - const mocks = getMocks(); + it('startInstanceEvent: should handle zero instances gracefully', async () => { const sample = getSample(); - const pubsubData = {zone: 'test-zone'}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.startInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); + sample.mocks.listStub.resolves([{}]); // Empty object missing 'items' property + const cloudEvent = getCloudEvent({label: 'env=dev'}); - assert.deepStrictEqual( - mocks.callback.firstCall.args[0], - new Error("Attribute 'label' missing from payload") - ); + await sample.registeredFunctions.startInstanceEvent(cloudEvent); + + assert.strictEqual(sample.mocks.patchStub.called, false); }); - it('startInstancePubSub: should fail with empty event payload', async () => { - const mocks = getMocks(); + it('startInstanceEvent: should fail with invalid CloudEvent payload', async () => { const sample = getSample(); - const pubsubData = {}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.startInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); + const cloudEvent = { + data: { + message: { + data: 'invalid-base64-payload', + }, + }, + }; - assert.deepStrictEqual( - mocks.callback.firstCall.args[0], - new Error("Attribute 'zone' missing from payload") + await assert.rejects( + sample.registeredFunctions.startInstanceEvent(cloudEvent), + /Invalid CloudEvent \/ Pub\/Sub message/ ); }); }); -/** Tests for stopInstancePubSub */ -describe('functions_stop_instance_pubsub', () => { - it('stopInstancePubSub: should accept JSON-formatted event payload with label', async () => { - const mocks = getMocks(); +describe('functions_stop_instance_event', () => { + it('stopInstanceEvent: should accept CloudEvent payload with label', async () => { const sample = getSample(); - const pubsubData = {zone: 'test-zone', label: 'testkey=value'}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.stopInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); - - assert.deepStrictEqual( - mocks.callback.firstCall.args[1], - 'Successfully stopped instance(s)' - ); + const cloudEvent = getCloudEvent({label: 'env=dev'}); + + await sample.registeredFunctions.stopInstanceEvent(cloudEvent); + + assert.strictEqual(sample.mocks.getProjectIdStub.calledOnce, true); + assert.strictEqual(sample.mocks.listStub.calledOnce, true); + assert.deepStrictEqual(sample.mocks.listStub.firstCall.args[0], { + project: 'test-project', + filter: 'settings.userLabels.env:dev', + }); + assert.strictEqual(sample.mocks.patchStub.calledOnce, true); + assert.deepStrictEqual(sample.mocks.patchStub.firstCall.args[0], { + project: 'test-project', + instance: 'test-instance', + body: { + settings: { + activationPolicy: 'NEVER', + }, + }, + }); }); - it("stopInstancePubSub: should fail with missing 'zone' attribute", async () => { - const mocks = getMocks(); + it("stopInstanceEvent: should fail with missing 'label' attribute", async () => { const sample = getSample(); - const pubsubData = {label: 'testkey=value'}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.stopInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); + const cloudEvent = getCloudEvent({other: 'value'}); - assert.deepStrictEqual( - mocks.callback.firstCall.args[0], - new Error("Attribute 'zone' missing from payload") + await assert.rejects( + sample.registeredFunctions.stopInstanceEvent(cloudEvent), + /Attribute 'label' missing from payload/ ); }); - it("stopInstancePubSub: should fail with missing 'label' attribute", async () => { - const mocks = getMocks(); + it('stopInstanceEvent: should handle zero instances gracefully', async () => { const sample = getSample(); - const pubsubData = {zone: 'test-zone'}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.stopInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); - - assert.deepStrictEqual( - mocks.callback.firstCall.args[0], - new Error("Attribute 'label' missing from payload") - ); - }); + sample.mocks.listStub.resolves([{items: []}]); // Empty items array + const cloudEvent = getCloudEvent({label: 'env=dev'}); - it('stopInstancePubSub: should fail with empty event payload', async () => { - const mocks = getMocks(); - const sample = getSample(); - const pubsubData = {}; - mocks.event.data = Buffer.from(JSON.stringify(pubsubData)).toString( - 'base64' - ); - await sample.program.stopInstancePubSub( - mocks.event, - mocks.context, - mocks.callback - ); + await sample.registeredFunctions.stopInstanceEvent(cloudEvent); - assert.deepStrictEqual( - mocks.callback.firstCall.args[0], - new Error("Attribute 'zone' missing from payload") - ); + assert.strictEqual(sample.mocks.patchStub.called, false); }); });