Skip to content

Commit c1a36d5

Browse files
committed
fix(functions): fix stop-billing sample
- Add test for notification within budget - Validate unit tests - Apply linting
1 parent 6f98466 commit c1a36d5

File tree

4 files changed

+283
-0
lines changed

4 files changed

+283
-0
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# This file specifies files that are *not* uploaded to Google Cloud Platform
2+
# using gcloud. It follows the same syntax as .gitignore, with the addition of
3+
# "#!include" directives (which insert the entries of the given .gitignore-style
4+
# file at that point).
5+
#
6+
# For more information, run:
7+
# $ gcloud topic gcloudignore
8+
#
9+
.gcloudignore
10+
# If you would like to upload your .git directory, .gitignore file or files
11+
# from your .gitignore file, remove the corresponding line
12+
# below:
13+
.git
14+
.gitignore
15+
16+
node_modules
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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+
const projectIdEnv = process.env.GOOGLE_CLOUD_PROJECT;
23+
24+
functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => {
25+
// TODO(developer): As stopping billing is a destructive action
26+
// for your project, change the following constant to `false`
27+
// after you validate with a test budget.
28+
const simulateDeactivation = true;
29+
30+
let projectId = projectIdEnv;
31+
32+
if (projectId === undefined) {
33+
console.log('Project ID not found in Env variables. Reading metadata...');
34+
try {
35+
projectId = await gcpMetadata.project('project-id');
36+
} catch (error) {
37+
console.error('project-id metadata not found:', error);
38+
return;
39+
}
40+
}
41+
42+
const projectName = `projects/${projectId}`;
43+
44+
// Find more information about the notification format here:
45+
// https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications#notification-format
46+
const eventData = Buffer.from(
47+
cloudEvent.data['message']['data'],
48+
'base64'
49+
).toString();
50+
51+
const eventObject = JSON.parse(eventData);
52+
53+
console.log(
54+
`Project ID: ${projectId} ` +
55+
`Current cost: ${eventObject.costAmount} ` +
56+
`Budget: ${eventObject.budgetAmount}`
57+
);
58+
59+
if (eventObject.costAmount <= eventObject.budgetAmount) {
60+
console.log('No action required. Current cost is within budget.');
61+
return;
62+
}
63+
64+
console.log(`Disabling billing for project '${projectName}'...`);
65+
66+
const billingEnabled = await _isBillingEnabled(projectName);
67+
if (billingEnabled) {
68+
_disableBillingForProject(projectName, simulateDeactivation);
69+
} else {
70+
console.log('Billing is already disabled.');
71+
}
72+
});
73+
74+
/**
75+
* Determine whether billing is enabled for a project
76+
* @param {string} projectName The name of the project to check
77+
* @returns {boolean} Whether the project has billing enabled or not
78+
*/
79+
const _isBillingEnabled = async projectName => {
80+
try {
81+
console.log(`Getting billing info for project '${projectName}'...`);
82+
const [res] = await billing.getProjectBillingInfo({name: projectName});
83+
84+
return res.billingEnabled;
85+
} catch (e) {
86+
console.log('Error getting billing info:', e);
87+
console.log(
88+
'Unable to determine if billing is enabled on specified project, ' +
89+
'assuming billing is enabled'
90+
);
91+
92+
return true;
93+
}
94+
};
95+
96+
/**
97+
* Disable billing for a project by removing its billing account
98+
* @param {string} projectName The name of the project to disable billing
99+
* @param {boolean} simulateDeactivation
100+
* If true, it won't actually disable billing.
101+
* Useful to validate with test budgets.
102+
* @returns {void}
103+
*/
104+
const _disableBillingForProject = async (projectName, simulateDeactivation) => {
105+
if (simulateDeactivation) {
106+
console.log('Billing disabled. (Simulated)');
107+
return;
108+
}
109+
110+
// Find more information about `projects/updateBillingInfo` API method here:
111+
// https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
112+
try {
113+
// To disable billing set the `billingAccountName` field to empty
114+
const requestBody = {billingAccountName: ''};
115+
116+
const [response] = await billing.updateProjectBillingInfo({
117+
name: projectName,
118+
resource: requestBody,
119+
});
120+
121+
console.log(`Billing disabled: ${JSON.stringify(response)}`);
122+
} catch (e) {
123+
console.log('Failed to disable billing, check permissions.', e);
124+
}
125+
};
126+
// [END functions_billing_stop]
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": "Emmanuel Parada <[email protected]>",
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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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('Billing Stop Function', () => {
81+
let consoleOutput = '';
82+
const originalConsoleLog = console.log;
83+
const originalConsoleError = console.error;
84+
85+
beforeEach(async () => {
86+
consoleOutput = '';
87+
console.log = message => (consoleOutput += message + '\n');
88+
console.error = message => (consoleOutput += 'ERROR: ' + message + '\n');
89+
});
90+
91+
afterEach(() => {
92+
console.log = originalConsoleLog;
93+
console.error = originalConsoleError;
94+
});
95+
96+
it('should receive a notification within budget', async () => {
97+
const StopBillingCloudEvent = getFunction('StopBillingCloudEvent');
98+
const isOverBudget = false;
99+
await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget));
100+
101+
assert.ok(
102+
consoleOutput.includes(
103+
'No action required. Current cost is within budget.'
104+
)
105+
);
106+
});
107+
108+
it('should receive a notification exceeding the budget and simulate stopping billing', async () => {
109+
const StopBillingCloudEvent = getFunction('StopBillingCloudEvent');
110+
const isOverBudget = true;
111+
await StopBillingCloudEvent(getCloudEventOverBudgetAlert(isOverBudget));
112+
113+
assert.ok(consoleOutput.includes('Getting billing info'));
114+
assert.ok(consoleOutput.includes('Disabling billing for project'));
115+
assert.ok(consoleOutput.includes('Billing disabled. (Simulated)'));
116+
});
117+
});

0 commit comments

Comments
 (0)