Skip to content

Commit 24c71f9

Browse files
committed
fix(functions): update and fix functions_billing_stop sample
1 parent 1eed2eb commit 24c71f9

File tree

5 files changed

+397
-0
lines changed

5 files changed

+397
-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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
functions.cloudEvent('stopBilling', async cloudEvent => {
23+
let PROJECT_ID;
24+
25+
try {
26+
PROJECT_ID = await gcpMetadata.project('project-id');
27+
} catch (error) {
28+
console.error('PROJECT_ID not found:', error);
29+
return;
30+
}
31+
32+
const PROJECT_NAME = `projects/${PROJECT_ID}`;
33+
34+
const eventData = Buffer.from(
35+
cloudEvent.data['message']['data'],
36+
'base64'
37+
).toString();
38+
39+
const eventObject = JSON.parse(eventData);
40+
41+
console.log(
42+
`Cost: ${eventObject.costAmount} Budget: ${eventObject.budgetAmount}`
43+
);
44+
45+
if (eventObject.costAmount <= eventObject.budgetAmount) {
46+
console.log('No action required. Current cost is within budget.');
47+
return;
48+
}
49+
50+
const billingEnabled = await _isBillingEnabled(PROJECT_NAME);
51+
if (billingEnabled) {
52+
_disableBillingForProject(PROJECT_NAME);
53+
} else {
54+
console.log('Billing is already disabled.');
55+
}
56+
});
57+
58+
/**
59+
* Determine whether billing is enabled for a project
60+
* @param {string} projectName Name of project to check if billing is enabled
61+
* @return {bool} Whether project has billing enabled or not
62+
*/
63+
const _isBillingEnabled = async projectName => {
64+
try {
65+
console.log(`Getting billing info for project '${projectName}'...`);
66+
const [res] = await billing.getProjectBillingInfo({name: projectName});
67+
68+
return res.billingEnabled;
69+
} catch (e) {
70+
console.log('Error getting billing info:', e);
71+
console.log(
72+
'Unable to determine if billing is enabled on specified project, ' +
73+
'assuming billing is enabled'
74+
);
75+
76+
return true;
77+
}
78+
};
79+
80+
/**
81+
* Disable billing for a project by removing its billing account
82+
* @param {string} projectName Name of project disable billing on
83+
*/
84+
const _disableBillingForProject = async projectName => {
85+
console.log(`Disabling billing for project '${projectName}'...`);
86+
87+
// To disable billing set the `billingAccountName` field to empty
88+
// LINT: Commented out to pass linter
89+
// const requestBody = {billingAccountName: ''};
90+
91+
// Find more information about `updateBillingInfo` API method here:
92+
// https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
93+
94+
try {
95+
// DEBUG: Simulate disabling billing
96+
console.log('Billing disabled. (Simulated)');
97+
98+
/*
99+
const [response] = await billing.updateProjectBillingInfo({
100+
name: projectName,
101+
resource: body, // Disable billing
102+
});
103+
104+
console.log(`Billing disabled: ${JSON.stringify(response)}`);
105+
*/
106+
} catch (e) {
107+
console.log('Failed to disable billing, check permissions.', e);
108+
}
109+
};
110+
// [END functions_billing_stop]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
"compute-test": "c8 mocha -p -j 2 test/periodic.test.js --timeout=600000",
12+
"test": "c8 mocha -p -j 2 test/index.test.js --timeout=5000 --exit"
13+
},
14+
"author": "Ace Nassri <[email protected]>",
15+
"license": "Apache-2.0",
16+
"dependencies": {
17+
"@google-cloud/billing": "^4.0.0",
18+
"@google-cloud/functions-framework": "^3.0.0",
19+
"gcp-metadata": "^6.0.0"
20+
},
21+
"devDependencies": {
22+
"c8": "^10.0.0",
23+
"gaxios": "^6.0.0",
24+
"mocha": "^10.0.0",
25+
"promise-retry": "^2.0.0",
26+
"proxyquire": "^2.1.0",
27+
"sinon": "^18.0.0",
28+
"wait-port": "^1.0.4"
29+
}
30+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright 2019 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 {exec} = require('child_process');
16+
const {request} = require('gaxios');
17+
const assert = require('assert');
18+
const sinon = require('sinon');
19+
const waitPort = require('wait-port');
20+
const {InstancesClient} = require('@google-cloud/compute');
21+
const sample = require('../index.js');
22+
23+
const {BILLING_ACCOUNT} = process.env;
24+
25+
describe('functions/billing tests', () => {
26+
let projectId;
27+
before(async () => {
28+
const client = new InstancesClient();
29+
projectId = await client.getProjectId();
30+
});
31+
after(async () => {
32+
// Re-enable billing using the sample file itself
33+
// Invoking the file directly is more concise vs. re-implementing billing setup here
34+
const jsonData = {
35+
billingAccountName: `billingAccounts/${BILLING_ACCOUNT}`,
36+
projectName: `projects/${projectId}`,
37+
};
38+
const encodedData = Buffer.from(JSON.stringify(jsonData)).toString(
39+
'base64'
40+
);
41+
const pubsubMessage = {data: encodedData, attributes: {}};
42+
await require('../').startBilling(pubsubMessage);
43+
});
44+
45+
describe('notifies Slack', () => {
46+
let ffProc;
47+
const PORT = 8080;
48+
const BASE_URL = `http://localhost:${PORT}`;
49+
50+
before(async () => {
51+
console.log('Starting functions-framework process...');
52+
ffProc = exec(
53+
`npx functions-framework --target=notifySlack --signature-type=event --port ${PORT}`
54+
);
55+
await waitPort({host: 'localhost', port: PORT});
56+
console.log('functions-framework process started and listening!');
57+
});
58+
59+
after(() => {
60+
console.log('Ending functions-framework process...');
61+
ffProc.kill();
62+
console.log('functions-framework process stopped.');
63+
});
64+
65+
describe('functions_billing_slack', () => {
66+
it('should notify Slack when budget is exceeded', async () => {
67+
const jsonData = {costAmount: 500, budgetAmount: 400};
68+
const encodedData = Buffer.from(JSON.stringify(jsonData)).toString(
69+
'base64'
70+
);
71+
const pubsubMessage = {data: encodedData, attributes: {}};
72+
73+
const response = await request({
74+
url: `${BASE_URL}/notifySlack`,
75+
method: 'POST',
76+
data: {data: pubsubMessage},
77+
});
78+
79+
assert.strictEqual(response.status, 200);
80+
assert.strictEqual(
81+
response.data,
82+
'Slack notification sent successfully'
83+
);
84+
});
85+
});
86+
});
87+
88+
describe('disables billing', () => {
89+
let ffProc;
90+
const PORT = 8081;
91+
const BASE_URL = `http://localhost:${PORT}`;
92+
93+
before(async () => {
94+
console.log('Starting functions-framework process...');
95+
ffProc = exec(
96+
`npx functions-framework --target=stopBilling --signature-type=event --port ${PORT}`
97+
);
98+
await waitPort({host: 'localhost', port: PORT});
99+
console.log('functions-framework process started and listening!');
100+
});
101+
102+
after(() => {
103+
console.log('Ending functions-framework process...');
104+
ffProc.kill();
105+
console.log('functions-framework process stopped.');
106+
});
107+
108+
describe('functions_billing_stop', () => {
109+
xit('should disable billing when budget is exceeded', async () => {
110+
// Use functions framework to ensure sample follows GCF specification
111+
// (Invoking it directly works too, but DOES NOT ensure GCF compatibility)
112+
const jsonData = {costAmount: 500, budgetAmount: 400};
113+
const encodedData = Buffer.from(JSON.stringify(jsonData)).toString(
114+
'base64'
115+
);
116+
const pubsubMessage = {data: encodedData, attributes: {}};
117+
118+
const response = await request({
119+
url: `${BASE_URL}/stopBilling`,
120+
method: 'POST',
121+
data: {data: pubsubMessage},
122+
});
123+
124+
assert.strictEqual(response.status, 200);
125+
assert.ok(response.data.includes('Billing disabled'));
126+
});
127+
});
128+
});
129+
130+
describe('shuts down GCE instances', () => {
131+
describe('functions_billing_limit', () => {
132+
it('should attempt to shut down GCE instances when budget is exceeded', async () => {
133+
const jsonData = {costAmount: 500, budgetAmount: 400};
134+
const encodedData = Buffer.from(JSON.stringify(jsonData)).toString(
135+
'base64'
136+
);
137+
const pubsubMessage = {data: encodedData, attributes: {}};
138+
// Mock GCE (because real GCE instances take too long to start/stop)
139+
const instances = [{name: 'test-instance-1', status: 'RUNNING'}];
140+
const listStub = sinon
141+
.stub(sample.getInstancesClient(), 'list')
142+
.resolves([instances]);
143+
const stopStub = sinon
144+
.stub(sample.getInstancesClient(), 'stop')
145+
.resolves({});
146+
await sample.limitUse(pubsubMessage);
147+
assert.strictEqual(listStub.calledOnce, true);
148+
assert.ok(stopStub.calledOnce);
149+
});
150+
});
151+
});
152+
});

0 commit comments

Comments
 (0)