Skip to content

Commit e6920ae

Browse files
authored
Merge pull request #471 from fvaleye/feature/add-gcp-and-azure-cloud-tracking
feat(cloud): add GCP and Azure carbon emission tracking using new rerentials
2 parents 88a8d73 + 256aebf commit e6920ae

File tree

18 files changed

+1243
-8
lines changed

18 files changed

+1243
-8
lines changed

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ pip install 'tracarbon[datadog,prometheus,kubernetes]'
3737
| **Cloud Provider** | **Description** |
3838
| ------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |
3939
| AWS | ✅ Use the hardware's usage with the EC2 instances carbon emissions datasets of [cloud-carbon-coefficients](https://github.com/cloud-carbon-footprint/ccf-coefficients/blob/main/data/aws-instances.csv). |
40-
| GCP | ❌ Not yet implemented. |
41-
| Azure | ❌ Not yet implemented. |
40+
| GCP | ✅ Use the hardware's usage with the GCP instances carbon emissions datasets of [cloud-carbon-coefficients](https://github.com/cloud-carbon-footprint/ccf-coefficients/blob/main/data/gcp-instances.csv). |
41+
| Azure | ✅ Use the hardware's usage with the Azure instances carbon emissions datasets of [cloud-carbon-coefficients](https://github.com/cloud-carbon-footprint/ccf-coefficients/blob/main/data/azure-instances.csv). |
4242

4343
### 🎮 GPU: power tracking
4444

