Skip to content

Commit 96a8334

Browse files
committed
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
1 parent 2e87396 commit 96a8334

File tree

3 files changed

+196
-13
lines changed

3 files changed

+196
-13
lines changed

functions/billing/stop_billing/package.json

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,12 @@
1313
"author": "Emmanuel Parada <[email protected]>",
1414
"license": "Apache-2.0",
1515
"dependencies": {
16-
"@google-cloud/billing": "^4.0.0",
17-
"@google-cloud/functions-framework": "^3.0.0",
18-
"cloudevents": "^9.0.0",
19-
"gcp-metadata": "^6.0.0"
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"
2020
},
2121
"devDependencies": {
22-
"c8": "^10.0.0",
23-
"chai": "^5.2.1",
24-
"gaxios": "^6.0.0",
25-
"mocha": "^10.0.0",
26-
"promise-retry": "^2.0.0",
27-
"proxyquire": "^2.1.0",
28-
"sinon": "^18.0.0",
29-
"supertest": "^7.1.3",
30-
"wait-port": "^1.0.4"
22+
"c8": "^10.1.3"
3123
}
3224
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// https://github.com/GoogleCloudPlatform/functions-framework-python
2+
// https://github.com/GoogleCloudPlatform/functions-framework-nodejs
3+
4+
// Simplified representation of `stop_billing` logic
5+
const {CloudBillingClient} = require('@google-cloud/billing');
6+
const functions = require('@google-cloud/functions-framework');
7+
const gcpMetadata = require('gcp-metadata');
8+
9+
const billing = new CloudBillingClient();
10+
11+
const PROJECT_ID = process.env.GOOGLE_CLOUD_PROJECT;
12+
13+
/*
14+
functions.cloudEvent('StopBillingCloudEvent', async (cloudEvent) => {
15+
// console.log(cloudEvent);
16+
const eventData = Buffer.from(
17+
cloudEvent.data['message']['data'],
18+
'base64'
19+
).toString();
20+
21+
const eventObject = JSON.parse(eventData);
22+
23+
console.log(
24+
`Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}`
25+
);
26+
27+
console.log("Getting billing info for project...");
28+
console.log("Disabling billing for project...");
29+
console.log("Billing disabled. (Simulated)");
30+
});
31+
*/
32+
33+
functions.cloudEvent('StopBillingCloudEvent', async cloudEvent => {
34+
// TODO(developer): As stopping billing is a destructive action
35+
// for your project, change the following constant to false
36+
// after you validate with a test budget.
37+
const simulateDeactivation = true;
38+
39+
let projectId = PROJECT_ID;
40+
41+
if (projectId === undefined) {
42+
try {
43+
projectId = await gcpMetadata.project('project-id');
44+
} catch (error) {
45+
console.error('project-id metadata not found:', error);
46+
return;
47+
}
48+
}
49+
50+
const projectName = `projects/${projectId}`;
51+
52+
const eventData = Buffer.from(
53+
cloudEvent.data['message']['data'],
54+
'base64'
55+
).toString();
56+
57+
const eventObject = JSON.parse(eventData);
58+
59+
console.log(
60+
`Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}`
61+
);
62+
63+
if (eventObject.costAmount <= eventObject.budgetAmount) {
64+
console.log('No action required. Current cost is within budget.');
65+
return;
66+
}
67+
68+
console.log(`Disabling billing for project '${projectName}'...`);
69+
70+
const billingEnabled = await _isBillingEnabled(projectName);
71+
if (billingEnabled) {
72+
_disableBillingForProject(projectName, simulateDeactivation);
73+
} else {
74+
console.log('Billing is already disabled.');
75+
}
76+
});
77+
78+
/**
79+
* Determine whether billing is enabled for a project
80+
* @param {string} projectName The name of the project to check
81+
* @returns {boolean} Whether the project has billing enabled or not
82+
*/
83+
const _isBillingEnabled = async projectName => {
84+
try {
85+
console.log(`Getting billing info for project '${projectName}'...`);
86+
const [res] = await billing.getProjectBillingInfo({name: projectName});
87+
88+
return res.billingEnabled;
89+
} catch (e) {
90+
console.log('Error getting billing info:', e);
91+
console.log(
92+
'Unable to determine if billing is enabled on specified project, ' +
93+
'assuming billing is enabled'
94+
);
95+
96+
return true;
97+
}
98+
};
99+
100+
/**
101+
* Disable billing for a project by removing its billing account
102+
* @param {string} projectName The name of the project to disable billing
103+
* @param {boolean} simulateDeactivation
104+
* If true, it won't actually disable billing.
105+
* Useful to validate with test budgets.
106+
* @returns {void}
107+
*/
108+
const _disableBillingForProject = async (projectName, simulateDeactivation) => {
109+
if (simulateDeactivation) {
110+
console.log('Billing disabled. (Simulated)');
111+
return;
112+
}
113+
114+
// Find more information about `projects/updateBillingInfo` API method here:
115+
// https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
116+
try {
117+
// To disable billing set the `billingAccountName` field to empty
118+
const requestBody = {billingAccountName: ''};
119+
120+
const [response] = await billing.updateProjectBillingInfo({
121+
name: projectName,
122+
resource: requestBody,
123+
});
124+
125+
console.log(`Billing disabled: ${JSON.stringify(response)}`);
126+
} catch (e) {
127+
console.log('Failed to disable billing, check permissions.', e);
128+
}
129+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
const assert = require('assert');
2+
const { CloudEvent } = require('cloudevents');
3+
const { getFunction } = require('@google-cloud/functions-framework/testing');
4+
5+
require('../stop_billing');
6+
7+
const getCloudEventBudgetAlert = () => {
8+
const budgetData = {
9+
"budgetDisplayName": "BUDGET_NAME",
10+
"alertThresholdExceeded": 1.0,
11+
"costAmount": 2.0,
12+
"costIntervalStart": "2025-05-01T07:00:00Z",
13+
"budgetAmount": 0.01,
14+
"budgetAmountType": "SPECIFIED_AMOUNT",
15+
"currencyCode": "USD"
16+
};
17+
18+
const jsonString = JSON.stringify(budgetData);
19+
const messageBase64 = Buffer.from(jsonString).toString('base64');
20+
21+
const encodedData = {
22+
"message": {
23+
"data": messageBase64
24+
}
25+
};
26+
27+
return new CloudEvent({
28+
specversion: '1.0',
29+
id: 'my-id',
30+
source: '//pubsub.googleapis.com/projects/PROJECT_NAME/topics/TOPIC_NAME',
31+
data: encodedData,
32+
type: 'google.cloud.pubsub.topic.v1.messagePublished',
33+
datacontenttype: 'application/json',
34+
time: new Date().toISOString(),
35+
});
36+
};
37+
38+
describe('Billing Stop Function', () => {
39+
let consoleOutput = '';
40+
const originalConsoleLog = console.log;
41+
const originalConsoleError = console.error;
42+
43+
beforeEach(async () => {
44+
consoleOutput = '';
45+
console.log = (message) => consoleOutput += message + '\n';
46+
console.error = (message) => consoleOutput += "ERROR: " + message + '\n';
47+
});
48+
49+
afterEach(() => {
50+
console.log = originalConsoleLog;
51+
console.error = originalConsoleError;
52+
});
53+
54+
it('should receive a budget alert and simulate stopping billing', async () => {
55+
const StopBillingCloudEvent = getFunction("StopBillingCloudEvent");
56+
StopBillingCloudEvent(getCloudEventBudgetAlert());
57+
58+
assert.ok(consoleOutput.includes('Getting billing info'));
59+
assert.ok(consoleOutput.includes('Disabling billing for project'));
60+
assert.ok(consoleOutput.includes('Billing disabled. (Simulated)'));
61+
});
62+
});

0 commit comments

Comments
 (0)