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..204780ff5a --- /dev/null +++ b/functions/billing/stop_billing/index.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. + +// [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 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')); + }); +});