@@ -65,6 +65,8 @@ pip install 'tracarbon[datadog,prometheus,kubernetes]'
6565
| Worldwide | Get the latest co2g/kwh in near real-time using the CO2Signal or ElectricityMaps APIs. See [here](http://api.electricitymap.org/v3/zones) for the list of available zones. | [CO2Signal API](https://www.co2signal.com) or [ElectricityMaps](https://static.electricitymaps.com/api/docs/index.html) |
6666
| Europe | Static file created from the European Environment Agency Emission for the co2g/kwh in European countries. | [EEA website](https://www.eea.europa.eu/en/analysis/maps-and-charts/co2-emission-intensity-15) |
6767
| AWS | Static file of the AWS Grid emissions factors. | [cloud-carbon-coefficients](https://github.com/cloud-carbon-footprint/cloud-carbon-coefficients/blob/main/data/grid-emissions-factors-aws.csv) |
68+
| GCP | Static file of the GCP Grid emissions factors (2024 yearly data). | [GoogleCloudPlatform/region-carbon-info](https://github.com/GoogleCloudPlatform/region-carbon-info/blob/main/data/yearly/2024.csv) |
69+
| Azure | Static file of the Azure Grid emissions factors. | [cloud-carbon-coefficients](https://github.com/cloud-carbon-footprint/cloud-carbon-coefficients/blob/main/data/grid-emissions-factors-azure.csv) |
6870

6971
### ⚙️ Configuration
7072

scripts/check_data.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import urllib.request
2+
from urllib.error import URLError
23
from urllib.parse import urlparse
34

45

@@ -17,6 +18,25 @@ def check_content_length(url: str, expected_content_length: str) -> bool:
1718
return True
1819

1920

21+
def check_new_year_available_for_gcp(base_url: str, current_year: int) -> None:
22+
if not is_valid_url(base_url):
23+
raise ValueError(f"Invalid or unsafe URL scheme for URL: {base_url}")
24+
25+
next_year = current_year + 1
26+
next_year_url = base_url.replace(str(current_year), str(next_year))
27+
28+
try:
29+
response = urllib.request.urlopen(next_year_url, timeout=10) # noqa: S310
30+
if response.getcode() == 200:
31+
raise ValueError(
32+
f"New year {next_year} data is available at {next_year_url}. "
33+
f"Please update the data file and code to use the new year."
34+
)
35+
except URLError:
36+
# URL doesn't exist, which is expected if no new year is available
37+
pass
38+
39+
2040
if __name__ == "__main__":
2141
urls = [
2242
{
@@ -31,6 +51,13 @@ def check_content_length(url: str, expected_content_length: str) -> bool:
3151
"url": "https://raw.githubusercontent.com/cloud-carbon-footprint/ccf-coefficients/main/data/grid-emissions-factors-aws.csv",
3252
"content_length": "1204",
3353
},
54+
{
55+
"url": "https://raw.githubusercontent.com/GoogleCloudPlatform/region-carbon-info/main/data/yearly/2024.csv",
56+
"content_length": "1621",
57+
},
3458
]
3559
for url in urls:
3660
check_content_length(url=url["url"], expected_content_length=url["content_length"])
61+
62+
gcp_base_url = "https://raw.githubusercontent.com/GoogleCloudPlatform/region-carbon-info/main/data/yearly/2024.csv"
63+
check_new_year_available_for_gcp(gcp_base_url, current_year=2024)

tests/hardwares/test_sensors.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
77
from tracarbon import EnergyConsumption
88
from tracarbon import LinuxEnergyConsumption
99
from tracarbon import TracarbonException
10+
from tracarbon.exceptions import AzureSensorException
11+
from tracarbon.exceptions import GCPSensorException
1012
from tracarbon.hardwares import EnergyUsage
1113
from tracarbon.hardwares import HardwareInfo
1214
from tracarbon.hardwares import WindowsEnergyConsumption
1315
from tracarbon.hardwares.cloud_providers import AWS
16+
from tracarbon.hardwares.cloud_providers import GCP
17+
from tracarbon.hardwares.cloud_providers import Azure
18+
from tracarbon.hardwares.sensors import AzureEnergyConsumption
19+
from tracarbon.hardwares.sensors import GCPEnergyConsumption
1420

1521

1622
@pytest.mark.darwin
@@ -151,3 +157,111 @@ async def test_get_platform_should_return_the_platform_energy_consumption_window
151157
with pytest.raises(TracarbonException) as exception:
152158
await WindowsEnergyConsumption().get_energy_usage()
153159
assert exception.value.args[0] == "This Windows hardware is not yet supported."
160+
161+
162+
def test_is_gcp_should_return_false_on_exception():
163+
assert GCP.is_gcp() is False
164+
165+
166+
def test_is_gcp_should_return_true(mocker):
167+
mock_response = mocker.Mock()
168+
mock_response.status_code = 200
169+
mocker.patch.object(requests, "get", return_value=mock_response)
170+
171+
assert GCP.is_gcp() is True
172+
173+
174+
def test_gcp_from_metadata(mocker):
175+
mock_machine_response = mocker.Mock()
176+
mock_machine_response.text = "projects/123456/machineTypes/n2-standard-4"
177+
178+
mock_zone_response = mocker.Mock()
179+
mock_zone_response.text = "projects/123456/zones/us-central1-a"
180+
181+
mocker.patch.object(
182+
requests,
183+
"get",
184+
side_effect=[mock_machine_response, mock_zone_response],
185+
)
186+
187+
gcp = GCP.from_metadata()
188+
189+
assert gcp.instance_type == "n2-standard-4"
190+
assert gcp.region_name == "us-central1"
191+
192+
193+
@pytest.mark.asyncio
194+
async def test_gcp_sensor_should_return_energy_consumption(mocker):
195+
gcp_sensor = GCPEnergyConsumption(instance_type="n2-standard-4")
196+
197+
assert gcp_sensor.vcpus == 4.0
198+
assert gcp_sensor.memory_gb == 16.0
199+
assert gcp_sensor.min_watts > 0
200+
assert gcp_sensor.max_watts > gcp_sensor.min_watts
201+
202+
mocker.patch.object(HardwareInfo, "get_cpu_usage", return_value=50)
203+
from tracarbon.hardwares.gpu import GPUInfo
204+
205+
mocker.patch.object(GPUInfo, "get_gpu_power_usage_or_none", return_value=None)
206+
207+
energy_usage = await gcp_sensor.get_energy_usage()
208+
209+
expected_power = gcp_sensor.min_watts + (gcp_sensor.max_watts - gcp_sensor.min_watts) * 0.5
210+
assert abs(energy_usage.host_energy_usage - expected_power) < 0.01
211+
212+
213+
def test_gcp_sensor_should_return_error_when_instance_type_is_missing():
214+
instance_type = "unknown-instance-type"
215+
216+
with pytest.raises(GCPSensorException):
217+
GCPEnergyConsumption(instance_type=instance_type)
218+
219+
220+
def test_is_azure_should_return_false_on_exception():
221+
assert Azure.is_azure() is False
222+
223+
224+
def test_is_azure_should_return_true(mocker):
225+
mock_response = mocker.Mock()
226+
mock_response.status_code = 200
227+
mocker.patch.object(requests, "get", return_value=mock_response)
228+
229+
assert Azure.is_azure() is True
230+
231+
232+
def test_azure_from_metadata(mocker):
233+
mock_response = mocker.Mock()
234+
mock_response.json.return_value = {"compute": {"vmSize": "Standard_D2s_v3", "location": "eastus"}}
235+
mocker.patch.object(requests, "get", return_value=mock_response)
236+
237+
azure = Azure.from_metadata()
238+
239+
assert azure.instance_type == "Standard_D2s_v3"
240+
assert azure.region_name == "eastus"
241+
242+
243+
@pytest.mark.asyncio
244+
async def test_azure_sensor_should_return_energy_consumption(mocker):
245+
azure_sensor = AzureEnergyConsumption(instance_type="D2 v3")
246+
247+
assert azure_sensor.vcpus == 2.0
248+
assert azure_sensor.memory_gb == 8.0
249+
assert azure_sensor.min_watts > 0
250+
assert azure_sensor.max_watts > azure_sensor.min_watts
251+
252+
mocker.patch.object(HardwareInfo, "get_cpu_usage", return_value=50)
253+
from tracarbon.hardwares.gpu import GPUInfo
254+
255+
mocker.patch.object(GPUInfo, "get_gpu_power_usage_or_none", return_value=None)
256+
257+
energy_usage = await azure_sensor.get_energy_usage()
258+
259+
expected_power = azure_sensor.min_watts + (azure_sensor.max_watts - azure_sensor.min_watts) * 0.5
260+
assert abs(energy_usage.host_energy_usage - expected_power) < 0.01
261+
262+
263+
def test_azure_sensor_should_return_error_when_instance_type_is_missing():
264+
instance_type = "unknown-instance-type"
265+
266+
with pytest.raises(AzureSensorException):
267+
AzureEnergyConsumption(instance_type=instance_type)

tests/locations/test_location.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from tracarbon.locations import AWSLocation
77
from tracarbon.locations import Country
88
from tracarbon.locations import Location
9+
from tracarbon.locations.country import AzureLocation
10+
from tracarbon.locations.country import GCPLocation
911

1012

1113
@pytest.mark.asyncio
@@ -88,3 +90,61 @@ def test_aws_location_should_return_ok_if_region_exists():
8890
assert location.name == "AWS(eu-west-1)"
8991
assert location.co2g_kwh == 316.0
9092
assert location.co2g_kwh_source.value == "file"
93+
94+
95+
def test_gcp_location_should_return_an_error_if_region_not_exists():
96+
region_name = "unknown-region"
97+
98+
with pytest.raises(CloudProviderRegionIsMissing) as exception:
99+
GCPLocation(region_name=region_name)
100+
assert exception.value.args[0] == f"The region [{region_name}] is not in the GCP grid emissions factors file."
101+
102+
103+
def test_gcp_location_should_return_ok_if_region_exists():
104+
region_name = "europe-west1"
105+
106+
location = GCPLocation(region_name=region_name)
107+
108+
assert location.name == "GCP(europe-west1)"
109+
assert location.co2g_kwh > 100
110+
assert location.co2g_kwh < 110
111+
assert location.co2g_kwh_source.value == "file"
112+
113+
114+
def test_gcp_location_us_central1():
115+
region_name = "us-central1"
116+
117+
location = GCPLocation(region_name=region_name)
118+
119+
assert location.name == "GCP(us-central1)"
120+
assert location.co2g_kwh > 400
121+
assert location.co2g_kwh < 420
122+
123+
124+
def test_azure_location_should_return_an_error_if_region_not_exists():
125+
region_name = "unknown-region"
126+
127+
with pytest.raises(CloudProviderRegionIsMissing) as exception:
128+
AzureLocation(region_name=region_name)
129+
assert exception.value.args[0] == f"The region [{region_name}] is not in the Azure grid emissions factors file."
130+
131+
132+
def test_azure_location_should_return_ok_if_region_exists():
133+
region_name = "West Europe"
134+
135+
location = AzureLocation(region_name=region_name)
136+
137+
assert location.name == "Azure(West Europe)"
138+
assert location.co2g_kwh > 380
139+
assert location.co2g_kwh < 400
140+
assert location.co2g_kwh_source.value == "file"
141+
142+
143+
def test_azure_location_east_us():
144+
region_name = "East US"
145+
146+
location = AzureLocation(region_name=region_name)
147+
148+
assert location.name == "Azure(East US)"
149+
assert location.co2g_kwh > 410
150+
assert location.co2g_kwh < 420

tracarbon/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
__all__ = [
2+
"TracarbonException",
3+
"CountryIsMissing",
4+
"CloudProviderRegionIsMissing",
5+
"AWSSensorException",
6+
"GCPSensorException",
7+
"AzureSensorException",
8+
"HardwareRAPLException",
9+
"HardwareNoGPUDetectedException",
10+
"CO2SignalAPIKeyIsMissing",
11+
]
12+
13+
114
class TracarbonException(Exception):
215
"""General Tracarbon Exception."""
316

@@ -22,6 +35,18 @@ class AWSSensorException(TracarbonException):
2235
pass
2336

2437

38+
class GCPSensorException(TracarbonException):
39+
"""Error in the GCP Sensor."""
40+
41+
pass
42+
43+
44+
class AzureSensorException(TracarbonException):
45+
"""Error in the Azure Sensor."""
46+
47+
pass
48+
49+
2550
class HardwareRAPLException(TracarbonException):
2651
"""The hardware is not compatible with RAPL."""
2752

tracarbon/hardwares/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from tracarbon.hardwares.amd_rapl import *
2+
from tracarbon.hardwares.cloud_providers import *
23
from tracarbon.hardwares.containers import *
34
from tracarbon.hardwares.energy import *
45
from tracarbon.hardwares.hardware import *

tracarbon/hardwares/amd_rapl.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from tracarbon.hardwares.energy import EnergyUsage
1616
from tracarbon.hardwares.energy import Power
1717

18+
__all__ = [
19+
"AMDRAPLResult",
20+
"AMDRAPL",
21+
]
22+
1823

1924
class AMDRAPLResult(BaseModel):
2025
"""

0 commit comments

Comments
 (0)