Skip to content

Commit a520b8e

Browse files
authored
Add support for image formats when exporting to GCS (#532)
* Add support for image formats when exporting to GCS Also, command line / reference example to invoke the exporting of a disk image to GCS. * linter appeasement * linter appeasement * linter appeasement * Added a test * linter appeasement * linter appeasement
1 parent 975a6a5 commit a520b8e

File tree

12 files changed

+163
-60
lines changed

12 files changed

+163
-60
lines changed

.pylintrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,8 @@ max-statements=50
370370
# Minimum number of public methods for a class (see R0903).
371371
min-public-methods=2
372372

373+
max-positional-arguments=12
374+
373375

374376
[CLASSES]
375377

libcloudforensics/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@
1616

1717
# Since moving to poetry, ensure the version number tracked in pyproject.toml is
1818
# also updated
19-
__version__ = '20250331'
19+
__version__ = '20250721'

libcloudforensics/providers/aws/forensics.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,6 @@ def CreateVolumeCopy(zone: str,
113113
account information could not be retrieved.
114114
"""
115115

116-
if not instance_id and not volume_id:
117-
raise ValueError(
118-
'You must specify at least one of [instance_id, volume_id].')
119116

120117
source_account = account.AWSAccount(zone, aws_profile=src_profile)
121118
destination_account = account.AWSAccount(zone, aws_profile=dst_profile)
@@ -127,6 +124,10 @@ def CreateVolumeCopy(zone: str,
127124
elif instance_id:
128125
instance = source_account.ec2.GetInstanceById(instance_id)
129126
volume_to_copy = instance.GetBootVolume()
127+
else:
128+
raise ValueError(
129+
'You must specify at least one of [instance_id, volume_id].')
130+
130131

131132
if not volume_type:
132133
volume_type = volume_to_copy.GetVolumeType()
@@ -200,8 +201,7 @@ def CreateVolumeCopy(zone: str,
200201

201202
return new_volume
202203

203-
# pylint: disable=too-many-arguments
204-
def StartAnalysisVm(
204+
def StartAnalysisVm( # pylint: disable=too-many-arguments,too-many-positional-arguments
205205
vm_name: str,
206206
default_availability_zone: str,
207207
boot_volume_size: int,

libcloudforensics/providers/aws/internal/ec2.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -372,8 +372,7 @@ def ListImages(
372372

373373
return images['Images']
374374

375-
# pylint: disable=too-many-arguments
376-
def GetOrCreateVm(
375+
def GetOrCreateVm( # pylint: disable=too-many-arguments,too-many-positional-arguments
377376
self,
378377
vm_name: str,
379378
boot_volume_size: int,

libcloudforensics/providers/azure/forensics.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,6 @@ def CreateDiskCopy(
7272
ValueError: If both instance_name and disk_name are missing.
7373
"""
7474

75-
if not instance_name and not disk_name:
76-
raise ValueError(
77-
'You must specify at least one of [instance_name, disk_name].')
78-
7975
src_account = account.AZAccount(
8076
resource_group_name, default_region=region, profile_name=src_profile)
8177
dst_account = account.AZAccount(resource_group_name,
@@ -88,6 +84,10 @@ def CreateDiskCopy(
8884
elif instance_name:
8985
instance = src_account.compute.GetInstance(instance_name)
9086
disk_to_copy = instance.GetBootDisk()
87+
else:
88+
raise ValueError(
89+
'You must specify at least one of [instance_name, disk_name].')
90+
9191
logger.info('Disk copy of {0:s} started...'.format(
9292
disk_to_copy.name))
9393

libcloudforensics/providers/gcp/forensics.py

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,6 @@ def CreateDiskCopy(
6868
ValueError: If both instance_name and disk_name are missing.
6969
"""
7070

71-
if not instance_name and not disk_name:
72-
raise ValueError(
73-
'You must specify at least one of [instance_name, disk_name].')
74-
7571
src_project = gcp_project.GoogleCloudProject(src_proj)
7672
dst_project = gcp_project.GoogleCloudProject(dst_proj, default_zone=zone)
7773

@@ -81,6 +77,9 @@ def CreateDiskCopy(
8177
elif instance_name:
8278
instance = src_project.compute.GetInstance(instance_name)
8379
disk_to_copy = instance.GetBootDisk()
80+
else:
81+
raise ValueError(
82+
'You must specify at least one of [instance_name, disk_name].')
8483

8584
if not disk_type:
8685
disk_type = disk_to_copy.GetDiskType()
@@ -252,6 +251,50 @@ def CreateDiskFromGCSImage(
252251
return result
253252

254253

254+
def CopyDisksToGCS(source_project: str,
255+
source_disk: str,
256+
destination_bucket: str,
257+
destination_directory: str,
258+
image_format: str) -> str:
259+
"""Given a VM, copy the disks to a GCS bucket.
260+
261+
Args:
262+
source_project: The project containing the disk to copy
263+
source_disk: The name of the disk to copy
264+
destination_bucket: The destination bucket to store the disk copy
265+
destination_directory: The directory in the bucket in which to store the
266+
disk image
267+
image_format: The image format to use. Supported formats documented at
268+
https://github.com/GoogleCloudPlatform/compute-image-import/blob/edee48bddbe159100da9ad961131a4beb0f12158/cli_tools/gce_vm_image_export/README.md?plain=1#L3
269+
"""
270+
try:
271+
src_project = gcp_project.GoogleCloudProject(source_project)
272+
disk_to_copy = src_project.compute.GetDisk(source_disk)
273+
copied_image = src_project.compute.CreateImageFromDisk(disk_to_copy)
274+
return copied_image.ExportImage(
275+
gcs_output_folder=f'gs://{destination_bucket}/{destination_directory}',
276+
image_format=image_format,
277+
output_name=disk_to_copy.name)
278+
except (RefreshError, DefaultCredentialsError) as exception:
279+
raise errors.CredentialsConfigurationError(
280+
'Something is wrong with your Application Default Credentials. Try '
281+
'running: $ gcloud auth application-default login: {0!s}'.format(
282+
exception),
283+
__name__) from exception
284+
except HttpError as exception:
285+
if exception.resp.status == 403:
286+
raise errors.CredentialsConfigurationError(
287+
'Make sure you have the appropriate permissions on the project: '
288+
'{0!s}'.format(exception),
289+
__name__) from exception
290+
if exception.resp.status == 404:
291+
raise errors.ResourceNotFoundError(
292+
'GCP resource not found. Maybe a typo in the project / instance / '
293+
'disk name?',
294+
__name__) from exception
295+
raise RuntimeError(exception) from exception
296+
297+
255298
def AddDenyAllFirewallRules(
256299
project_id: str,
257300
network: str,
@@ -669,19 +712,13 @@ def TriageInstance(project_id: str, instance_name: str) -> Dict[str, Any]:
669712

670713
cpu_usage = project.monitoring.GetCpuUsage(
671714
instance_ids=[instance_info['id']], aggregation_minutes=1)
672-
if cpu_usage:
673-
parsed_cpu = cpu_usage[0].get('cpu_usage', [])
715+
parsed_cpu = cpu_usage[0].get('cpu_usage', []) if cpu_usage else None
674716

675717

676718
gce_gpu_usage = project.monitoring.GetInstanceGPUUsage(
677-
instance_ids=[instance_info['id']])
678-
if gce_gpu_usage:
679-
parsed_gce_gpu = gce_gpu_usage
680-
719+
instance_ids=[instance_info['id']])
681720

682721
gke_gpu_usage = project.monitoring.GetNodeAccelUsage()
683-
if gke_gpu_usage:
684-
parsed_gke_gpu = gke_gpu_usage
685722

686723
instance_triage = {
687724
'instance_info': {
@@ -697,25 +734,25 @@ def TriageInstance(project_id: str, instance_name: str) -> Dict[str, Any]:
697734
'data_type': 'service_accounts',
698735
'values': instance_info['serviceAccounts']
699736
},
700-
{
701-
'data_type': 'firewalls',
702-
'values': instance.GetNormalisedFirewalls()
703-
}, {
704-
'data_type': 'cpu_usage', 'values': parsed_cpu
705-
}, {
706-
'data_type': 'gce_gpu_usage', 'values': parsed_gce_gpu
707-
}, {
708-
'data_type': 'gke_gpu_usage', 'values': parsed_gke_gpu
709-
}, {
710-
'data_type':
711-
'ssh_auth',
712-
'values':
713-
CheckInstanceSSHAuth(
714-
project_id, instance_info['name'])
715-
}, {
716-
'data_type': 'active_services',
717-
'values': parsed_services
718-
}]
737+
{
738+
'data_type': 'firewalls',
739+
'values': instance.GetNormalisedFirewalls()
740+
}, {
741+
'data_type': 'cpu_usage', 'values': parsed_cpu
742+
}, {
743+
'data_type': 'gce_gpu_usage', 'values': gce_gpu_usage
744+
}, {
745+
'data_type': 'gke_gpu_usage', 'values': gke_gpu_usage
746+
}, {
747+
'data_type':
748+
'ssh_auth',
749+
'values':
750+
CheckInstanceSSHAuth(
751+
project_id, instance_info['name'])
752+
}, {
753+
'data_type': 'active_services',
754+
'values': parsed_services
755+
}]
719756
}
720757

721758
return instance_triage

libcloudforensics/providers/gcp/internal/compute.py

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ def CreateInstanceFromRequest(
682682
return GoogleComputeInstance(
683683
project_id=self.project_id, zone=compute_zone, name=instance_name)
684684

685-
def CreateInstanceFromArguments( #pylint: disable=too-many-arguments
685+
def CreateInstanceFromArguments( # pylint: disable=too-many-arguments,too-many-positional-arguments
686686
self,
687687
instance_name: str,
688688
machine_type: str,
@@ -1288,6 +1288,7 @@ def ImportImageFromStorage(self,
12881288
'windows-8-x86-byol'
12891289
]
12901290

1291+
img_type = None
12911292
if not bootable:
12921293
img_type = '-data_disk'
12931294
elif not os_name:
@@ -2253,39 +2254,49 @@ def GetOperation(self) -> Dict[str, Any]:
22532254
return response
22542255

22552256
def ExportImage(
2256-
self, gcs_output_folder: str, output_name: Optional[str] = None) -> None:
2257-
"""Export compute image to Google Cloud storage.
2258-
2257+
self,
2258+
gcs_output_folder: str,
2259+
image_format: str,
2260+
output_name: Optional[str]) -> str:
2261+
"""Export compute image to Google Cloud Storage.
2262+
22592263
Exported image is compressed and stored in .tar.gz format.
22602264
22612265
Args:
22622266
gcs_output_folder (str): Folder path of the exported image.
2267+
image_format (str): The image format to use for the export.
22632268
output_name (str): Optional. Name of the output file. Name will be
22642269
appended with .tar.gz. Default is [image_name].tar.gz.
2265-
2270+
Returns:
2271+
str: The full path of the exported image.
22662272
Raises:
22672273
InvalidNameError: If exported image name is invalid.
22682274
"""
2269-
22702275
if output_name:
22712276
if not common.REGEX_DISK_NAME.match(output_name):
22722277
raise errors.InvalidNameError(
22732278
'Exported image name {0:s} does not comply with {1:s}'.format(
22742279
output_name, common.REGEX_DISK_NAME.pattern),
22752280
__name__)
2276-
full_path = '{0:s}.tar.gz'.format(
2277-
os.path.join(gcs_output_folder, output_name))
2281+
full_path = '{0:s}'.format(os.path.join(gcs_output_folder, output_name))
22782282
else:
2279-
full_path = '{0:s}.tar.gz'.format(
2280-
os.path.join(gcs_output_folder, self.name))
2283+
full_path = '{0:s}'.format(os.path.join(gcs_output_folder, self.name))
2284+
if not image_format:
2285+
full_path = '{0:s}.tar.gz'.format(full_path)
2286+
else:
2287+
full_path = '{0:s}.{1:s}'.format(full_path, image_format)
2288+
2289+
build_args = [
2290+
'-source_image={0:s}'.format(self.name),
2291+
'-destination_uri={0:s}'.format(full_path),
2292+
'-client_id=api',
2293+
]
2294+
if image_format:
2295+
build_args.append('-format={0:s}'.format(image_format))
22812296
build_body = {
22822297
'timeout': '86400s',
2283-
'steps': [{
2284-
'args': [
2285-
'-source_image={0:s}'.format(self.name),
2286-
'-destination_uri={0:s}'.format(full_path),
2287-
'-client_id=api',
2288-
],
2298+
'steps': [{
2299+
'args': build_args,
22892300
'name': 'gcr.io/compute-image-tools/gce_vm_image_export:release',
22902301
'env': []
22912302
}],
@@ -2295,6 +2306,7 @@ def ExportImage(
22952306
response = cloud_build.CreateBuild(build_body)
22962307
cloud_build.BlockOperation(response)
22972308
logger.info('Image {0:s} exported to {1:s}.'.format(self.name, full_path))
2309+
return full_path
22982310

22992311
def Delete(self) -> None:
23002312
"""Delete Compute Disk Image from a project."""

libcloudforensics/providers/gcp/internal/monitoring.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ def GetInstanceGPUUsage(
333333
for response in responses:
334334
time_series = response.get('timeSeries', [])
335335
for ts in time_series:
336+
gpu_name = 'None'
336337
if ts['metric'].get('labels', None):
337338
gpu_name = "{0:s} ({1:s})".format(
338339
ts['metric']['labels']['model'],

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "libcloudforensics"
3-
version = "20250331"
3+
version = "20250721"
44
description = "libcloudforensics is a set of tools to help acquire forensic evidence from Cloud platforms."
55
authors = ["cloud-forensics-utils development team <[email protected]>"]
66
license = "Apache-2.0"

tests/providers/gcp/test_forensics.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,28 @@ def testCheckInstanceSSHAuth(self, mock_subprocess, mock_project):
153153
'fake_project' , 'fake_instance')
154154
self.assertListEqual(
155155
ssh_auth, ['publickey', 'password', 'keyboard-interactive'])
156+
157+
@mock.patch('libcloudforensics.providers.gcp.internal.project.GoogleCloudProject')
158+
def testCopyDisksToGCS(self, mock_project: mock.MagicMock) -> None:
159+
"""Tests copying a disk to GCS storage."""
160+
161+
dest_bucket_name = gcp_mocks.MOCK_GCS_BUCKETS['items'][0].get('name') # type: ignore
162+
163+
forensics.CopyDisksToGCS(gcp_mocks.FAKE_SOURCE_PROJECT.project_id,
164+
gcp_mocks.FAKE_DISK.name,
165+
dest_bucket_name,
166+
'/path/to/directory/',
167+
'qcow2')
168+
169+
mock_project.assert_called_once_with(gcp_mocks.FAKE_SOURCE_PROJECT.project_id)
170+
mock_project.return_value.compute.GetDisk.assert_called_once_with(gcp_mocks.FAKE_DISK.name)
171+
172+
mock_disk_obj = mock_project.return_value.compute.GetDisk.return_value
173+
mock_project.return_value.compute.CreateImageFromDisk.assert_called_once_with(mock_disk_obj)
174+
175+
mock_image_obj = mock_project.return_value.compute.CreateImageFromDisk.return_value
176+
mock_image_obj.ExportImage.assert_called_once_with(
177+
gcs_output_folder=f'gs://{dest_bucket_name}/{"/path/to/directory/"}',
178+
image_format='qcow2',
179+
output_name=mock_disk_obj.name)
180+

0 commit comments

Comments
 (0)