Skip to content

Commit 987a049

Browse files
Fryyyyygoogle-labs-jules[bot]ramo-j
authored
Allow zones to be provided for disks and instances (#541)
* Update deps * Update python versions * Create new ListSnapshots method * feat(gcp): add dest_project and dest_zone to CreateDiskFromSnapshot Updated `CreateDiskFromSnapshot` in the GCP compute provider to allow users to specify an optional `dest_project` and `dest_zone` when creating a disk from a snapshot. The method defaults to the instance's `project_id` and `default_zone` if these are not provided. Included unit tests to verify behavior. * feat(gcp): add dest_project and dest_zone to CreateDiskFromSnapshot Updated `CreateDiskFromSnapshot` in the GCP compute provider to allow users to specify an optional `dest_project` and `dest_zone` when creating a disk from a snapshot. The method defaults to the instance's `project_id` and `default_zone` if these are not provided. Included unit tests to verify behavior. Fixes BlockOperation parameter passing. * Pass through packages argument to GetOrCreateAnalysisVm * Add zones to disks and instances * Update tests * Fix test that apparently only fails in Github runner? * Lint * More lint * One more * Update libcloudforensics/providers/gcp/internal/compute.py Co-authored-by: Ramo <ramo_j@protonmail.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Ramo <ramo_j@protonmail.com>
1 parent 4647937 commit 987a049

6 files changed

Lines changed: 289 additions & 103 deletions

File tree

libcloudforensics/logging_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ def format(self, record: logging.LogRecord) -> str:
9999
if loglevel_color:
100100
message = loglevel_color + message + RESET_SEQ
101101
record.msg = message
102+
record.args = ()
102103
return super().format(record)
103104

104105

libcloudforensics/providers/gcp/forensics.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ def CreateDiskCopy(
4343
zone: str,
4444
instance_name: Optional[str] = None,
4545
disk_name: Optional[str] = None,
46-
disk_type: Optional[str] = None) -> 'compute.GoogleComputeDisk':
46+
disk_type: Optional[str] = None,
47+
src_zone: Optional[str] = None) -> 'compute.GoogleComputeDisk':
4748
"""Creates a copy of a Google Compute Disk.
4849
4950
Args:
@@ -56,6 +57,8 @@ def CreateDiskCopy(
5657
disk_type (str): Optional. URL of the disk type resource describing
5758
which disk type to use to create the disk. The default behavior is to
5859
use the same disk type as the source disk.
60+
src_zone (str): Optional. Zone where the source disk is located. If None,
61+
the default zone will be used.
5962
6063
Returns:
6164
GoogleComputeDisk: A Google Compute Disk object.
@@ -73,9 +76,9 @@ def CreateDiskCopy(
7376

7477
try:
7578
if disk_name:
76-
disk_to_copy = src_project.compute.GetDisk(disk_name)
79+
disk_to_copy = src_project.compute.GetDisk(disk_name, zone=src_zone)
7780
elif instance_name:
78-
instance = src_project.compute.GetInstance(instance_name)
81+
instance = src_project.compute.GetInstance(instance_name, zone=src_zone)
7982
disk_to_copy = instance.GetBootDisk()
8083
else:
8184
raise ValueError(
@@ -472,15 +475,15 @@ def VMRemoveServiceAccount(
472475
# Get the initial powered state of the instance
473476
initial_state = instance.GetPowerState()
474477

475-
if not initial_state in valid_starting_states:
478+
if initial_state not in valid_starting_states:
476479
logger.error(
477480
'Instance "{0:s}" is currently {1:s} which is an invalid '
478481
'state for this operation'.format(instance_name, initial_state))
479482
return False
480483

481484
try:
482485
# Stop the instance if it is not already (or on the way)....
483-
if not initial_state in ('TERMINATED', 'STOPPING'):
486+
if initial_state not in ('TERMINATED', 'STOPPING'):
484487
instance.Stop()
485488

486489
# Remove the service account

libcloudforensics/providers/gcp/internal/compute.py

Lines changed: 137 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import subprocess
2121
import time
2222
from collections import defaultdict
23-
from typing import Any, Dict, List, Optional, Tuple, TypeVar, TYPE_CHECKING, Union
23+
from typing import Any, cast, Dict, List, Optional, Tuple, TypeVar, TYPE_CHECKING, Union
2424

2525
from googleapiclient.errors import HttpError
2626

@@ -143,6 +143,73 @@ def _FindResourceByName(
143143

144144
return matches.pop()
145145

146+
def _GetResourceFromComputeApi(
147+
self,
148+
resource_type: str,
149+
resource_name: str,
150+
zone: Optional[str] = None,
151+
region: Optional[str] = None) -> Optional[Dict[str, Any]]:
152+
"""Helper to get a specific resource from the GCE API.
153+
154+
Args:
155+
resource_type: The GCE resource type (e.g., 'instance' or 'disk').
156+
resource_name: The resource name or ID.
157+
zone: Optional zone to restrict the search.
158+
region: Optional region to restrict the search.
159+
160+
Returns:
161+
Optional[Dict[str, Any]]: The resource metadata if found, None otherwise.
162+
"""
163+
client = getattr(self.GceApi(), resource_type)()
164+
if zone or region:
165+
param_name = (resource_type[:-1] if resource_type.endswith('s')
166+
else resource_type)
167+
if resource_type == 'instanceGroupManagers':
168+
param_name = 'instanceGroupManager'
169+
elif resource_type == 'regionDisks':
170+
param_name = 'disk'
171+
172+
kwargs = {'project': self.project_id, param_name: resource_name}
173+
if zone:
174+
kwargs['zone'] = zone
175+
if region:
176+
kwargs['region'] = region
177+
178+
try:
179+
return cast(Dict[str, Any], client.get(**kwargs).execute())
180+
except HttpError as e:
181+
if e.resp.status == 404:
182+
return None
183+
raise
184+
else:
185+
# Use aggregatedList with filter to avoid listing all resources
186+
filter_str = f'name = "{resource_name}"'
187+
if re.match(RESOURCE_ID_REGEX, resource_name):
188+
filter_str = f'id = "{resource_name}"'
189+
190+
# Regional resources might not support aggregatedList on their own
191+
# client. For example, regionDisks doesn't have aggregatedList.
192+
# We use disks.aggregatedList instead as it returns both zonal
193+
# and regional disks.
194+
if resource_type == 'regionDisks':
195+
client = self.GceApi().disks() # pylint: disable=no-member
196+
res_type_in_resp = 'disks'
197+
else:
198+
res_type_in_resp = resource_type
199+
200+
responses = common.ExecuteRequest(
201+
client,
202+
'aggregatedList', {
203+
'project': self.project_id, 'filter': filter_str
204+
})
205+
for response in responses:
206+
for location in response.get('items', {}):
207+
items = response['items'][location].get(res_type_in_resp, [])
208+
if items:
209+
return cast(Dict[str, Any], items[0])
210+
return None
211+
212+
146213
def Instances(self,
147214
refresh: bool = True) -> Dict[str, 'GoogleComputeInstance']:
148215
"""Get all instances in the project.
@@ -237,18 +304,28 @@ def ListInstances(self) -> Dict[str, 'GoogleComputeInstance']:
237304

