Repository: google/cloud-forensics-utils

Issue:
Show CPU usage of an instance
Would be awesome to be able to see the CPU usage of an instance to understand if its been breached.

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/343?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google) Report
> :exclamation: No coverage uploaded for pull request base (`main@943ebc9`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/google/cloud-forensics-utils/pull/343/graphs/tree.svg?width=650&height=150&src=pr&token=zZkPno3ah0&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)](https://codecov.io/gh/google/cloud-forensics-utils/pull/343?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)

```diff
@@           Coverage Diff           @@
##             main     #343   +/-   ##
=======================================
  Coverage        ?   64.18%           
=======================================
  Files           ?       33           
  Lines           ?     2482           
  Branches        ?        0           
=======================================
  Hits            ?     1593           
  Misses          ?      889           
  Partials        ?        0           
```

| Flag | Coverage Δ | |
|---|---|---|
| nosetests | `64.18% <0.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google#carryforward-flags-in-the-pull-request-comment) to find out more.


------

[Continue to review full report at Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/343?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/343?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Last update [943ebc9...34a358c](https://codecov.io/gh/google/cloud-forensics-utils/pull/343?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=google).

google-cla[bot]: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and then comment `@googlebot I fixed it.`. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fcloud-forensics-utils%2Fpull%2F343) for more info**.

<!-- need_author_cla -->
google-cla[bot]: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and then comment `@googlebot I fixed it.`. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fcloud-forensics-utils%2Fpull%2F343) for more info**.

<!-- need_author_cla -->
google-cla[bot]: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and then comment `@googlebot I fixed it.`. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Fgoogle%2Fcloud-forensics-utils%2Fpull%2F343) for more info**.

<!-- need_author_cla -->

Patch:
diff --git a/libcloudforensics/providers/gcp/internal/monitoring.py b/libcloudforensics/providers/gcp/internal/monitoring.py
index 2a88ed0..a90644d 100644
--- a/libcloudforensics/providers/gcp/internal/monitoring.py
+++ b/libcloudforensics/providers/gcp/internal/monitoring.py
@@ -15,7 +15,7 @@
 """Google Cloud Monitoring functionality."""
 
 import datetime
-from typing import TYPE_CHECKING, Dict
+from typing import TYPE_CHECKING, Dict, List, Tuple, Optional
 
 from libcloudforensics.providers.gcp.internal import common
 
@@ -90,3 +90,84 @@ class GoogleCloudMonitoring:
             if val:
               ret[service] = int(val)
     return ret
+
+  def _BuildCpuUsageFilter(self, instances: Optional[List[str]]) -> str:
+    """Builds a metrics query filter based on a list of instances.
+
+    Args:
+      instances list[str]: a list of instance names.
+
+    Returns:
+      str: the filter to use in a metrics query.
+    """
+    instances_filter = (
+          ['metric.type = "compute.googleapis.com/instance/cpu/utilization"'])
+    if instances:
+      instances_filter.append(
+          ' AND (metric.label.instance_name = "{0:s}"'.format(instances[0]))
+      if len(instances) > 1:
+        for instance_name in instances[1:]:
+          instances_filter.append(
+              ' OR metric.label.instance_name = "{0:s}"'.format(instance_name))
+      instances_filter.append(')')
+
+    return ''.join(instances_filter)
+
+  def GetCpuUsage(self,
+    instances: Optional[List[str]] = None,
+    days: int = 7,
+    aggregation_minutes: int = 60
+    ) -> Dict[str, List[Tuple[str, float]]]:
+    """Returns CPU usage metrics for compute instances.
+
+    By default returns hourly usage for the last seven days for all instances
+    within a project.
+
+    Args:
+      instances list[str]: Optional. A list of instance names to collect
+        metrics for. When not provided will collect metrics for all instances
+        in the project.
+      days (int): Optional. The number of days to collect metrics for.
+      aggregate_minutes (int): Optional. The minutes to aggregate on.
+
+    Returns:
+      Dict[str, List[Tuple[str, float]]]: the CPU usage for the instances with
+        instancename_instanceid as the key and (timestamp, usage) tuples
+        as values.
+    """
+    service = self.GcmApi()
+    gcm_timeseries_client = service.projects().timeSeries() # pylint: disable=no-member
+
+    start_time = common.FormatRFC3339(
+        datetime.datetime.utcnow() - datetime.timedelta(days=days))
+    end_time = common.FormatRFC3339(datetime.datetime.utcnow())
+    period = aggregation_minutes * 60
+    instance_filter = self._BuildCpuUsageFilter(instances)
+
+    responses = common.ExecuteRequest(gcm_timeseries_client, 'list', {
+        'name': 'projects/{0:s}'.format(self.project_id),
+        'filter': instance_filter,
+        'interval_startTime': start_time,
+        'interval_endTime': end_time,
+        'view': 'FULL',
+        'aggregation_perSeriesAligner': 'ALIGN_MEAN',
+        'aggregation_alignmentPeriod': '{0:d}s'.format(period),
+    })
+
+    cpu_usage_instances = {}
+
+    for response in responses:
+      time_series = response.get('timeSeries', [])
+      for ts in time_series:
+        instance_name = ts['metric']['labels']['instance_name']
+        instance_id = ts['resource']['labels']['instance_id']
+        instance_key = instance_name + '_' + instance_id # type: str
+        points = ts['points']
+        parsed_points = []
+        for point in points:
+          timestamp = point['interval']['startTime'] # type: str
+          cpu_usage = point['value']['doubleValue'] # type: float
+          parsed_points.append((timestamp, cpu_usage))
+        cpu_usage_instances[instance_key] = parsed_points
+
+    return cpu_usage_instances


Test patch:
diff --git a/tests/providers/gcp/gcp_mocks.py b/tests/providers/gcp/gcp_mocks.py
index f702f0a..13fefc6 100644
--- a/tests/providers/gcp/gcp_mocks.py
+++ b/tests/providers/gcp/gcp_mocks.py
@@ -497,6 +497,62 @@ MOCK_GCSQL_INSTANCES = {
     ]
 }
 
+MOCK_GCM_METRICS_CPU_POINTS = [
+    {
+        'interval': {
+            'startTime': '2021-01-01T00:00:00.000000Z',
+            'endTime': '2021-01-01T00:00:00.000000Z'
+        },
+        'value': {
+            'doubleValue': 0.100000000000000000
+        }
+    }
+] * 24 * 7
+
+MOCK_GCM_METRICS_CPU = {
+    'timeSeries': [
+        {
+            'metric': {
+                'labels': {
+                    'instance_name': 'instance-a'
+                },
+                'type': 'compute.googleapis.com/instance/cpu/utilization'
+            },
+            'resource': {
+                'type': 'gce_instance',
+                'labels': {
+                    'instance_id': '0000000000000000000',
+                    'zone': 'us-central1-a',
+                    'project_id': 'fake-project'
+                }
+            },
+            'metricKind': 'GAUGE',
+            'valueType': 'DOUBLE',
+            'points': MOCK_GCM_METRICS_CPU_POINTS
+        },
+        {
+            'metric': {
+                'labels': {
+                    'instance_name': 'instance-b'
+                },
+                'type': 'compute.googleapis.com/instance/cpu/utilization'
+            },
+            'resource': {
+                'type': 'gce_instance',
+                'labels': {
+                    'instance_id': '0000000000000000000',
+                    'zone': 'us-central1-a',
+                    'project_id': 'fake-project'
+                }
+            },
+            'metricKind': 'GAUGE',
+            'valueType': 'DOUBLE',
+            'points': MOCK_GCM_METRICS_CPU_POINTS
+        }
+    ],
+    'unit': '10^2.%'
+}
+
 # See: https://cloud.google.com/compute/docs/reference/rest/v1/disks
 REGEX_DISK_NAME = re.compile('^(?=.{1,63}$)[a-z]([-a-z0-9]*[a-z0-9])?$')
 STARTUP_SCRIPT = 'scripts/startup.sh'
diff --git a/tests/providers/gcp/internal/test_monitoring.py b/tests/providers/gcp/internal/test_monitoring.py
index 5079c83..52005c6 100644
--- a/tests/providers/gcp/internal/test_monitoring.py
+++ b/tests/providers/gcp/internal/test_monitoring.py
@@ -41,3 +41,27 @@ class GoogleCloudMonitoringTest(unittest.TestCase):
     self.assertIn('logging.googleapis.com', active_services)
     self.assertEqual(active_services['logging.googleapis.com'],
                      gcp_mocks.MOCK_LOGGING_METRIC)
+
+  @typing.no_type_check
+  def testBuildCpuUsageFilter(self):
+    """Validates the query filter builder functionality"""
+    # pylint: disable=protected-access
+    instances_filter = gcp_mocks.FAKE_MONITORING._BuildCpuUsageFilter(
+        ['instance-a', 'instance-b'])
+    self.assertEqual(
+        instances_filter, ('metric.type = "compute.googleapis.com/instance/'
+        'cpu/utilization" AND (metric.label.instance_name = "instance-a" OR '
+        'metric.label.instance_name = "instance-b")'))
+
+  @typing.no_type_check
+  @mock.patch('libcloudforensics.providers.gcp.internal.monitoring.GoogleCloudMonitoring.GcmApi')
+  def testGetCpuUsage(self, mock_gcm_api):
+    """Validates the parsing of CPU usage metrics."""
+    services = mock_gcm_api.return_value.projects.return_value.timeSeries.return_value.list
+    services.return_value.execute.return_value = gcp_mocks.MOCK_GCM_METRICS_CPU
+    cpu_usage = gcp_mocks.FAKE_MONITORING.GetCpuUsage()
+    self.assertEqual(len(cpu_usage), 2)
+    self.assertIn('instance-a_0000000000000000000', cpu_usage)
+    self.assertEqual(
+        cpu_usage['instance-a_0000000000000000000'],
+        [('2021-01-01T00:00:00.000000Z', 0.100000000000000000)] * 24*7)