Skip to content

Commit d198266

Browse files
fix(functions): update and fix functions_billing_stop sample (#4085)
* fix(functions): update and fix `functions_billing_stop` sample * fix(functions): add a variable for the developer to simulate disabling billing - Style fixes - Fix comments * fix(functions): stop-billing - update dependencies to latest versions * 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 * fix(functions): stop-billing - clean up unused files * fix(functions): fix stop-billing sample - Add test for notification within budget - Validate unit tests - Apply linting * fix(functions): stop-billing - Apply feedback from gemini-code-assist review #4085 (review) * fix(functions): stop-billing - Add support for ECMAScript 2020 to the linter config file * fix(functions): stop-billing - Apply feedback from gemini-code-assist #4085 (review) * fix(functions): stop-billing - Apply freedback from gemini-code-assist #4085 (review) * fix(functions): stop-billing - Apply feedback from glasnt #4085 (review) * fix(functions): stop-billing - Apply feedback from glasnt #4085 (review) * fix(functions): stopBilling - Move sample from root folder to v2, since it's an update to Node 20 (Functions v2) * fix(functions): stopBilling - Update info message on dryRun mode. --------- Co-authored-by: Jennifer Davis <[email protected]>
1 parent e09c927 commit d198266

File tree

4 files changed

+284
-0
lines changed

4 files changed

+284
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"parserOptions": {
3+
"ecmaVersion": 2020
4+
}
5+
}

functions/v2/stopBilling/index.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// [START functions_billing_stop]
16+
const {CloudBillingClient} = require('@google-cloud/billing');
17+
const functions = require('@google-cloud/functions-framework');
18+
const gcpMetadata = require('gcp-metadata');
19+
20+
const billing = new CloudBillingClient();
21+
22+
let projectId = process.env.GOOGLE_CLOUD_PROJECT;
23+
24+
// TODO(developer): Since stopping billing is a destructive action
25+
// for your project, first validate a test budget with a dry run enabled.
26+
const dryRun = true;
27+
28+
functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => {
29+
if (projectId === undefined) {
30+
try {
31+
projectId = await gcpMetadata.project('project-id');
32+
} catch (error) {
33+
console.error('project-id metadata not found:', error);
34+
35+
console.error(
36+
'Project ID could not be found in environment variables ' +
37+
'or Cloud Run metadata server. Stopping execution.'
38+
);
39+
return;
40+
}
41+
}
42+
43+
const projectName = `projects/${projectId}`;
44+
45+
// Find more information about the notification format here:
46+
// https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications#notification-format
47+
const messageData = cloudEvent.data?.message?.data;
48+
if (!messageData) {
49+
console.error('Invalid CloudEvent: missing data.message.data');
50+
return;
51+
}
52+
53+
const eventData = Buffer.from(messageData, 'base64').toString();
54+
55+
let eventObject;
56+
try {
57+
eventObject = JSON.parse(eventData);
58+
} catch (e) {
59+
console.error('Error parsing event data:', e);
60+
return;
61+
}
62+
63+
console.log(
64+
`Project ID: ${projectId} ` +
65+
`Current cost: ${eventObject.costAmount} ` +
66+
`Budget: ${eventObject.budgetAmount}`
67+
);
68+
69+
if (eventObject.costAmount <= eventObject.budgetAmount) {
70+
console.log('No action required. Current cost is within budget.');
71+
return;
72+
}
73+
74+
console.log(`Disabling billing for project '${projectName}'...`);
75+
76+
const billingEnabled = await _isBillingEnabled(projectName);
77+
if (billingEnabled) {
78+
await _disableBillingForProject(projectName);
79+
} else {
80+
console.log('Billing is already disabled.');
81+
}
82+
});
83+
84+
/**
85+
* Determine whether billing is enabled for a project
86+
* @param {string} projectName The name of the project to check
87+
* @returns {boolean} Whether the project has billing enabled or not
88+
*/
89+
const _isBillingEnabled = async projectName => {
90+
try {
91+
console.log(`Getting billing info for project '${projectName}'...`);
92+
const [res] = await billing.getProjectBillingInfo({name: projectName});
93+
94+
return res.billingEnabled;
95+
} catch (e) {
96+
console.error('Error getting billing info:', e);
97+
console.error(
98+
'Unable to determine if billing is enabled on specified project, ' +
99+
'assuming billing is enabled'
100+
);
101+
102+
return true;
103+
}
104+
};
105+
106+
/**
107+
* Disable billing for a project by removing its billing account
108+
* @param {string} projectName The name of the project to disable billing
109+
* @returns {void}
110+
*/
111+
const _disableBillingForProject = async projectName => {
112+
if (dryRun) {
113+
console.log(
114+
'** INFO: Disabling running in info-only mode because "dryRun" is true. ' +
115+
'To disable billing, set "dryRun" to false.'
116+
);
117+
return;
118+
}
119+
120+
// Find more information about `projects/updateBillingInfo` API method here:
121+
// https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
122+
try {
123+
// To disable billing set the `billingAccountName` field to empty
124+
const requestBody = {billingAccountName: ''};
125+
126+
const [response] = await billing.updateProjectBillingInfo({
127+
name: projectName,
128+
resource: requestBody,
129+
});
130+
131+
console.log(`Billing disabled. Response: ${JSON.stringify(response)}`);
132+
} catch (e) {
133+
console.error('Failed to disable billing, check permissions.', e);
134+
}
135+
};
136+
// [END functions_billing_stop]

