From 24c71f912fdc6889f00511462e8bb88b3a75d85d Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Thu, 8 May 2025 13:37:27 -0600 Subject: [PATCH 01/14] fix(functions): update and fix `functions_billing_stop` sample --- functions/billing/stop_billing/.gcloudignore | 16 ++ functions/billing/stop_billing/index.js | 110 +++++++++++++ functions/billing/stop_billing/package.json | 30 ++++ .../billing/stop_billing/test/index.test.js | 152 ++++++++++++++++++ .../stop_billing/test/periodic.test.js | 89 ++++++++++ 5 files changed, 397 insertions(+) create mode 100644 functions/billing/stop_billing/.gcloudignore create mode 100644 functions/billing/stop_billing/index.js create mode 100644 functions/billing/stop_billing/package.json create mode 100644 functions/billing/stop_billing/test/index.test.js create mode 100644 functions/billing/stop_billing/test/periodic.test.js diff --git a/functions/billing/stop_billing/.gcloudignore b/functions/billing/stop_billing/.gcloudignore new file mode 100644 index 0000000000..ccc4eb240e --- /dev/null +++ b/functions/billing/stop_billing/.gcloudignore @@ -0,0 +1,16 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules diff --git a/functions/billing/stop_billing/index.js b/functions/billing/stop_billing/index.js new file mode 100644 index 0000000000..a3311ad337 --- /dev/null +++ b/functions/billing/stop_billing/index.js @@ -0,0 +1,110 @@ +// Copyright 2025 Google LLC +// +// 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_billing_stop] +const {CloudBillingClient} = require('@google-cloud/billing'); +const functions = require('@google-cloud/functions-framework'); +const gcpMetadata = require('gcp-metadata'); + +const billing = new CloudBillingClient(); + +functions.cloudEvent('stopBilling', async cloudEvent => { + let PROJECT_ID; + + try { + PROJECT_ID = await gcpMetadata.project('project-id'); + } catch (error) { + console.error('PROJECT_ID not found:', error); + return; + } + + const PROJECT_NAME = `projects/${PROJECT_ID}`; + + const eventData = Buffer.from( + cloudEvent.data['message']['data'], + 'base64' + ).toString(); + + const eventObject = JSON.parse(eventData); + + console.log( + `Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}` + ); + + if (eventObject.costAmount <= eventObject.budgetAmount) { + console.log('No action required. Current cost is within budget.'); + return; + } + + const billingEnabled = await _isBillingEnabled(PROJECT_NAME); + if (billingEnabled) { + _disableBillingForProject(PROJECT_NAME); + } else { + console.log('Billing is already disabled.'); + } +}); + +/** + * Determine whether billing is enabled for a project + * @param {string} projectName Name of project to check if billing is enabled + * @return {bool} Whether project has billing enabled or not + */ +const _isBillingEnabled = async projectName => { + try { + console.log(`Getting billing info for project '${projectName}'...`); + const [res] = await billing.getProjectBillingInfo({name: projectName}); + + return res.billingEnabled; + } catch (e) { + console.log('Error getting billing info:', e); + console.log( + 'Unable to determine if billing is enabled on specified project, ' + + 'assuming billing is enabled' + ); + + return true; + } +}; + +/** + * Disable billing for a project by removing its billing account + * @param {string} projectName Name of project disable billing on + */ +const _disableBillingForProject = async projectName => { + console.log(`Disabling billing for project '${projectName}'...`); + + // To disable billing set the `billingAccountName` field to empty + // LINT: Commented out to pass linter + // const requestBody = {billingAccountName: ''}; + + // Find more information about `updateBillingInfo` API method here: + // https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo + + try { + // DEBUG: Simulate disabling billing + console.log('Billing disabled. (Simulated)'); + + /* + const [response] = await billing.updateProjectBillingInfo({ + name: projectName, + resource: body, // Disable billing + }); + + console.log(`Billing disabled: ${JSON.stringify(response)}`); + */ + } catch (e) { + console.log('Failed to disable billing, check permissions.', e); + } +}; +// [END functions_billing_stop] diff --git a/functions/billing/stop_billing/package.json b/functions/billing/stop_billing/package.json new file mode 100644 index 0000000000..c3bc9fb60c --- /dev/null +++ b/functions/billing/stop_billing/package.json @@ -0,0 +1,30 @@ +{ + "name": "cloud-functions-stop-billing", + "private": "true", + "version": "0.0.1", + "description": "Disable billing with a budget notification.", + "main": "index.js", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "compute-test": "c8 mocha -p -j 2 test/periodic.test.js --timeout=600000", + "test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit" + }, + "author": "Ace Nassri ", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/billing": "^4.0.0", + "@google-cloud/functions-framework": "^3.0.0", + "gcp-metadata": "^6.0.0" + }, + "devDependencies": { + "c8": "^10.0.0", + "gaxios": "^6.0.0", + "mocha": "^10.0.0", + "promise-retry": "^2.0.0", + "proxyquire": "^2.1.0", + "sinon": "^18.0.0", + "wait-port": "^1.0.4" + } +} diff --git a/functions/billing/stop_billing/test/index.test.js b/functions/billing/stop_billing/test/index.test.js new file mode 100644 index 0000000000..47328b1d96 --- /dev/null +++ b/functions/billing/stop_billing/test/index.test.js @@ -0,0 +1,152 @@ +// Copyright 2019 Google LLC +// +// 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. + +const {exec} = require('child_process'); +const {request} = require('gaxios'); +const assert = require('assert'); +const sinon = require('sinon'); +const waitPort = require('wait-port'); +const {InstancesClient} = require('@google-cloud/compute'); +const sample = require('../index.js'); + +const {BILLING_ACCOUNT} = process.env; + +describe('functions/billing tests', () => { + let projectId; + before(async () => { + const client = new InstancesClient(); + projectId = await client.getProjectId(); + }); + after(async () => { + // Re-enable billing using the sample file itself + // Invoking the file directly is more concise vs. re-implementing billing setup here + const jsonData = { + billingAccountName: `billingAccounts/${BILLING_ACCOUNT}`, + projectName: `projects/${projectId}`, + }; + const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( + 'base64' + ); + const pubsubMessage = {data: encodedData, attributes: {}}; + await require('../').startBilling(pubsubMessage); + }); + + describe('notifies Slack', () => { + let ffProc; + const PORT = 8080; + const BASE_URL = `http://localhost:${PORT}`; + + before(async () => { + console.log('Starting functions-framework process...'); + ffProc = exec( + `npx functions-framework --target=notifySlack --signature-type=event --port ${PORT}` + ); + await waitPort({host: 'localhost', port: PORT}); + console.log('functions-framework process started and listening!'); + }); + + after(() => { + console.log('Ending functions-framework process...'); + ffProc.kill(); + console.log('functions-framework process stopped.'); + }); + + describe('functions_billing_slack', () => { + it('should notify Slack when budget is exceeded', async () => { + const jsonData = {costAmount: 500, budgetAmount: 400}; + const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( + 'base64' + ); + const pubsubMessage = {data: encodedData, attributes: {}}; + + const response = await request({ + url: `${BASE_URL}/notifySlack`, + method: 'POST', + data: {data: pubsubMessage}, + }); + + assert.strictEqual(response.status, 200); + assert.strictEqual( + response.data, + 'Slack notification sent successfully' + ); + }); + }); + }); + + describe('disables billing', () => { + let ffProc; + const PORT = 8081; + const BASE_URL = `http://localhost:${PORT}`; + + before(async () => { + console.log('Starting functions-framework process...'); + ffProc = exec( + `npx functions-framework --target=stopBilling --signature-type=event --port ${PORT}` + ); + await waitPort({host: 'localhost', port: PORT}); + console.log('functions-framework process started and listening!'); + }); + + after(() => { + console.log('Ending functions-framework process...'); + ffProc.kill(); + console.log('functions-framework process stopped.'); + }); + + describe('functions_billing_stop', () => { + xit('should disable billing when budget is exceeded', async () => { + // Use functions framework to ensure sample follows GCF specification + // (Invoking it directly works too, but DOES NOT ensure GCF compatibility) + const jsonData = {costAmount: 500, budgetAmount: 400}; + const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( + 'base64' + ); + const pubsubMessage = {data: encodedData, attributes: {}}; + + const response = await request({ + url: `${BASE_URL}/stopBilling`, + method: 'POST', + data: {data: pubsubMessage}, + }); + + assert.strictEqual(response.status, 200); + assert.ok(response.data.includes('Billing disabled')); + }); + }); + }); + + describe('shuts down GCE instances', () => { + describe('functions_billing_limit', () => { + it('should attempt to shut down GCE instances when budget is exceeded', async () => { + const jsonData = {costAmount: 500, budgetAmount: 400}; + const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( + 'base64' + ); + const pubsubMessage = {data: encodedData, attributes: {}}; + // Mock GCE (because real GCE instances take too long to start/stop) + const instances = [{name: 'test-instance-1', status: 'RUNNING'}]; + const listStub = sinon + .stub(sample.getInstancesClient(), 'list') + .resolves([instances]); + const stopStub = sinon + .stub(sample.getInstancesClient(), 'stop') + .resolves({}); + await sample.limitUse(pubsubMessage); + assert.strictEqual(listStub.calledOnce, true); + assert.ok(stopStub.calledOnce); + }); + }); + }); +}); diff --git a/functions/billing/stop_billing/test/periodic.test.js b/functions/billing/stop_billing/test/periodic.test.js new file mode 100644 index 0000000000..1d6496126c --- /dev/null +++ b/functions/billing/stop_billing/test/periodic.test.js @@ -0,0 +1,89 @@ +// Copyright 2019 Google LLC +// +// 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. + +const {exec} = require('child_process'); +const {request} = require('gaxios'); +const assert = require('assert'); +const promiseRetry = require('promise-retry'); + +const BASE_URL = process.env.BASE_URL || 'http://localhost:8080'; + +describe('functions_billing_limit', () => { + let ffProc; + before(async () => { + console.log('Running periodic before hook....'); + // Re-enable compute instances using the sample file itself + const {startInstances, listRunningInstances} = require('../'); + + const emptyJson = JSON.stringify({}); + const encodedData = Buffer.from(emptyJson).toString('base64'); + const emptyMessage = {data: encodedData, attributes: {}}; + + await startInstances(emptyMessage); + + try { + await promiseRetry( + async (retry, n) => { + const result = await listRunningInstances(emptyMessage); + + console.log(`${n}: ${result}`); + if (result.length > 0) { + return Promise.resolve(); + } else { + return retry(); + } + }, + {retries: 8} + ); + } catch (err) { + console.error('Failed to restart GCE instances:', err); + } + console.log('Periodic before hook complete.'); + }); + + after(() => { + console.log('Ending functions-framework process...'); + ffProc.kill(); + console.log('functions-framework process stopped.'); + }); + + it('should shut down GCE instances when budget is exceeded', async () => { + console.log('Starting functions-framework process...'); + ffProc = exec( + 'npx functions-framework --target=limitUse --signature-type=event' + ); + console.log('functions-framework process started and listening!'); + + const jsonData = {costAmount: 500, budgetAmount: 400}; + const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( + 'base64' + ); + const pubsubMessage = {data: encodedData, attributes: {}}; + + const response = await request({ + url: `${BASE_URL}/`, + method: 'POST', + data: {data: pubsubMessage}, + retryConfig: { + retries: 3, + retryDelay: 200, + }, + }); + + console.log(response.data); + + assert.strictEqual(response.status, 200); + assert.ok(response.data.includes('instance(s) stopped successfully')); + }); +}); From 5863455a399932dd4488b39c47a6bf231487c8a5 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Fri, 9 May 2025 12:00:05 -0600 Subject: [PATCH 02/14] fix(functions): add a variable for the developer to simulate disabling billing - Style fixes - Fix comments --- functions/billing/stop_billing/index.js | 51 ++++++++++++++----------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/functions/billing/stop_billing/index.js b/functions/billing/stop_billing/index.js index a3311ad337..204780ff5a 100644 --- a/functions/billing/stop_billing/index.js +++ b/functions/billing/stop_billing/index.js @@ -20,16 +20,21 @@ const gcpMetadata = require('gcp-metadata'); const billing = new CloudBillingClient(); functions.cloudEvent('stopBilling', async cloudEvent => { - let PROJECT_ID; + // TODO(developer): As stopping billing is a destructive action + // for your project, change the following constant to false + // after you validate with a test budget. + const simulateDeactivation = true; + + let projectId; try { - PROJECT_ID = await gcpMetadata.project('project-id'); + projectId = await gcpMetadata.project('project-id'); } catch (error) { - console.error('PROJECT_ID not found:', error); + console.error('project-id metadata not found:', error); return; } - const PROJECT_NAME = `projects/${PROJECT_ID}`; + const projectName = `projects/${projectId}`; const eventData = Buffer.from( cloudEvent.data['message']['data'], @@ -47,9 +52,11 @@ functions.cloudEvent('stopBilling', async cloudEvent => { return; } - const billingEnabled = await _isBillingEnabled(PROJECT_NAME); + console.log(`Disabling billing for project '${projectName}'...`); + + const billingEnabled = await _isBillingEnabled(projectName); if (billingEnabled) { - _disableBillingForProject(PROJECT_NAME); + _disableBillingForProject(projectName, simulateDeactivation); } else { console.log('Billing is already disabled.'); } @@ -57,8 +64,8 @@ functions.cloudEvent('stopBilling', async cloudEvent => { /** * Determine whether billing is enabled for a project - * @param {string} projectName Name of project to check if billing is enabled - * @return {bool} Whether project has billing enabled or not + * @param {string} projectName The name of the project to check + * @returns {boolean} Whether the project has billing enabled or not */ const _isBillingEnabled = async projectName => { try { @@ -79,30 +86,30 @@ const _isBillingEnabled = async projectName => { /** * Disable billing for a project by removing its billing account - * @param {string} projectName Name of project disable billing on + * @param {string} projectName The name of the project to disable billing + * @param {boolean} simulateDeactivation + * If true, it won't actually disable billing. + * Useful to validate with test budgets. + * @returns {void} */ -const _disableBillingForProject = async projectName => { - console.log(`Disabling billing for project '${projectName}'...`); - - // To disable billing set the `billingAccountName` field to empty - // LINT: Commented out to pass linter - // const requestBody = {billingAccountName: ''}; +const _disableBillingForProject = async (projectName, simulateDeactivation) => { + if (simulateDeactivation) { + console.log('Billing disabled. (Simulated)'); + return; + } - // Find more information about `updateBillingInfo` API method here: + // Find more information about `projects/updateBillingInfo` API method here: // https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo - try { - // DEBUG: Simulate disabling billing - console.log('Billing disabled. (Simulated)'); + // To disable billing set the `billingAccountName` field to empty + const requestBody = {billingAccountName: ''}; - /* const [response] = await billing.updateProjectBillingInfo({ name: projectName, - resource: body, // Disable billing + resource: requestBody, }); console.log(`Billing disabled: ${JSON.stringify(response)}`); - */ } catch (e) { console.log('Failed to disable billing, check permissions.', e); } From 2e873960d986f03c86c37ee743b4921eb8f86b3b Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Tue, 22 Jul 2025 11:58:50 -0600 Subject: [PATCH 03/14] fix(functions): stop-billing - update dependencies to latest versions --- functions/billing/stop_billing/package.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/functions/billing/stop_billing/package.json b/functions/billing/stop_billing/package.json index c3bc9fb60c..394cd5f551 100644 --- a/functions/billing/stop_billing/package.json +++ b/functions/billing/stop_billing/package.json @@ -8,23 +8,25 @@ "node": ">=20.0.0" }, "scripts": { - "compute-test": "c8 mocha -p -j 2 test/periodic.test.js --timeout=600000", - "test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit" + "test": "c8 mocha -p -j 2 test/stop-billing.test.js --timeout=5000 --exit" }, - "author": "Ace Nassri ", + "author": "Emmanuel Parada ", "license": "Apache-2.0", "dependencies": { "@google-cloud/billing": "^4.0.0", "@google-cloud/functions-framework": "^3.0.0", + "cloudevents": "^9.0.0", "gcp-metadata": "^6.0.0" }, "devDependencies": { "c8": "^10.0.0", + "chai": "^5.2.1", "gaxios": "^6.0.0", "mocha": "^10.0.0", "promise-retry": "^2.0.0", "proxyquire": "^2.1.0", "sinon": "^18.0.0", + "supertest": "^7.1.3", "wait-port": "^1.0.4" } } From 96a833443279ab5e70f599069ac1a08c598e54d3 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Tue, 22 Jul 2025 12:55:55 -0600 Subject: [PATCH 04/14] fix(functions): stop-billing-cloud-event - WIP Implement sample - Implement a PoC unit test for the CloudEvent received - Refactor index.js in stop-billing.js to validate in local environment and Cloud Run --- functions/billing/stop_billing/package.json | 18 +-- .../billing/stop_billing/stop-billing.js | 129 ++++++++++++++++++ .../stop_billing/test/stop-billing.test.js | 62 +++++++++ 3 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 functions/billing/stop_billing/stop-billing.js create mode 100644 functions/billing/stop_billing/test/stop-billing.test.js diff --git a/functions/billing/stop_billing/package.json b/functions/billing/stop_billing/package.json index 394cd5f551..aa6ad01ba1 100644 --- a/functions/billing/stop_billing/package.json +++ b/functions/billing/stop_billing/package.json @@ -13,20 +13,12 @@ "author": "Emmanuel Parada ", "license": "Apache-2.0", "dependencies": { - "@google-cloud/billing": "^4.0.0", - "@google-cloud/functions-framework": "^3.0.0", - "cloudevents": "^9.0.0", - "gcp-metadata": "^6.0.0" + "@google-cloud/billing": "^5.1.0", + "@google-cloud/functions-framework": "^4.0.0", + "cloudevents": "^10.0.0", + "gcp-metadata": "^7.0.1" }, "devDependencies": { - "c8": "^10.0.0", - "chai": "^5.2.1", - "gaxios": "^6.0.0", - "mocha": "^10.0.0", - "promise-retry": "^2.0.0", - "proxyquire": "^2.1.0", - "sinon": "^18.0.0", - "supertest": "^7.1.3", - "wait-port": "^1.0.4" + "c8": "^10.1.3" } } diff --git a/functions/billing/stop_billing/stop-billing.js b/functions/billing/stop_billing/stop-billing.js new file mode 100644 index 0000000000..ac63928313 --- /dev/null +++ b/functions/billing/stop_billing/stop-billing.js @@ -0,0 +1,129 @@ +// https://github.com/GoogleCloudPlatform/functions-framework-python +// https://github.com/GoogleCloudPlatform/functions-framework-nodejs + +// Simplified representation of `stop_billing` logic +const {CloudBillingClient} = require('@google-cloud/billing'); +const functions = require('@google-cloud/functions-framework'); +const gcpMetadata = require('gcp-metadata'); + +const billing = new CloudBillingClient(); + +const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT; + +/* +functions.cloudEvent('StopBillingCloudEvent', async (cloudEvent) => { + // console.log(cloudEvent); + const eventData = Buffer.from( + cloudEvent.data['message']['data'], + 'base64' + ).toString(); + + const eventObject = JSON.parse(eventData); + + console.log( + `Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}` + ); + + console.log("Getting billing info for project..."); + console.log("Disabling billing for project..."); + console.log("Billing disabled. (Simulated)"); +}); +*/ + +functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { + // TODO(developer): As stopping billing is a destructive action + // for your project, change the following constant to false + // after you validate with a test budget. + const simulateDeactivation = true; + + let projectId = PROJECT_ID; + + if (projectId === undefined) { + try { + projectId = await gcpMetadata.project('project-id'); + } catch (error) { + console.error('project-id metadata not found:', error); + return; + } + } + + const projectName = `projects/${projectId}`; + + const eventData = Buffer.from( + cloudEvent.data['message']['data'], + 'base64' + ).toString(); + + const eventObject = JSON.parse(eventData); + + console.log( + `Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}` + ); + + if (eventObject.costAmount <= eventObject.budgetAmount) { + console.log('No action required. Current cost is within budget.'); + return; + } + + console.log(`Disabling billing for project '${projectName}'...`); + + const billingEnabled = await _isBillingEnabled(projectName); + if (billingEnabled) { + _disableBillingForProject(projectName, simulateDeactivation); + } else { + console.log('Billing is already disabled.'); + } +}); + +/** + * Determine whether billing is enabled for a project + * @param {string} projectName The name of the project to check + * @returns {boolean} Whether the project has billing enabled or not + */ +const _isBillingEnabled = async projectName => { + try { + console.log(`Getting billing info for project '${projectName}'...`); + const [res] = await billing.getProjectBillingInfo({name: projectName}); + + return res.billingEnabled; + } catch (e) { + console.log('Error getting billing info:', e); + console.log( + 'Unable to determine if billing is enabled on specified project, ' + + 'assuming billing is enabled' + ); + + return true; + } +}; + +/** + * Disable billing for a project by removing its billing account + * @param {string} projectName The name of the project to disable billing + * @param {boolean} simulateDeactivation + * If true, it won't actually disable billing. + * Useful to validate with test budgets. + * @returns {void} + */ +const _disableBillingForProject = async (projectName, simulateDeactivation) => { + if (simulateDeactivation) { + console.log('Billing disabled. (Simulated)'); + return; + } + + // Find more information about `projects/updateBillingInfo` API method here: + // https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo + try { + // To disable billing set the `billingAccountName` field to empty + const requestBody = {billingAccountName: ''}; + + const [response] = await billing.updateProjectBillingInfo({ + name: projectName, + resource: requestBody, + }); + + console.log(`Billing disabled: ${JSON.stringify(response)}`); + } catch (e) { + console.log('Failed to disable billing, check permissions.', e); + } +}; \ No newline at end of file diff --git a/functions/billing/stop_billing/test/stop-billing.test.js b/functions/billing/stop_billing/test/stop-billing.test.js new file mode 100644 index 0000000000..87598d5797 --- /dev/null +++ b/functions/billing/stop_billing/test/stop-billing.test.js @@ -0,0 +1,62 @@ +const assert = require('assert'); +const { CloudEvent } = require('cloudevents'); +const { getFunction } = require('@google-cloud/functions-framework/testing'); + +require('../stop_billing'); + +const getCloudEventBudgetAlert = () => { + const budgetData = { + "budgetDisplayName": "BUDGET_NAME", + "alertThresholdExceeded": 1.0, + "costAmount": 2.0, + "costIntervalStart": "2025-05-01T07:00:00Z", + "budgetAmount": 0.01, + "budgetAmountType": "SPECIFIED_AMOUNT", + "currencyCode": "USD" + }; + + const jsonString = JSON.stringify(budgetData); + const messageBase64 = Buffer.from(jsonString).toString('base64'); + + const encodedData = { + "message": { + "data": messageBase64 + } + }; + + return new CloudEvent({ + specversion: '1.0', + id: 'my-id', + source: '//pubsub.googleapis.com/projects/PROJECT_NAME/topics/TOPIC_NAME', + data: encodedData, + type: 'google.cloud.pubsub.topic.v1.messagePublished', + datacontenttype: 'application/json', + time: new Date().toISOString(), + }); +}; + +describe('Billing Stop Function', () => { + let consoleOutput = ''; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + beforeEach(async () => { + consoleOutput = ''; + console.log = (message) => consoleOutput += message + '\n'; + console.error = (message) => consoleOutput += "ERROR: " + message + '\n'; + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + it('should receive a budget alert and simulate stopping billing', async () => { + const StopBillingCloudEvent = getFunction("StopBillingCloudEvent"); + StopBillingCloudEvent(getCloudEventBudgetAlert()); + + assert.ok(consoleOutput.includes('Getting billing info')); + assert.ok(consoleOutput.includes('Disabling billing for project')); + assert.ok(consoleOutput.includes('Billing disabled. (Simulated)')); + }); +}); \ No newline at end of file From 6f9846674315ebfaff1699121a5aae020d5a34df Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Thu, 24 Jul 2025 12:35:26 -0600 Subject: [PATCH 05/14] fix(functions): stop-billing - clean up unused files --- functions/billing/stop_billing/.gcloudignore | 16 -- functions/billing/stop_billing/index.js | 117 -------------- functions/billing/stop_billing/package.json | 24 --- .../billing/stop_billing/stop-billing.js | 129 --------------- .../billing/stop_billing/test/index.test.js | 152 ------------------ .../stop_billing/test/periodic.test.js | 89 ---------- .../stop_billing/test/stop-billing.test.js | 62 ------- 7 files changed, 589 deletions(-) delete mode 100644 functions/billing/stop_billing/.gcloudignore delete mode 100644 functions/billing/stop_billing/index.js delete mode 100644 functions/billing/stop_billing/package.json delete mode 100644 functions/billing/stop_billing/stop-billing.js delete mode 100644 functions/billing/stop_billing/test/index.test.js delete mode 100644 functions/billing/stop_billing/test/periodic.test.js delete mode 100644 functions/billing/stop_billing/test/stop-billing.test.js diff --git a/functions/billing/stop_billing/.gcloudignore b/functions/billing/stop_billing/.gcloudignore deleted file mode 100644 index ccc4eb240e..0000000000 --- a/functions/billing/stop_billing/.gcloudignore +++ /dev/null @@ -1,16 +0,0 @@ -# This file specifies files that are *not* uploaded to Google Cloud Platform -# using gcloud. It follows the same syntax as .gitignore, with the addition of -# "#!include" directives (which insert the entries of the given .gitignore-style -# file at that point). -# -# For more information, run: -# $ gcloud topic gcloudignore -# -.gcloudignore -# If you would like to upload your .git directory, .gitignore file or files -# from your .gitignore file, remove the corresponding line -# below: -.git -.gitignore - -node_modules diff --git a/functions/billing/stop_billing/index.js b/functions/billing/stop_billing/index.js deleted file mode 100644 index 204780ff5a..0000000000 --- a/functions/billing/stop_billing/index.js +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright 2025 Google LLC -// -// 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_billing_stop] -const {CloudBillingClient} = require('@google-cloud/billing'); -const functions = require('@google-cloud/functions-framework'); -const gcpMetadata = require('gcp-metadata'); - -const billing = new CloudBillingClient(); - -functions.cloudEvent('stopBilling', async cloudEvent => { - // TODO(developer): As stopping billing is a destructive action - // for your project, change the following constant to false - // after you validate with a test budget. - const simulateDeactivation = true; - - let projectId; - - try { - projectId = await gcpMetadata.project('project-id'); - } catch (error) { - console.error('project-id metadata not found:', error); - return; - } - - const projectName = `projects/${projectId}`; - - const eventData = Buffer.from( - cloudEvent.data['message']['data'], - 'base64' - ).toString(); - - const eventObject = JSON.parse(eventData); - - console.log( - `Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}` - ); - - if (eventObject.costAmount <= eventObject.budgetAmount) { - console.log('No action required. Current cost is within budget.'); - return; - } - - console.log(`Disabling billing for project '${projectName}'...`); - - const billingEnabled = await _isBillingEnabled(projectName); - if (billingEnabled) { - _disableBillingForProject(projectName, simulateDeactivation); - } else { - console.log('Billing is already disabled.'); - } -}); - -/** - * Determine whether billing is enabled for a project - * @param {string} projectName The name of the project to check - * @returns {boolean} Whether the project has billing enabled or not - */ -const _isBillingEnabled = async projectName => { - try { - console.log(`Getting billing info for project '${projectName}'...`); - const [res] = await billing.getProjectBillingInfo({name: projectName}); - - return res.billingEnabled; - } catch (e) { - console.log('Error getting billing info:', e); - console.log( - 'Unable to determine if billing is enabled on specified project, ' + - 'assuming billing is enabled' - ); - - return true; - } -}; - -/** - * Disable billing for a project by removing its billing account - * @param {string} projectName The name of the project to disable billing - * @param {boolean} simulateDeactivation - * If true, it won't actually disable billing. - * Useful to validate with test budgets. - * @returns {void} - */ -const _disableBillingForProject = async (projectName, simulateDeactivation) => { - if (simulateDeactivation) { - console.log('Billing disabled. (Simulated)'); - return; - } - - // Find more information about `projects/updateBillingInfo` API method here: - // https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo - try { - // To disable billing set the `billingAccountName` field to empty - const requestBody = {billingAccountName: ''}; - - const [response] = await billing.updateProjectBillingInfo({ - name: projectName, - resource: requestBody, - }); - - console.log(`Billing disabled: ${JSON.stringify(response)}`); - } catch (e) { - console.log('Failed to disable billing, check permissions.', e); - } -}; -// [END functions_billing_stop] diff --git a/functions/billing/stop_billing/package.json b/functions/billing/stop_billing/package.json deleted file mode 100644 index aa6ad01ba1..0000000000 --- a/functions/billing/stop_billing/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "cloud-functions-stop-billing", - "private": "true", - "version": "0.0.1", - "description": "Disable billing with a budget notification.", - "main": "index.js", - "engines": { - "node": ">=20.0.0" - }, - "scripts": { - "test": "c8 mocha -p -j 2 test/stop-billing.test.js --timeout=5000 --exit" - }, - "author": "Emmanuel Parada ", - "license": "Apache-2.0", - "dependencies": { - "@google-cloud/billing": "^5.1.0", - "@google-cloud/functions-framework": "^4.0.0", - "cloudevents": "^10.0.0", - "gcp-metadata": "^7.0.1" - }, - "devDependencies": { - "c8": "^10.1.3" - } -} diff --git a/functions/billing/stop_billing/stop-billing.js b/functions/billing/stop_billing/stop-billing.js deleted file mode 100644 index ac63928313..0000000000 --- a/functions/billing/stop_billing/stop-billing.js +++ /dev/null @@ -1,129 +0,0 @@ -// https://github.com/GoogleCloudPlatform/functions-framework-python -// https://github.com/GoogleCloudPlatform/functions-framework-nodejs - -// Simplified representation of `stop_billing` logic -const {CloudBillingClient} = require('@google-cloud/billing'); -const functions = require('@google-cloud/functions-framework'); -const gcpMetadata = require('gcp-metadata'); - -const billing = new CloudBillingClient(); - -const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT; - -/* -functions.cloudEvent('StopBillingCloudEvent', async (cloudEvent) => { - // console.log(cloudEvent); - const eventData = Buffer.from( - cloudEvent.data['message']['data'], - 'base64' - ).toString(); - - const eventObject = JSON.parse(eventData); - - console.log( - `Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}` - ); - - console.log("Getting billing info for project..."); - console.log("Disabling billing for project..."); - console.log("Billing disabled. (Simulated)"); -}); -*/ - -functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { - // TODO(developer): As stopping billing is a destructive action - // for your project, change the following constant to false - // after you validate with a test budget. - const simulateDeactivation = true; - - let projectId = PROJECT_ID; - - if (projectId === undefined) { - try { - projectId = await gcpMetadata.project('project-id'); - } catch (error) { - console.error('project-id metadata not found:', error); - return; - } - } - - const projectName = `projects/${projectId}`; - - const eventData = Buffer.from( - cloudEvent.data['message']['data'], - 'base64' - ).toString(); - - const eventObject = JSON.parse(eventData); - - console.log( - `Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}` - ); - - if (eventObject.costAmount <= eventObject.budgetAmount) { - console.log('No action required. Current cost is within budget.'); - return; - } - - console.log(`Disabling billing for project '${projectName}'...`); - - const billingEnabled = await _isBillingEnabled(projectName); - if (billingEnabled) { - _disableBillingForProject(projectName, simulateDeactivation); - } else { - console.log('Billing is already disabled.'); - } -}); - -/** - * Determine whether billing is enabled for a project - * @param {string} projectName The name of the project to check - * @returns {boolean} Whether the project has billing enabled or not - */ -const _isBillingEnabled = async projectName => { - try { - console.log(`Getting billing info for project '${projectName}'...`); - const [res] = await billing.getProjectBillingInfo({name: projectName}); - - return res.billingEnabled; - } catch (e) { - console.log('Error getting billing info:', e); - console.log( - 'Unable to determine if billing is enabled on specified project, ' + - 'assuming billing is enabled' - ); - - return true; - } -}; - -/** - * Disable billing for a project by removing its billing account - * @param {string} projectName The name of the project to disable billing - * @param {boolean} simulateDeactivation - * If true, it won't actually disable billing. - * Useful to validate with test budgets. - * @returns {void} - */ -const _disableBillingForProject = async (projectName, simulateDeactivation) => { - if (simulateDeactivation) { - console.log('Billing disabled. (Simulated)'); - return; - } - - // Find more information about `projects/updateBillingInfo` API method here: - // https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo - try { - // To disable billing set the `billingAccountName` field to empty - const requestBody = {billingAccountName: ''}; - - const [response] = await billing.updateProjectBillingInfo({ - name: projectName, - resource: requestBody, - }); - - console.log(`Billing disabled: ${JSON.stringify(response)}`); - } catch (e) { - console.log('Failed to disable billing, check permissions.', e); - } -}; \ No newline at end of file diff --git a/functions/billing/stop_billing/test/index.test.js b/functions/billing/stop_billing/test/index.test.js deleted file mode 100644 index 47328b1d96..0000000000 --- a/functions/billing/stop_billing/test/index.test.js +++ /dev/null @@ -1,152 +0,0 @@ -// Copyright 2019 Google LLC -// -// 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. - -const {exec} = require('child_process'); -const {request} = require('gaxios'); -const assert = require('assert'); -const sinon = require('sinon'); -const waitPort = require('wait-port'); -const {InstancesClient} = require('@google-cloud/compute'); -const sample = require('../index.js'); - -const {BILLING_ACCOUNT} = process.env; - -describe('functions/billing tests', () => { - let projectId; - before(async () => { - const client = new InstancesClient(); - projectId = await client.getProjectId(); - }); - after(async () => { - // Re-enable billing using the sample file itself - // Invoking the file directly is more concise vs. re-implementing billing setup here - const jsonData = { - billingAccountName: `billingAccounts/${BILLING_ACCOUNT}`, - projectName: `projects/${projectId}`, - }; - const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( - 'base64' - ); - const pubsubMessage = {data: encodedData, attributes: {}}; - await require('../').startBilling(pubsubMessage); - }); - - describe('notifies Slack', () => { - let ffProc; - const PORT = 8080; - const BASE_URL = `http://localhost:${PORT}`; - - before(async () => { - console.log('Starting functions-framework process...'); - ffProc = exec( - `npx functions-framework --target=notifySlack --signature-type=event --port ${PORT}` - ); - await waitPort({host: 'localhost', port: PORT}); - console.log('functions-framework process started and listening!'); - }); - - after(() => { - console.log('Ending functions-framework process...'); - ffProc.kill(); - console.log('functions-framework process stopped.'); - }); - - describe('functions_billing_slack', () => { - it('should notify Slack when budget is exceeded', async () => { - const jsonData = {costAmount: 500, budgetAmount: 400}; - const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( - 'base64' - ); - const pubsubMessage = {data: encodedData, attributes: {}}; - - const response = await request({ - url: `${BASE_URL}/notifySlack`, - method: 'POST', - data: {data: pubsubMessage}, - }); - - assert.strictEqual(response.status, 200); - assert.strictEqual( - response.data, - 'Slack notification sent successfully' - ); - }); - }); - }); - - describe('disables billing', () => { - let ffProc; - const PORT = 8081; - const BASE_URL = `http://localhost:${PORT}`; - - before(async () => { - console.log('Starting functions-framework process...'); - ffProc = exec( - `npx functions-framework --target=stopBilling --signature-type=event --port ${PORT}` - ); - await waitPort({host: 'localhost', port: PORT}); - console.log('functions-framework process started and listening!'); - }); - - after(() => { - console.log('Ending functions-framework process...'); - ffProc.kill(); - console.log('functions-framework process stopped.'); - }); - - describe('functions_billing_stop', () => { - xit('should disable billing when budget is exceeded', async () => { - // Use functions framework to ensure sample follows GCF specification - // (Invoking it directly works too, but DOES NOT ensure GCF compatibility) - const jsonData = {costAmount: 500, budgetAmount: 400}; - const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( - 'base64' - ); - const pubsubMessage = {data: encodedData, attributes: {}}; - - const response = await request({ - url: `${BASE_URL}/stopBilling`, - method: 'POST', - data: {data: pubsubMessage}, - }); - - assert.strictEqual(response.status, 200); - assert.ok(response.data.includes('Billing disabled')); - }); - }); - }); - - describe('shuts down GCE instances', () => { - describe('functions_billing_limit', () => { - it('should attempt to shut down GCE instances when budget is exceeded', async () => { - const jsonData = {costAmount: 500, budgetAmount: 400}; - const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( - 'base64' - ); - const pubsubMessage = {data: encodedData, attributes: {}}; - // Mock GCE (because real GCE instances take too long to start/stop) - const instances = [{name: 'test-instance-1', status: 'RUNNING'}]; - const listStub = sinon - .stub(sample.getInstancesClient(), 'list') - .resolves([instances]); - const stopStub = sinon - .stub(sample.getInstancesClient(), 'stop') - .resolves({}); - await sample.limitUse(pubsubMessage); - assert.strictEqual(listStub.calledOnce, true); - assert.ok(stopStub.calledOnce); - }); - }); - }); -}); diff --git a/functions/billing/stop_billing/test/periodic.test.js b/functions/billing/stop_billing/test/periodic.test.js deleted file mode 100644 index 1d6496126c..0000000000 --- a/functions/billing/stop_billing/test/periodic.test.js +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2019 Google LLC -// -// 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. - -const {exec} = require('child_process'); -const {request} = require('gaxios'); -const assert = require('assert'); -const promiseRetry = require('promise-retry'); - -const BASE_URL = process.env.BASE_URL || 'http://localhost:8080'; - -describe('functions_billing_limit', () => { - let ffProc; - before(async () => { - console.log('Running periodic before hook....'); - // Re-enable compute instances using the sample file itself - const {startInstances, listRunningInstances} = require('../'); - - const emptyJson = JSON.stringify({}); - const encodedData = Buffer.from(emptyJson).toString('base64'); - const emptyMessage = {data: encodedData, attributes: {}}; - - await startInstances(emptyMessage); - - try { - await promiseRetry( - async (retry, n) => { - const result = await listRunningInstances(emptyMessage); - - console.log(`${n}: ${result}`); - if (result.length > 0) { - return Promise.resolve(); - } else { - return retry(); - } - }, - {retries: 8} - ); - } catch (err) { - console.error('Failed to restart GCE instances:', err); - } - console.log('Periodic before hook complete.'); - }); - - after(() => { - console.log('Ending functions-framework process...'); - ffProc.kill(); - console.log('functions-framework process stopped.'); - }); - - it('should shut down GCE instances when budget is exceeded', async () => { - console.log('Starting functions-framework process...'); - ffProc = exec( - 'npx functions-framework --target=limitUse --signature-type=event' - ); - console.log('functions-framework process started and listening!'); - - const jsonData = {costAmount: 500, budgetAmount: 400}; - const encodedData = Buffer.from(JSON.stringify(jsonData)).toString( - 'base64' - ); - const pubsubMessage = {data: encodedData, attributes: {}}; - - const response = await request({ - url: `${BASE_URL}/`, - method: 'POST', - data: {data: pubsubMessage}, - retryConfig: { - retries: 3, - retryDelay: 200, - }, - }); - - console.log(response.data); - - assert.strictEqual(response.status, 200); - assert.ok(response.data.includes('instance(s) stopped successfully')); - }); -}); diff --git a/functions/billing/stop_billing/test/stop-billing.test.js b/functions/billing/stop_billing/test/stop-billing.test.js deleted file mode 100644 index 87598d5797..0000000000 --- a/functions/billing/stop_billing/test/stop-billing.test.js +++ /dev/null @@ -1,62 +0,0 @@ -const assert = require('assert'); -const { CloudEvent } = require('cloudevents'); -const { getFunction } = require('@google-cloud/functions-framework/testing'); - -require('../stop_billing'); - -const getCloudEventBudgetAlert = () => { - const budgetData = { - "budgetDisplayName": "BUDGET_NAME", - "alertThresholdExceeded": 1.0, - "costAmount": 2.0, - "costIntervalStart": "2025-05-01T07:00:00Z", - "budgetAmount": 0.01, - "budgetAmountType": "SPECIFIED_AMOUNT", - "currencyCode": "USD" - }; - - const jsonString = JSON.stringify(budgetData); - const messageBase64 = Buffer.from(jsonString).toString('base64'); - - const encodedData = { - "message": { - "data": messageBase64 - } - }; - - return new CloudEvent({ - specversion: '1.0', - id: 'my-id', - source: '//pubsub.googleapis.com/projects/PROJECT_NAME/topics/TOPIC_NAME', - data: encodedData, - type: 'google.cloud.pubsub.topic.v1.messagePublished', - datacontenttype: 'application/json', - time: new Date().toISOString(), - }); -}; - -describe('Billing Stop Function', () => { - let consoleOutput = ''; - const originalConsoleLog = console.log; - const originalConsoleError = console.error; - - beforeEach(async () => { - consoleOutput = ''; - console.log = (message) => consoleOutput += message + '\n'; - console.error = (message) => consoleOutput += "ERROR: " + message + '\n'; - }); - - afterEach(() => { - console.log = originalConsoleLog; - console.error = originalConsoleError; - }); - - it('should receive a budget alert and simulate stopping billing', async () => { - const StopBillingCloudEvent = getFunction("StopBillingCloudEvent"); - StopBillingCloudEvent(getCloudEventBudgetAlert()); - - assert.ok(consoleOutput.includes('Getting billing info')); - assert.ok(consoleOutput.includes('Disabling billing for project')); - assert.ok(consoleOutput.includes('Billing disabled. (Simulated)')); - }); -}); \ No newline at end of file From c1a36d5dfec9f8c86b47c7e9a4d146c574d52160 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Thu, 24 Jul 2025 12:39:43 -0600 Subject: [PATCH 06/14] fix(functions): fix stop-billing sample - Add test for notification within budget - Validate unit tests - Apply linting --- functions/billing/stop-billing/.gcloudignore | 16 +++ functions/billing/stop-billing/index.js | 126 ++++++++++++++++++ functions/billing/stop-billing/package.json | 24 ++++ .../billing/stop-billing/test/index.test.js | 117 ++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 functions/billing/stop-billing/.gcloudignore create mode 100644 functions/billing/stop-billing/index.js create mode 100644 functions/billing/stop-billing/package.json create mode 100644 functions/billing/stop-billing/test/index.test.js diff --git a/functions/billing/stop-billing/.gcloudignore b/functions/billing/stop-billing/.gcloudignore new file mode 100644 index 0000000000..ccc4eb240e --- /dev/null +++ b/functions/billing/stop-billing/.gcloudignore @@ -0,0 +1,16 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js new file mode 100644 index 0000000000..bc6f573e2b --- /dev/null +++ b/functions/billing/stop-billing/index.js @@ -0,0 +1,126 @@ +// Copyright 2025 Google LLC +// +// 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_billing_stop] +const {CloudBillingClient} = require('@google-cloud/billing'); +const functions = require('@google-cloud/functions-framework'); +const gcpMetadata = require('gcp-metadata'); + +const billing = new CloudBillingClient(); + +const projectIdEnv = process.env.GOOGLE_CLOUD_PROJECT; + +functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { + // TODO(developer): As stopping billing is a destructive action + // for your project, change the following constant to `false` + // after you validate with a test budget. + const simulateDeactivation = true; + + let projectId = projectIdEnv; + + if (projectId === undefined) { + console.log('Project ID not found in Env variables. Reading metadata...'); + try { + projectId = await gcpMetadata.project('project-id'); + } catch (error) { + console.error('project-id metadata not found:', error); + return; + } + } + + const projectName = `projects/${projectId}`; + + // Find more information about the notification format here: + // https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications#notification-format + const eventData = Buffer.from( + cloudEvent.data['message']['data'], + 'base64' + ).toString(); + + const eventObject = JSON.parse(eventData); + + console.log( + `Project ID: ${projectId} ` + + `Current cost: ${eventObject.costAmount} ` + + `Budget: ${eventObject.budgetAmount}` + ); + + if (eventObject.costAmount <= eventObject.budgetAmount) { + console.log('No action required. Current cost is within budget.'); + return; + } + + console.log(`Disabling billing for project '${projectName}'...`); + + const billingEnabled = await _isBillingEnabled(projectName); + if (billingEnabled) { + _disableBillingForProject(projectName, simulateDeactivation); + } else { + console.log('Billing is already disabled.'); + } +}); + +/** + * Determine whether billing is enabled for a project + * @param {string} projectName The name of the project to check + * @returns {boolean} Whether the project has billing enabled or not + */ +const _isBillingEnabled = async projectName => { + try { + console.log(`Getting billing info for project '${projectName}'...`); + const [res] = await billing.getProjectBillingInfo({name: projectName}); + + return res.billingEnabled; + } catch (e) { + console.log('Error getting billing info:', e); + console.log( + 'Unable to determine if billing is enabled on specified project, ' + + 'assuming billing is enabled' + ); + + return true; + } +}; + +/** + * Disable billing for a project by removing its billing account + * @param {string} projectName The name of the project to disable billing + * @param {boolean} simulateDeactivation + * If true, it won't actually disable billing. + * Useful to validate with test budgets. + * @returns {void} + */ +const _disableBillingForProject = async (projectName, simulateDeactivation) => { + if (simulateDeactivation) { + console.log('Billing disabled. (Simulated)'); + return; + } + + // Find more information about `projects/updateBillingInfo` API method here: + // https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo + try { + // To disable billing set the `billingAccountName` field to empty + const requestBody = {billingAccountName: ''}; + + const [response] = await billing.updateProjectBillingInfo({ + name: projectName, + resource: requestBody, + }); + + console.log(`Billing disabled: ${JSON.stringify(response)}`); + } catch (e) { + console.log('Failed to disable billing, check permissions.', e); + } +}; +// [END functions_billing_stop] diff --git a/functions/billing/stop-billing/package.json b/functions/billing/stop-billing/package.json new file mode 100644 index 0000000000..c16a2144b3 --- /dev/null +++ b/functions/billing/stop-billing/package.json @@ -0,0 +1,24 @@ +{ + "name": "cloud-functions-stop-billing", + "private": "true", + "version": "0.0.1", + "description": "Disable billing with a budget notification.", + "main": "index.js", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit" + }, + "author": "Emmanuel Parada ", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/billing": "^5.1.0", + "@google-cloud/functions-framework": "^4.0.0", + "cloudevents": "^10.0.0", + "gcp-metadata": "^7.0.1" + }, + "devDependencies": { + "c8": "^10.1.3" + } +} diff --git a/functions/billing/stop-billing/test/index.test.js b/functions/billing/stop-billing/test/index.test.js new file mode 100644 index 0000000000..119f0a62f8 --- /dev/null +++ b/functions/billing/stop-billing/test/index.test.js @@ -0,0 +1,117 @@ +// Copyright 2025 Google LLC +// +// 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. + +const assert = require('assert'); +const {CloudEvent} = require('cloudevents'); +const {getFunction} = require('@google-cloud/functions-framework/testing'); + +require('../index'); + +const getDataWithinBudget = () => { + return { + budgetDisplayName: 'BUDGET_NAME', + costAmount: 5.0, + costIntervalStart: new Date().toISOString(), + budgetAmount: 10.0, + budgetAmountType: 'SPECIFIED_AMOUNT', + currencyCode: 'USD', + }; +}; + +const getDataOverBudget = () => { + return { + budgetDisplayName: 'BUDGET_NAME', + alertThresholdExceeded: 0.9, + costAmount: 20.0, + costIntervalStart: new Date().toISOString(), + budgetAmount: 10.0, + budgetAmountType: 'SPECIFIED_AMOUNT', + currencyCode: 'USD', + }; +}; + +/** + * Get a simulated CloudEvent for a Budget notification. + * Find more examples here: + * https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications + * @param {boolean} isOverBudget - Whether or not the budget has been exceeded. + * @returns {CloudEvent} The simulated CloudEvent. + */ +const getCloudEventOverBudgetAlert = isOverBudget => { + let budgetData; + + if (isOverBudget) { + budgetData = getDataOverBudget(); + } else { + budgetData = getDataWithinBudget(); + } + + const jsonString = JSON.stringify(budgetData); + const messageBase64 = Buffer.from(jsonString).toString('base64'); + + const encodedData = { + message: { + data: messageBase64, + }, + }; + + return new CloudEvent({ + specversion: '1.0', + id: 'my-id', + source: '//pubsub.googleapis.com/projects/PROJECT_NAME/topics/TOPIC_NAME', + data: encodedData, + type: 'google.cloud.pubsub.topic.v1.messagePublished', + datacontenttype: 'application/json', + time: new Date().toISOString(), + }); +}; + +describe('Billing Stop Function', () => { + let consoleOutput = ''; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + beforeEach(async () => { + consoleOutput = ''; + console.log = message => (consoleOutput += message + '\n'); + console.error = message => (consoleOutput += 'ERROR: ' + message + '\n'); + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + it('should receive a notification within budget', async () => { + const StopBillingCloudEvent = getFunction('StopBillingCloudEvent'); + const isOverBudget = false; + await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget)); + + assert.ok( + consoleOutput.includes( + 'No action required. Current cost is within budget.' + ) + ); + }); + + it('should receive a notification exceeding the budget and simulate stopping billing', async () => { + const StopBillingCloudEvent = getFunction('StopBillingCloudEvent'); + const isOverBudget = true; + await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget)); + + assert.ok(consoleOutput.includes('Getting billing info')); + assert.ok(consoleOutput.includes('Disabling billing for project')); + assert.ok(consoleOutput.includes('Billing disabled. (Simulated)')); + }); +}); From bb58b19d2d8b7310575d789cbefe78056695b7da Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Thu, 24 Jul 2025 14:05:21 -0600 Subject: [PATCH 07/14] fix(functions): stop-billing - Apply feedback from gemini-code-assist review https://github.com/GoogleCloudPlatform/nodejs-docs-samples/pull/4085#pullrequestreview-3052815560 --- functions/billing/stop-billing/index.js | 22 ++++++++++++++------- functions/billing/stop-billing/package.json | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js index bc6f573e2b..ae4e0fb299 100644 --- a/functions/billing/stop-billing/index.js +++ b/functions/billing/stop-billing/index.js @@ -30,7 +30,7 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { let projectId = projectIdEnv; if (projectId === undefined) { - console.log('Project ID not found in Env variables. Reading metadata...'); + console.log('Project ID not found in env variables. Getting GCP metadata...'); try { projectId = await gcpMetadata.project('project-id'); } catch (error) { @@ -43,12 +43,20 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { // Find more information about the notification format here: // https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications#notification-format - const eventData = Buffer.from( - cloudEvent.data['message']['data'], - 'base64' - ).toString(); + const messageData = cloudEvent.data?.message?.data; + if (!messageData) { + console.error('Invalid CloudEvent: missing data.message.data'); + return; + } + const eventData = Buffer.from(messageData, 'base64').toString(); - const eventObject = JSON.parse(eventData); + let eventObject; + try { + eventObject = JSON.parse(eventData); + } catch (e) { + console.error('Error parsing event data:', e); + return; + } console.log( `Project ID: ${projectId} ` + @@ -63,7 +71,7 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { console.log(`Disabling billing for project '${projectName}'...`); - const billingEnabled = await _isBillingEnabled(projectName); + const billingEnabled = _isBillingEnabled(projectName); if (billingEnabled) { _disableBillingForProject(projectName, simulateDeactivation); } else { diff --git a/functions/billing/stop-billing/package.json b/functions/billing/stop-billing/package.json index c16a2144b3..9e63cbf8e0 100644 --- a/functions/billing/stop-billing/package.json +++ b/functions/billing/stop-billing/package.json @@ -1,6 +1,6 @@ { "name": "cloud-functions-stop-billing", - "private": "true", + "private": true, "version": "0.0.1", "description": "Disable billing with a budget notification.", "main": "index.js", From 82d6db3d63babe311d377dda03c4195c2f0fe55e Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Thu, 24 Jul 2025 14:21:39 -0600 Subject: [PATCH 08/14] fix(functions): stop-billing - Add support for ECMAScript 2020 to the linter config file --- functions/billing/stop-billing/.eslintrc.json | 14 ++++++++++++++ functions/billing/stop-billing/index.js | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 functions/billing/stop-billing/.eslintrc.json diff --git a/functions/billing/stop-billing/.eslintrc.json b/functions/billing/stop-billing/.eslintrc.json new file mode 100644 index 0000000000..edeece0203 --- /dev/null +++ b/functions/billing/stop-billing/.eslintrc.json @@ -0,0 +1,14 @@ +{ + "rules": { + "node/no-missing-import": ["off"], + "node/no-missing-require": ["off"], + "node/no-unpublished-import": ["off"], + "node/no-unpublished-require": ["off"], + "node/no-unsupported-features/es-syntax": ["off"] + }, + "parserOptions": { + // This sample is designed for Node.js 20+ which supports ECMAScript 2020 + "ecmaVersion": 2020, + "sourceType": "module" + } +} diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js index ae4e0fb299..47335d0332 100644 --- a/functions/billing/stop-billing/index.js +++ b/functions/billing/stop-billing/index.js @@ -30,7 +30,9 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { let projectId = projectIdEnv; if (projectId === undefined) { - console.log('Project ID not found in env variables. Getting GCP metadata...'); + console.log( + 'Project ID not found in env variables. Getting GCP metadata...' + ); try { projectId = await gcpMetadata.project('project-id'); } catch (error) { From e6ab70455347606f3f4c8ba26c724feea964775d Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Thu, 24 Jul 2025 17:11:13 -0600 Subject: [PATCH 09/14] fix(functions): stop-billing - Apply feedback from gemini-code-assist https://github.com/GoogleCloudPlatform/nodejs-docs-samples/pull/4085#pullrequestreview-3053167899 --- functions/billing/stop-billing/.eslintrc.json | 4 +- functions/billing/stop-billing/index.js | 4 +- .../billing/stop-billing/test/index.test.js | 74 ++++++++++--------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/functions/billing/stop-billing/.eslintrc.json b/functions/billing/stop-billing/.eslintrc.json index edeece0203..ca2592e726 100644 --- a/functions/billing/stop-billing/.eslintrc.json +++ b/functions/billing/stop-billing/.eslintrc.json @@ -7,8 +7,6 @@ "node/no-unsupported-features/es-syntax": ["off"] }, "parserOptions": { - // This sample is designed for Node.js 20+ which supports ECMAScript 2020 - "ecmaVersion": 2020, - "sourceType": "module" + "ecmaVersion": 2020 } } diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js index 47335d0332..41fc68e86f 100644 --- a/functions/billing/stop-billing/index.js +++ b/functions/billing/stop-billing/index.js @@ -73,9 +73,9 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { console.log(`Disabling billing for project '${projectName}'...`); - const billingEnabled = _isBillingEnabled(projectName); + const billingEnabled = await _isBillingEnabled(projectName); if (billingEnabled) { - _disableBillingForProject(projectName, simulateDeactivation); + await _disableBillingForProject(projectName, simulateDeactivation); } else { console.log('Billing is already disabled.'); } diff --git a/functions/billing/stop-billing/test/index.test.js b/functions/billing/stop-billing/test/index.test.js index 119f0a62f8..34d5161fe2 100644 --- a/functions/billing/stop-billing/test/index.test.js +++ b/functions/billing/stop-billing/test/index.test.js @@ -77,41 +77,43 @@ const getCloudEventOverBudgetAlert = isOverBudget => { }); }; -describe('Billing Stop Function', () => { - let consoleOutput = ''; - const originalConsoleLog = console.log; - const originalConsoleError = console.error; - - beforeEach(async () => { - consoleOutput = ''; - console.log = message => (consoleOutput += message + '\n'); - console.error = message => (consoleOutput += 'ERROR: ' + message + '\n'); - }); - - afterEach(() => { - console.log = originalConsoleLog; - console.error = originalConsoleError; - }); - - it('should receive a notification within budget', async () => { - const StopBillingCloudEvent = getFunction('StopBillingCloudEvent'); - const isOverBudget = false; - await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget)); - - assert.ok( - consoleOutput.includes( - 'No action required. Current cost is within budget.' - ) - ); - }); - - it('should receive a notification exceeding the budget and simulate stopping billing', async () => { - const StopBillingCloudEvent = getFunction('StopBillingCloudEvent'); - const isOverBudget = true; - await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget)); - - assert.ok(consoleOutput.includes('Getting billing info')); - assert.ok(consoleOutput.includes('Disabling billing for project')); - assert.ok(consoleOutput.includes('Billing disabled. (Simulated)')); +describe('index.test.js', () => { + describe('functions_billing_stop StopBillingCloudEvent', () => { + let consoleOutput = ''; + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + + beforeEach(async () => { + consoleOutput = ''; + console.log = message => (consoleOutput += message + '\n'); + console.error = message => (consoleOutput += 'ERROR: ' + message + '\n'); + }); + + afterEach(() => { + console.log = originalConsoleLog; + console.error = originalConsoleError; + }); + + it('should receive a notification within budget', async () => { + const StopBillingCloudEvent = getFunction('StopBillingCloudEvent'); + const isOverBudget = false; + await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget)); + + assert.ok( + consoleOutput.includes( + 'No action required. Current cost is within budget.' + ) + ); + }); + + it('should receive a notification exceeding the budget and simulate stopping billing', async () => { + const StopBillingCloudEvent = getFunction('StopBillingCloudEvent'); + const isOverBudget = true; + await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget)); + + assert.ok(consoleOutput.includes('Getting billing info')); + assert.ok(consoleOutput.includes('Disabling billing for project')); + assert.ok(consoleOutput.includes('Billing disabled. (Simulated)')); + }); }); }); From b279adf494ac25907d7a98616d6b4384bab51ece Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Fri, 25 Jul 2025 08:45:36 -0600 Subject: [PATCH 10/14] fix(functions): stop-billing - Apply freedback from gemini-code-assist https://github.com/GoogleCloudPlatform/nodejs-docs-samples/pull/4085#pullrequestreview-3053619354 --- functions/billing/stop-billing/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js index 41fc68e86f..fa130d4877 100644 --- a/functions/billing/stop-billing/index.js +++ b/functions/billing/stop-billing/index.js @@ -93,8 +93,8 @@ const _isBillingEnabled = async projectName => { return res.billingEnabled; } catch (e) { - console.log('Error getting billing info:', e); - console.log( + console.error('Error getting billing info:', e); + console.error( 'Unable to determine if billing is enabled on specified project, ' + 'assuming billing is enabled' ); @@ -130,7 +130,7 @@ const _disableBillingForProject = async (projectName, simulateDeactivation) => { console.log(`Billing disabled: ${JSON.stringify(response)}`); } catch (e) { - console.log('Failed to disable billing, check permissions.', e); + console.error('Failed to disable billing, check permissions.', e); } }; // [END functions_billing_stop] From 3c6c4739291638b6dcb2e6541dac77c18b49a85d Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Mon, 28 Jul 2025 13:43:25 -0600 Subject: [PATCH 11/14] fix(functions): stop-billing - Apply feedback from glasnt https://github.com/GoogleCloudPlatform/nodejs-docs-samples/pull/4085#pullrequestreview-3059692035 --- functions/billing/stop-billing/.eslintrc.json | 9 +---- functions/billing/stop-billing/.gcloudignore | 16 --------- functions/billing/stop-billing/index.js | 36 +++++++++---------- functions/billing/stop-billing/package.json | 4 +-- .../billing/stop-billing/test/index.test.js | 2 +- 5 files changed, 21 insertions(+), 46 deletions(-) delete mode 100644 functions/billing/stop-billing/.gcloudignore diff --git a/functions/billing/stop-billing/.eslintrc.json b/functions/billing/stop-billing/.eslintrc.json index ca2592e726..6d723b9e5a 100644 --- a/functions/billing/stop-billing/.eslintrc.json +++ b/functions/billing/stop-billing/.eslintrc.json @@ -1,12 +1,5 @@ { - "rules": { - "node/no-missing-import": ["off"], - "node/no-missing-require": ["off"], - "node/no-unpublished-import": ["off"], - "node/no-unpublished-require": ["off"], - "node/no-unsupported-features/es-syntax": ["off"] - }, "parserOptions": { "ecmaVersion": 2020 } -} +} \ No newline at end of file diff --git a/functions/billing/stop-billing/.gcloudignore b/functions/billing/stop-billing/.gcloudignore deleted file mode 100644 index ccc4eb240e..0000000000 --- a/functions/billing/stop-billing/.gcloudignore +++ /dev/null @@ -1,16 +0,0 @@ -# This file specifies files that are *not* uploaded to Google Cloud Platform -# using gcloud. It follows the same syntax as .gitignore, with the addition of -# "#!include" directives (which insert the entries of the given .gitignore-style -# file at that point). -# -# For more information, run: -# $ gcloud topic gcloudignore -# -.gcloudignore -# If you would like to upload your .git directory, .gitignore file or files -# from your .gitignore file, remove the corresponding line -# below: -.git -.gitignore - -node_modules diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js index fa130d4877..ea970e188e 100644 --- a/functions/billing/stop-billing/index.js +++ b/functions/billing/stop-billing/index.js @@ -19,24 +19,23 @@ const gcpMetadata = require('gcp-metadata'); const billing = new CloudBillingClient(); -const projectIdEnv = process.env.GOOGLE_CLOUD_PROJECT; +let projectId = process.env.GOOGLE_CLOUD_PROJECT; -functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { - // TODO(developer): As stopping billing is a destructive action - // for your project, change the following constant to `false` - // after you validate with a test budget. - const simulateDeactivation = true; - - let projectId = projectIdEnv; +// TODO(developer): Since stopping billing is a destructive action +// for your project, first validate a test budget with a dry run enabled. +const dryRun = true; +functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { if (projectId === undefined) { - console.log( - 'Project ID not found in env variables. Getting GCP metadata...' - ); try { projectId = await gcpMetadata.project('project-id'); } catch (error) { console.error('project-id metadata not found:', error); + + console.error( + 'Project ID could not be found in environment variables ' + + 'or Cloud Run metadata server. Stopping execution.' + ); return; } } @@ -50,6 +49,7 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { console.error('Invalid CloudEvent: missing data.message.data'); return; } + const eventData = Buffer.from(messageData, 'base64').toString(); let eventObject; @@ -75,7 +75,7 @@ functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => { const billingEnabled = await _isBillingEnabled(projectName); if (billingEnabled) { - await _disableBillingForProject(projectName, simulateDeactivation); + await _disableBillingForProject(projectName); } else { console.log('Billing is already disabled.'); } @@ -106,14 +106,12 @@ const _isBillingEnabled = async projectName => { /** * Disable billing for a project by removing its billing account * @param {string} projectName The name of the project to disable billing - * @param {boolean} simulateDeactivation - * If true, it won't actually disable billing. - * Useful to validate with test budgets. * @returns {void} */ -const _disableBillingForProject = async (projectName, simulateDeactivation) => { - if (simulateDeactivation) { - console.log('Billing disabled. (Simulated)'); +const _disableBillingForProject = async projectName => { + if (dryRun) { + console.log('** DRY RUN: simulating billing deactivation'); + console.log('Billing disabled.'); return; } @@ -128,7 +126,7 @@ const _disableBillingForProject = async (projectName, simulateDeactivation) => { resource: requestBody, }); - console.log(`Billing disabled: ${JSON.stringify(response)}`); + console.log(`Billing disabled. Response: ${JSON.stringify(response)}`); } catch (e) { console.error('Failed to disable billing, check permissions.', e); } diff --git a/functions/billing/stop-billing/package.json b/functions/billing/stop-billing/package.json index 9e63cbf8e0..1f0a4b9086 100644 --- a/functions/billing/stop-billing/package.json +++ b/functions/billing/stop-billing/package.json @@ -10,7 +10,7 @@ "scripts": { "test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit" }, - "author": "Emmanuel Parada ", + "author": "Google Inc.", "license": "Apache-2.0", "dependencies": { "@google-cloud/billing": "^5.1.0", @@ -21,4 +21,4 @@ "devDependencies": { "c8": "^10.1.3" } -} +} \ No newline at end of file diff --git a/functions/billing/stop-billing/test/index.test.js b/functions/billing/stop-billing/test/index.test.js index 34d5161fe2..ec8f10f306 100644 --- a/functions/billing/stop-billing/test/index.test.js +++ b/functions/billing/stop-billing/test/index.test.js @@ -113,7 +113,7 @@ describe('index.test.js', () => { assert.ok(consoleOutput.includes('Getting billing info')); assert.ok(consoleOutput.includes('Disabling billing for project')); - assert.ok(consoleOutput.includes('Billing disabled. (Simulated)')); + assert.ok(consoleOutput.includes('Billing disabled.')); }); }); }); From 5f7f27dd83eef7e9d10abd8970df24c829968ddc Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Wed, 30 Jul 2025 16:18:12 -0600 Subject: [PATCH 12/14] fix(functions): stop-billing - Apply feedback from glasnt https://github.com/GoogleCloudPlatform/nodejs-docs-samples/pull/4085#pullrequestreview-3059692035 --- functions/billing/stop-billing/index.js | 6 ++++-- functions/billing/stop-billing/test/index.test.js | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/functions/billing/stop-billing/index.js b/functions/billing/stop-billing/index.js index ea970e188e..e2c4dcd292 100644 --- a/functions/billing/stop-billing/index.js +++ b/functions/billing/stop-billing/index.js @@ -110,8 +110,10 @@ const _isBillingEnabled = async projectName => { */ const _disableBillingForProject = async projectName => { if (dryRun) { - console.log('** DRY RUN: simulating billing deactivation'); - console.log('Billing disabled.'); + console.log( + '** This script would disable billing here, but "dryRun" has been set to true.' + + 'Change "dryRun" to alter this behaviour.' + ); return; } diff --git a/functions/billing/stop-billing/test/index.test.js b/functions/billing/stop-billing/test/index.test.js index ec8f10f306..c4b4203fed 100644 --- a/functions/billing/stop-billing/test/index.test.js +++ b/functions/billing/stop-billing/test/index.test.js @@ -113,7 +113,9 @@ describe('index.test.js', () => { assert.ok(consoleOutput.includes('Getting billing info')); assert.ok(consoleOutput.includes('Disabling billing for project')); - assert.ok(consoleOutput.includes('Billing disabled.')); + assert.ok( + consoleOutput.includes('This script would disable billing here') + ); }); }); }); From 99920e227c4e664dc6a7f0bfcb8e7cba7fe863b6 Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Fri, 1 Aug 2025 10:55:20 -0600 Subject: [PATCH 13/14] fix(functions): stopBilling - Move sample from root folder to v2, since it's an update to Node 20 (Functions v2) --- functions/{billing/stop-billing => v2/stopBilling}/.eslintrc.json | 0 functions/{billing/stop-billing => v2/stopBilling}/index.js | 0 functions/{billing/stop-billing => v2/stopBilling}/package.json | 0 .../{billing/stop-billing => v2/stopBilling}/test/index.test.js | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename functions/{billing/stop-billing => v2/stopBilling}/.eslintrc.json (100%) rename functions/{billing/stop-billing => v2/stopBilling}/index.js (100%) rename functions/{billing/stop-billing => v2/stopBilling}/package.json (100%) rename functions/{billing/stop-billing => v2/stopBilling}/test/index.test.js (100%) diff --git a/functions/billing/stop-billing/.eslintrc.json b/functions/v2/stopBilling/.eslintrc.json similarity index 100% rename from functions/billing/stop-billing/.eslintrc.json rename to functions/v2/stopBilling/.eslintrc.json diff --git a/functions/billing/stop-billing/index.js b/functions/v2/stopBilling/index.js similarity index 100% rename from functions/billing/stop-billing/index.js rename to functions/v2/stopBilling/index.js diff --git a/functions/billing/stop-billing/package.json b/functions/v2/stopBilling/package.json similarity index 100% rename from functions/billing/stop-billing/package.json rename to functions/v2/stopBilling/package.json diff --git a/functions/billing/stop-billing/test/index.test.js b/functions/v2/stopBilling/test/index.test.js similarity index 100% rename from functions/billing/stop-billing/test/index.test.js rename to functions/v2/stopBilling/test/index.test.js From ebefd6e32dad706ac1811ab4aa0fd5af419967bb Mon Sep 17 00:00:00 2001 From: Emmanuel Alejandro Parada Licea Date: Fri, 1 Aug 2025 11:01:28 -0600 Subject: [PATCH 14/14] fix(functions): stopBilling - Update info message on dryRun mode. --- functions/v2/stopBilling/index.js | 4 ++-- functions/v2/stopBilling/test/index.test.js | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/functions/v2/stopBilling/index.js b/functions/v2/stopBilling/index.js index e2c4dcd292..8d6dabb1d7 100644 --- a/functions/v2/stopBilling/index.js +++ b/functions/v2/stopBilling/index.js @@ -111,8 +111,8 @@ const _isBillingEnabled = async projectName => { const _disableBillingForProject = async projectName => { if (dryRun) { console.log( - '** This script would disable billing here, but "dryRun" has been set to true.' + - 'Change "dryRun" to alter this behaviour.' + '** INFO: Disabling running in info-only mode because "dryRun" is true. ' + + 'To disable billing, set "dryRun" to false.' ); return; } diff --git a/functions/v2/stopBilling/test/index.test.js b/functions/v2/stopBilling/test/index.test.js index c4b4203fed..b4edb7a967 100644 --- a/functions/v2/stopBilling/test/index.test.js +++ b/functions/v2/stopBilling/test/index.test.js @@ -113,9 +113,7 @@ describe('index.test.js', () => { assert.ok(consoleOutput.includes('Getting billing info')); assert.ok(consoleOutput.includes('Disabling billing for project')); - assert.ok( - consoleOutput.includes('This script would disable billing here') - ); + assert.ok(consoleOutput.includes('Disabling running in info-only mode')); }); }); });