Skip to content

Commit 01dafe7

Browse files
fix(functions): update and fix functions_billing_stop sample (#13359)
* fix(functions): WIP fix sample functions_billing_stop - Moved to another folder since it requires specific requirements * fix(functions): add detail to function names * fix(functions): WIP to migrate client library * fix(functions): fix sample for internal review - replace googleapiclient.discovery with google.cloud.billing_v1 - unify namings with Node sample - pass lint * fix(functions): add missing file in previous commit * fix(functions): add a variable for the developer to simulate disabling billing - Style fixes - Create the billing client at the top of the script * fix(functions): add unit test * fix(functions): move sample to another folder to avoid problem in Kokoro CI * fix(functions): uncomment debug code, and fix comments * fix(functions): add warning to the sample * fix(functions): log an entry in Cloud Logging when disabling billing
1 parent 87f569e commit 01dafe7

File tree

4 files changed

+257
-0
lines changed

4 files changed

+257
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pytest==8.3.5
2+
cloudevents==1.11.0
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
functions-framework==3.*
2+
google-cloud-billing==1.16.2
3+
google-cloud-logging==3.12.1
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
# WARNING: The following action, if not in simulation mode, will disable billing
17+
# for the project, potentially stopping all services and causing outages.
18+
# Ensure thorough testing and understanding before enabling live deactivation.
19+
20+
import base64
21+
import json
22+
import os
23+
import urllib.request
24+
25+
from cloudevents.http.event import CloudEvent
26+
import functions_framework
27+
28+
from google.api_core import exceptions
29+
from google.cloud import billing_v1
30+
from google.cloud import logging
31+
32+
billing_client = billing_v1.CloudBillingClient()
33+
34+
35+
def get_project_id() -> str:
36+
"""Retrieves the Google Cloud Project ID.
37+
38+
This function first attempts to get the project ID from the
39+
`GOOGLE_CLOUD_PROJECT` environment variable. If the environment
40+
variable is not set or is None, it then attempts to retrieve the
41+
project ID from the Google Cloud metadata server.
42+
43+
Returns:
44+
str: The Google Cloud Project ID.
45+
46+
Raises:
47+
ValueError: If the project ID cannot be determined either from
48+
the environment variable or the metadata server.
49+
"""
50+
51+
# Read the environment variable, usually set manually
52+
project_id = os.getenv("GOOGLE_CLOUD_PROJECT")
53+
if project_id is not None:
54+
return project_id
55+
56+
# Otherwise, get the `project-id`` from the Metadata server
57+
url = "http://metadata.google.internal/computeMetadata/v1/project/project-id"
58+
req = urllib.request.Request(url)
59+
req.add_header("Metadata-Flavor", "Google")
60+
project_id = urllib.request.urlopen(req).read().decode()
61+
62+
if project_id is None:
63+
raise ValueError("project-id metadata not found.")
64+
65+
return project_id
66+
67+
68+
@functions_framework.cloud_event
69+
def stop_billing(cloud_event: CloudEvent) -> None:
70+
# TODO(developer): As stoping billing is a destructive action
71+
# for your project, change the following constant to False
72+
# after you validate with a test budget.
73+
SIMULATE_DEACTIVATION = True
74+
75+
PROJECT_ID = get_project_id()
76+
PROJECT_NAME = f"projects/{PROJECT_ID}"
77+
78+
event_data = base64.b64decode(
79+
cloud_event.data["message"]["data"]
80+
).decode("utf-8")
81+
82+
event_dict = json.loads(event_data)
83+
cost_amount = event_dict["costAmount"]
84+
budget_amount = event_dict["budgetAmount"]
85+
print(f"Cost: {cost_amount} Budget: {budget_amount}")
86+
87+
if cost_amount <= budget_amount:
88+
print("No action required. Current cost is within budget.")
89+
return
90+
91+
print(f"Disabling billing for project '{PROJECT_NAME}'...")
92+
93+
is_billing_enabled = _is_billing_enabled(PROJECT_NAME)
94+
95+
if is_billing_enabled:
96+
_disable_billing_for_project(
97+
PROJECT_NAME,
98+
SIMULATE_DEACTIVATION
99+
)
100+
else:
101+
print("Billing is already disabled.")
102+
103+
104+
def _is_billing_enabled(project_name: str) -> bool:
105+
"""Determine whether billing is enabled for a project.
106+
107+
Args:
108+
project_name: Name of project to check if billing is enabled.
109+
110+
Returns:
111+
Whether project has billing enabled or not.
112+
"""
113+
try:
114+
print(f"Getting billing info for project '{project_name}'...")
115+
response = billing_client.get_project_billing_info(name=project_name)
116+
117+
return response.billing_enabled
118+
except Exception as e:
119+
print(f'Error getting billing info: {e}')
120+
print(
121+
"Unable to determine if billing is enabled on specified project, "
122+
"assuming billing is enabled."
123+
)
124+
125+
return True
126+
127+
128+
def _disable_billing_for_project(
129+
project_name: str,
130+
simulate_deactivation: bool,
131+
) -> None:
132+
"""Disable billing for a project by removing its billing account.
133+
134+
Args:
135+
project_name: Name of project to disable billing.
136+
simulate_deactivation:
137+
If True, it won't actually disable billing.
138+
Useful to validate with test budgets.
139+
"""
140+
141+
# Log this operation in Cloud Logging
142+
logging_client = logging.Client()
143+
logger = logging_client.logger(name="disable-billing")
144+
145+
if simulate_deactivation:
146+
entry_text = "Billing disabled. (Simulated)"
147+
print(entry_text)
148+
logger.log_text(entry_text, severity="CRITICAL")
149+
return
150+
151+
# Find more information about `updateBillingInfo` API method here:
152+
# https://cloud.google.com/billing/docs/reference/rest/v1/projects/updateBillingInfo
153+
try:
154+
# To disable billing set the `billing_account_name` field to empty
155+
project_billing_info = billing_v1.ProjectBillingInfo(
156+
billing_account_name=""
157+
)
158+
159+
response = billing_client.update_project_billing_info(
160+
name=project_name,
161+
project_billing_info=project_billing_info
162+
)
163+
164+
entry_text = f"Billing disabled: {response}"
165+
print(entry_text)
166+
logger.log_text(entry_text, severity="CRITICAL")
167+
except exceptions.PermissionDenied:
168+
print("Failed to disable billing, check permissions.")
169+
# [END functions_billing_stop]
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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+
import base64
16+
import json
17+
18+
from cloudevents.conversion import to_structured
19+
from cloudevents.http import CloudEvent
20+
21+
from flask.testing import FlaskClient
22+
23+
from functions_framework import create_app
24+
25+
import pytest
26+
27+
28+
@pytest.fixture
29+
def cloud_event_budget_alert() -> CloudEvent:
30+
attributes = {
31+
"specversion": "1.0",
32+
"id": "my-id",
33+
"source": "//pubsub.googleapis.com/projects/PROJECT_NAME/topics/TOPIC_NAME",
34+
"type": "google.cloud.pubsub.topic.v1.messagePublished",
35+
"datacontenttype": "application/json",
36+
"time": "2025-05-09T18:32:46.572Z"
37+
}
38+
39+
budget_data = {
40+
"budgetDisplayName": "BUDGET_NAME",
41+
"alertThresholdExceeded": 1.0,
42+
"costAmount": 2.0,
43+
"costIntervalStart": "2025-05-01T07:00:00Z",
44+
"budgetAmount": 0.01,
45+
"budgetAmountType": "SPECIFIED_AMOUNT",
46+
"currencyCode": "USD"
47+
}
48+
49+
json_string = json.dumps(budget_data)
50+
message_base64 = base64.b64encode(json_string.encode('utf-8')).decode('utf-8')
51+
52+
data = {
53+
"message": {
54+
"data": message_base64
55+
}
56+
}
57+
58+
return CloudEvent(attributes, data)
59+
60+
61+
@pytest.fixture
62+
def client() -> FlaskClient:
63+
source = "stop_billing.py"
64+
target = "stop_billing"
65+
return create_app(target, source, "cloudevent").test_client()
66+
67+
68+
def test_receive_notification_to_stop_billing(
69+
client: FlaskClient,
70+
cloud_event_budget_alert: CloudEvent,
71+
capsys: pytest.CaptureFixture[str]
72+
) -> None:
73+
headers, data = to_structured(cloud_event_budget_alert)
74+
resp = client.post("/", headers=headers, data=data)
75+
76+
captured = capsys.readouterr()
77+
78+
assert resp.status_code == 200
79+
assert resp.data == b"OK"
80+
81+
assert "Getting billing info for project" in captured.out
82+
assert "Disabling billing for project" in captured.out
83+
assert "Billing disabled. (Simulated)" in captured.out

0 commit comments

Comments
 (0)