functions/v2/stopBilling/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "cloud-functions-stop-billing",
3+
"private": true,
4+
"version": "0.0.1",
5+
"description": "Disable billing with a budget notification.",
6+
"main": "index.js",
7+
"engines": {
8+
"node": ">=20.0.0"
9+
},
10+
"scripts": {
11+
"test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit"
12+
},
13+
"author": "Google Inc.",
14+
"license": "Apache-2.0",
15+
"dependencies": {
16+
"@google-cloud/billing": "^5.1.0",
17+
"@google-cloud/functions-framework": "^4.0.0",
18+
"cloudevents": "^10.0.0",
19+
"gcp-metadata": "^7.0.1"
20+
},
21+
"devDependencies": {
22+
"c8": "^10.1.3"
23+
}
24+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
const assert = require('assert');
16+
const {CloudEvent} = require('cloudevents');
17+
const {getFunction} = require('@google-cloud/functions-framework/testing');
18+
19+
require('../index');
20+
21+
const getDataWithinBudget = () => {
22+
return {
23+
budgetDisplayName: 'BUDGET_NAME',
24+
costAmount: 5.0,
25+
costIntervalStart: new Date().toISOString(),
26+
budgetAmount: 10.0,
27+
budgetAmountType: 'SPECIFIED_AMOUNT',
28+
currencyCode: 'USD',
29+
};
30+
};
31+
32+
const getDataOverBudget = () => {
33+
return {
34+
budgetDisplayName: 'BUDGET_NAME',
35+
alertThresholdExceeded: 0.9,
36+
costAmount: 20.0,
37+
costIntervalStart: new Date().toISOString(),
38+
budgetAmount: 10.0,
39+
budgetAmountType: 'SPECIFIED_AMOUNT',
40+
currencyCode: 'USD',
41+
};
42+
};
43+
44+
/**
45+
* Get a simulated CloudEvent for a Budget notification.
46+
* Find more examples here:
47+
* https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications
48+
* @param {boolean} isOverBudget - Whether or not the budget has been exceeded.
49+
* @returns {CloudEvent} The simulated CloudEvent.
50+
*/
51+
const getCloudEventOverBudgetAlert = isOverBudget => {
52+
let budgetData;
53+
54+
if (isOverBudget) {
55+
budgetData = getDataOverBudget();
56+
} else {
57+
budgetData = getDataWithinBudget();
58+
}
59+
60+
const jsonString = JSON.stringify(budgetData);
61+
const messageBase64 = Buffer.from(jsonString).toString('base64');
62+
63+
const encodedData = {
64+
message: {
65+
data: messageBase64,
66+
},
67+
};
68+
69+
return new CloudEvent({
70+
specversion: '1.0',
71+
id: 'my-id',
72+
source: '//pubsub.googleapis.com/projects/PROJECT_NAME/topics/TOPIC_NAME',
73+
data: encodedData,
74+
type: 'google.cloud.pubsub.topic.v1.messagePublished',
75+
datacontenttype: 'application/json',
76+
time: new Date().toISOString(),
77+
});
78+
};
79+
80+
describe('index.test.js', () => {
81+
describe('functions_billing_stop StopBillingCloudEvent', () => {
82+
let consoleOutput = '';
83+
const originalConsoleLog = console.log;
84+
const originalConsoleError = console.error;
85+
86+
beforeEach(async () => {
87+
consoleOutput = '';
88+
console.log = message => (consoleOutput += message + '\n');
89+
console.error = message => (consoleOutput += 'ERROR: ' + message + '\n');
90+
});
91+
92+
afterEach(() => {
93+
console.log = originalConsoleLog;
94+
console.error = originalConsoleError;
95+
});
96+
97+
it('should receive a notification within budget', async () => {
98+
const StopBillingCloudEvent = getFunction('StopBillingCloudEvent');
99+
const isOverBudget = false;
100+
await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget));
101+
102+
assert.ok(
103+
consoleOutput.includes(
104+
'No action required. Current cost is within budget.'
105+
)
106+
);
107+
});
108+
109+
it('should receive a notification exceeding the budget and simulate stopping billing', async () => {
110+
const StopBillingCloudEvent = getFunction('StopBillingCloudEvent');
111+
const isOverBudget = true;
112+
await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget));
113+
114+
assert.ok(consoleOutput.includes('Getting billing info'));
115+
assert.ok(consoleOutput.includes('Disabling billing for project'));
116+
assert.ok(consoleOutput.includes('Disabling running in info-only mode'));
117+
});
118+
});
119+
});

0 commit comments

Comments
 (0)