238305
return instances
239306

240-
def ListSnapshots(self, filter_string: str | None = None) -> Dict[str, Any]:
307+
def ListSnapshots(self,
308+
filter_string: str | None = None,
309+
zone: str | None = None) -> Dict[str, Any]:
241310
"""List snapshots in project.
242311
243312
Args:
244313
filter_string: Filter for the snapshot query.
314+
zone: Optional zone to filter snapshots by.
245315
246316
Returns:
247317
Dict[str, Any]: Dictionary mapping snapshot IDs (str)
248318
to their respective snapshot description.
249319
See:
250320
https://docs.cloud.google.com/compute/docs/reference/rest/v1/snapshots/list
251321
"""
322+
if zone:
323+
zone_filter = f'sourceDisk ~ ".*/zones/{zone}/disks/.*"'
324+
if filter_string:
325+
filter_string = f'({filter_string}) ({zone_filter})'
326+
else:
327+
filter_string = zone_filter
328+
252329
snapshots = {}
253330
gce_snapshot_client = self.GceApi().snapshots() # pylint: disable=no-member
254331
responses = common.ExecuteRequest(
@@ -450,16 +527,25 @@ def GetRegionDisk(
450527
Raises:
451528
ResourceNotFoundError: When the specified disk cannot be found in project.
452529
"""
453-
disks = self.RegionDisks()
454-
if re.match(RESOURCE_ID_REGEX, disk_name):
455-
disk = disks.get(disk_name)
456-
else:
457-
disk = self._FindResourceByName(disks, disk_name, region=region)
458-
if not disk:
530+
disk_dict = self._GetResourceFromComputeApi(
531+
'regionDisks', disk_name, region=region)
532+
533+
if not disk_dict:
459534
raise errors.ResourceNotFoundError(
460535
f'Disk {disk_name} was not found in project '
461536
f'{self.project_id}', __name__)
462-
return disk
537+
538+
try:
539+
_, disk_region = disk_dict['region'].rsplit('/', 1)
540+
except ValueError as exception:
541+
raise errors.ResourceNotFoundError(
542+
f'Region not found for disk {disk_name} in project '
543+
f'{self.project_id}', __name__) from exception
544+
545+
return GoogleRegionComputeDisk(
546+
self.project_id, disk_region, disk_dict['name'],
547+
resource_id=disk_dict['id'],
548+
labels=disk_dict.get('labels'))
463549

464550
def GetInstance(
465551
self,
@@ -478,19 +564,28 @@ def GetInstance(
478564
Raises:
479565
ResourceNotFoundError: If instance does not exist.
480566
"""
567+
instance_dict = self._GetResourceFromComputeApi(
568+
'instances', instance_name, zone=zone)
481569

482-
instances = self.Instances()
483-
484-
if re.match(RESOURCE_ID_REGEX, instance_name):
485-
instance = instances.get(instance_name)
486-
else:
487-
instance = self._FindResourceByName(instances, instance_name, zone)
488-
489-
if not instance:
570+
if not instance_dict:
490571
raise errors.ResourceNotFoundError(
491572
f'Instance {instance_name} was not found in project '
492573
f'{self.project_id}', __name__)
493-
return instance
574+
575+
try:
576+
_, instance_zone = instance_dict['zone'].rsplit('/', 1)
577+
except ValueError as exception:
578+
raise errors.ResourceNotFoundError(
579+
f'Zone not found for instance {instance_name} in project '
580+
f'{self.project_id}', __name__) from exception
581+
582+
return GoogleComputeInstance(
583+
self.project_id,
584+
instance_zone,
585+
instance_dict['name'],
586+
resource_id=instance_dict['id'],
587+
labels=instance_dict.get('labels'),
588+
deletion_protection=instance_dict.get('deletionProtection', False))
494589

495590
def GetDisk(
496591
self,
@@ -509,16 +604,26 @@ def GetDisk(
509604
ResourceNotFoundError: When the specified disk cannot be found in project.
510605
"""
511606

512-
disks = self.Disks()
513-
if re.match(RESOURCE_ID_REGEX, disk_name):
514-
disk = disks.get(disk_name)
515-
else:
516-
disk = self._FindResourceByName(disks, disk_name, zone)
517-
if not disk:
607+
disk_dict = self._GetResourceFromComputeApi('disks', disk_name, zone=zone)
608+
609+
if not disk_dict:
518610
raise errors.ResourceNotFoundError(
519611
f'Disk {disk_name} was not found in project '
520612
f'{self.project_id}', __name__)
521-
return disk
613+
614+
try:
615+
_, disk_zone = disk_dict['zone'].rsplit('/', 1)
616+
except ValueError as exception:
617+
raise errors.ResourceNotFoundError(
618+
f'Zone not found for disk {disk_name} in project '
619+
f'{self.project_id}', __name__) from exception
620+
621+
return GoogleComputeDisk(
622+
self.project_id,
623+
disk_zone,
624+
disk_dict['name'],
625+
resource_id=disk_dict['id'],
626+
labels=disk_dict.get('labels'))
522627

523628
def CreateDiskFromSnapshot(
524629
self,
@@ -1431,7 +1536,8 @@ def GetBootDisk(self) -> 'GoogleComputeDisk':
14311536
self.name),
14321537
__name__)
14331538
disk_name = disk['source'].split('/')[-1]
1434-
return GoogleCloudCompute(self.project_id).GetDisk(disk_name=disk_name)
1539+
return GoogleCloudCompute(self.project_id).GetDisk(
1540+
disk_name=disk_name, zone=self.zone)
14351541
raise errors.ResourceNotFoundError(
14361542
'Boot disk not found for instance {0:s}'.format(self.name), __name__)
14371543

@@ -1451,7 +1557,8 @@ def GetDisk(self, disk_name: str) -> 'GoogleComputeDisk':
14511557

14521558
for disk in self.GetValue('disks'):
14531559
if disk.get('source', '').split('/')[-1] == disk_name:
1454-
return GoogleCloudCompute(self.project_id).GetDisk(disk_name=disk_name)
1560+
return GoogleCloudCompute(self.project_id).GetDisk(
1561+
disk_name=disk_name, zone=self.zone)
14551562
raise errors.ResourceNotFoundError(
14561563
'Disk {0:s} was not found in instance {1:s}'.format(
14571564
disk_name, self.name),
@@ -1640,7 +1747,8 @@ def Delete(
16401747

16411748
for disk_name in disks_to_delete:
16421749
try:
1643-
disk = GoogleCloudCompute(self.project_id).GetDisk(disk_name=disk_name)
1750+
disk = GoogleCloudCompute(self.project_id).GetDisk(
1751+
disk_name=disk_name, zone=self.zone)
16441752
disk.Delete()
16451753
except (errors.ResourceDeletionError, errors.ResourceNotFoundError):
16461754
logger.info(
@@ -2333,7 +2441,7 @@ def ExportImage(
23332441
build_args.append('-format={0:s}'.format(image_format))
23342442
build_body = {
23352443
'timeout': '86400s',
2336-
'steps': [{
2444+
'steps': [{
23372445
'args': build_args,
23382446
'name': 'gcr.io/compute-image-tools/gce_vm_image_export:release',
23392447
'env': []

0 commit comments

Comments
 (0)