diff --git a/functions/v2/stopBilling/.eslintrc.json b/functions/v2/stopBilling/.eslintrc.json new file mode 100644 index 0000000000..6d723b9e5a --- /dev/null +++ b/functions/v2/stopBilling/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "parserOptions": { + "ecmaVersion": 2020 + } +} \ No newline at end of file diff --git a/functions/v2/stopBilling/index.js b/functions/v2/stopBilling/index.js new file mode 100644 index 0000000000..8d6dabb1d7 --- /dev/null +++ b/functions/v2/stopBilling/index.js @@ -0,0 +1,136 @@ +// 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(); + +let projectId = process.env.GOOGLE_CLOUD_PROJECT; + +// 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) { + 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; + } + } + + 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 messageData = cloudEvent.data?.message?.data; + if (!messageData) { + console.error('Invalid CloudEvent: missing data.message.data'); + return; + } + + const eventData = Buffer.from(messageData, 'base64').toString(); + + let eventObject; + try { + eventObject = JSON.parse(eventData); + } catch (e) { + console.error('Error parsing event data:', e); + return; + } + + 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) { + await _disableBillingForProject(projectName); + } 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.error('Error getting billing info:', e); + console.error( + '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 + * @returns {void} + */ +const _disableBillingForProject = async projectName => { + if (dryRun) { + console.log( + '** INFO: Disabling running in info-only mode because "dryRun" is true. ' + + 'To disable billing, set "dryRun" to false.' + ); + 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. Response: ${JSON.stringify(response)}`); + } catch (e) { + console.error('Failed to disable billing, check permissions.', e); + } +}; +// [END functions_billing_stop] diff --git a/functions/v2/stopBilling/package.json b/functions/v2/stopBilling/package.json new file mode 100644 index 0000000000..1f0a4b9086 --- /dev/null +++ b/functions/v2/stopBilling/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": "Google Inc.", + "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" + } +} \ No newline at end of file diff --git a/functions/v2/stopBilling/test/index.test.js b/functions/v2/stopBilling/test/index.test.js new file mode 100644 index 0000000000..b4edb7a967 --- /dev/null +++ b/functions/v2/stopBilling/test/index.test.js @@ -0,0 +1,119 @@ +// 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('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('Disabling running in info-only mode')); + }); + }); +});