Repository: google/cloud-forensics-utils

Issue:
Refactor AWS internal module
Move methods like ListImages, ListVolumes, etc. to `ec2.py` to match closer the organization that we have under GCP. Make use of `@property` decorator in `account.py`

Discussion:
codecov-commenter: # [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=h1) Report
> Merging [#225](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=desc) into [master](https://codecov.io/gh/google/cloud-forensics-utils/commit/e4448c60002eee03c344ec2803276ff5d91936be&el=desc) will **increase** coverage by `3.25%`.
> The diff coverage is `73.21%`.

[![Impacted file tree graph](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/graphs/tree.svg?width=650&height=150&src=pr&token=zZkPno3ah0)](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #225      +/-   ##
==========================================
+ Coverage   65.39%   68.64%   +3.25%     
==========================================
  Files          14       23       +9     
  Lines         968     1735     +767     
==========================================
+ Hits          633     1191     +558     
- Misses        335      544     +209     
```

| Flag | Coverage Δ | |
|---|---|---|
| #nosetests | `68.64% <73.21%> (+3.25%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [libcloudforensics/providers/aws/internal/kms.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2F3cy9pbnRlcm5hbC9rbXMucHk=) | `38.23% <38.23%> (ø)` | |
| [libcloudforensics/providers/aws/forensics.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2F3cy9mb3JlbnNpY3MucHk=) | `55.71% <48.83%> (-14.06%)` | :arrow_down: |
| [libcloudforensics/providers/gcp/forensics.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2djcC9mb3JlbnNpY3MucHk=) | `53.62% <48.83%> (-11.38%)` | :arrow_down: |
| [...ibcloudforensics/providers/gcp/internal/compute.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2djcC9pbnRlcm5hbC9jb21wdXRlLnB5) | `56.78% <52.38%> (-5.58%)` | :arrow_down: |
| [...cs/providers/gcp/internal/compute\_base\_resource.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2djcC9pbnRlcm5hbC9jb21wdXRlX2Jhc2VfcmVzb3VyY2UucHk=) | `37.33% <52.94%> (+0.82%)` | :arrow_up: |
| [libcloudforensics/providers/aws/internal/log.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2F3cy9pbnRlcm5hbC9sb2cucHk=) | `73.07% <55.55%> (-4.20%)` | :arrow_down: |
| [...cloudforensics/providers/azure/internal/compute.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2F6dXJlL2ludGVybmFsL2NvbXB1dGUucHk=) | `55.68% <55.68%> (ø)` | |
| [...ibcloudforensics/providers/gcp/internal/project.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2djcC9pbnRlcm5hbC9wcm9qZWN0LnB5) | `61.90% <66.66%> (-3.81%)` | :arrow_down: |
| [...bcloudforensics/providers/azure/internal/common.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2F6dXJlL2ludGVybmFsL2NvbW1vbi5weQ==) | `72.05% <72.05%> (ø)` | |
| [libcloudforensics/providers/gcp/internal/gke.py](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree#diff-bGliY2xvdWRmb3JlbnNpY3MvcHJvdmlkZXJzL2djcC9pbnRlcm5hbC9na2UucHk=) | `73.68% <73.68%> (ø)` | |
| ... and [23 more](https://codecov.io/gh/google/cloud-forensics-utils/pull/225/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=footer). Last update [cc650e1...09fa619](https://codecov.io/gh/google/cloud-forensics-utils/pull/225?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

tomchop: Assigning @Fryyyyy as second reviewer for this.
giovannt0: Thanks @tomchop , @Fryyyyy, PTAL

Patch:
diff --git a/libcloudforensics/providers/aws/forensics.py b/libcloudforensics/providers/aws/forensics.py
index 1f80d04..c2fa462 100644
--- a/libcloudforensics/providers/aws/forensics.py
+++ b/libcloudforensics/providers/aws/forensics.py
@@ -101,7 +101,8 @@ def CreateVolumeCopy(zone: str,
   Raises:
     RuntimeError: If there are errors copying the volume, or errors during
         KMS key creation/sharing if the target volume is encrypted.
-    ValueError: If both instance_id and volume_id are missing.
+    ValueError: If both instance_id and volume_id are missing, or if AWS
+        account information could not be retrieved.
   """
 
   if not instance_id and not volume_id:
@@ -114,9 +115,9 @@ def CreateVolumeCopy(zone: str,
 
   try:
     if volume_id:
-      volume_to_copy = source_account.GetVolumeById(volume_id)
+      volume_to_copy = source_account.ebs.GetVolumeById(volume_id)
     elif instance_id:
-      instance = source_account.GetInstanceById(instance_id)
+      instance = source_account.ec2.GetInstanceById(instance_id)
       volume_to_copy = instance.GetBootVolume()
 
     if not volume_type:
@@ -127,9 +128,15 @@ def CreateVolumeCopy(zone: str,
     snapshot = volume_to_copy.Snapshot()
     logger.info('Created snapshot: {0:s}'.format(snapshot.snapshot_id))
 
-    source_account_id = source_account.GetAccountInformation('Account')
-    destination_account_id = destination_account.GetAccountInformation(
+    source_account_id = source_account.ebs.GetAccountInformation().get(
         'Account')
+    destination_account_id = destination_account.ebs.GetAccountInformation(
+        ).get('Account')
+
+    if not (source_account_id and destination_account_id):
+      raise ValueError(
+          'Could not retrieve AWS account ID: source {0!s}, dest: {1!s}'.format(
+              source_account_id, destination_account_id))
 
     if source_account_id != destination_account_id:
       logger.info('External account detected: source account ID is {0:s} and '
@@ -140,8 +147,8 @@ def CreateVolumeCopy(zone: str,
             'Encrypted volume detected, generating one-time use CMK key')
         # Generate one-time use KMS key that will be shared with the
         # destination account.
-        kms_key_id = source_account.CreateKMSKey()
-        source_account.ShareKMSKeyWithAWSAccount(
+        kms_key_id = source_account.kms.CreateKMSKey()
+        source_account.kms.ShareKMSKeyWithAWSAccount(
             kms_key_id, destination_account_id)
         # Create a copy of the initial snapshot and encrypts it with the
         # shared key
@@ -158,13 +165,13 @@ def CreateVolumeCopy(zone: str,
       snapshot = snapshot.Copy(delete=True, deletion_account=source_account)
 
     if tags and tags.get('Name'):
-      new_volume = destination_account.CreateVolumeFromSnapshot(
+      new_volume = destination_account.ebs.CreateVolumeFromSnapshot(
           snapshot,
           volume_type=volume_type,
           volume_name=tags['Name'],
           tags=tags)
     else:
-      new_volume = destination_account.CreateVolumeFromSnapshot(
+      new_volume = destination_account.ebs.CreateVolumeFromSnapshot(
           snapshot,
           volume_type=volume_type,
           volume_name_prefix='evidence',
@@ -176,7 +183,7 @@ def CreateVolumeCopy(zone: str,
 
     snapshot.Delete()
     # Delete the one-time use KMS key, if one was generated
-    source_account.DeleteKMSKey(kms_key_id)
+    source_account.kms.DeleteKMSKey(kms_key_id)
     logger.info('Done')
   except RuntimeError as exception:
     error_msg = 'Copying volume {0:s}: {1!s}'.format(
@@ -249,7 +256,7 @@ def StartAnalysisVm(
   if not ami:
     logger.info('No AMI provided, fetching one for Ubuntu 18.04')
     qfilter = [{'Name': 'name', 'Values': [UBUNTU_1804_FILTER]}]
-    ami_list = aws_account.ListImages(qfilter)
+    ami_list = aws_account.ec2.ListImages(qfilter)
     # We should only get 1 AMI image back, if we get multiple we
     # have no way of knowing which one to use.
     if len(ami_list) > 1:
@@ -260,7 +267,7 @@ def StartAnalysisVm(
   assert ami  # Mypy: assert that ami is not None
 
   logger.info('Starting analysis VM {0:s}'.format(vm_name))
-  analysis_vm, created = aws_account.GetOrCreateAnalysisVm(
+  analysis_vm, created = aws_account.ec2.GetOrCreateAnalysisVm(
       vm_name,
       boot_volume_size,
       ami,
@@ -272,6 +279,7 @@ def StartAnalysisVm(
   for volume_id, device_name in (attach_volumes or []):
     logger.info('Attaching volume {0:s} to device {1:s}'.format(
         volume_id, device_name))
-    analysis_vm.AttachVolume(aws_account.GetVolumeById(volume_id), device_name)
+    analysis_vm.AttachVolume(
+        aws_account.ebs.GetVolumeById(volume_id), device_name)
   logger.info('VM ready.')
   return analysis_vm, created
diff --git a/libcloudforensics/providers/aws/internal/account.py b/libcloudforensics/providers/aws/internal/account.py
index 595a695..cbd4461 100644
--- a/libcloudforensics/providers/aws/internal/account.py
+++ b/libcloudforensics/providers/aws/internal/account.py
@@ -18,18 +18,13 @@ Library to make forensic images of Amazon Elastic Block Store devices and create
 analysis virtual machine to be used in incident response.
 """
 
-import binascii
-import json
-import os
-from typing import Dict, List, Tuple, Optional, Any
-
+from typing import Optional, TYPE_CHECKING
 import boto3
-import botocore
 
-from libcloudforensics.providers.aws.internal import ec2
-from libcloudforensics.providers.aws.internal import ebs
-from libcloudforensics.providers.aws.internal import common
-from libcloudforensics.scripts import utils
+from libcloudforensics.providers.aws.internal import ec2, ebs, kms
+
+if TYPE_CHECKING:
+  import botocore
 
 
 class AWSAccount:
@@ -38,8 +33,13 @@ class AWSAccount:
   Attributes:
     default_availability_zone (str): Default zone within the region to create
         new resources in.
+    default_region (str): The default region to create new resources in.
     aws_profile (str): The AWS profile defined in the AWS
         credentials file to use.
+    session (boto3.session.Session): A boto3 session object.
+    _ec2 (AWSEC2): An AWS EC2 client object.
+    _ebs (AWSEBS): An AWS EBS client object.
+    _kms (AWSKMS): An AWS KMS client object.
   """
 
   def __init__(self,
@@ -80,6 +80,49 @@ class AWSAccount:
     else:
       self.session = boto3.session.Session(profile_name=self.aws_profile)
 
+    self._ec2 = None  # type: Optional[ec2.EC2]
+    self._ebs = None  # type: Optional[ebs.EBS]
+    self._kms = None  # type: Optional[kms.KMS]
+
+  @property
+  def ec2(self) -> ec2.EC2:
+    """Get an AWS ec2 object for the account.
+
+    Returns:
+      AWSEC2: Object that represents AWS EC2 services.
+    """
+
+    if self._ec2:
+      return self._ec2
+    self._ec2 = ec2.EC2(self)
+    return self._ec2
+
+  @property
+  def ebs(self) -> ebs.EBS:
+    """Get an AWS ebs object for the account.
+
+    Returns:
+      AWSEBS: Object that represents AWS EBS services.
+    """
+
+    if self._ebs:
+      return self._ebs
+    self._ebs = ebs.EBS(self)
+    return self._ebs
+
+  @property
+  def kms(self) -> kms.KMS:
+    """Get an AWS kms object for the account.
+
+    Returns:
+      AWSKMS: Object that represents AWS KMS services.
+    """
+
+    if self._kms:
+      return self._kms
+    self._kms = kms.KMS(self)
+    return self._kms
+
   def ClientApi(self,
                 service: str,
                 region: Optional[str] = None) -> 'botocore.client.EC2':  # pylint: disable=no-member
@@ -122,736 +165,3 @@ class AWSAccount:
       return self.session.resource(service_name=service, region_name=region)
     return self.session.resource(
         service_name=service, region_name=self.default_region)
-
-  def ListInstances(
-      self,
-      region: Optional[str] = None,
-      filters: Optional[List[Dict[str, Any]]] = None,
-      show_terminated: bool = False) -> Dict[str, ec2.AWSInstance]:
-    """List instances of an AWS account.
-
-    Example usage:
-      ListInstances(region='us-east-1', filters=[
-          {'Name':'instance-id', 'Values':['some-instance-id']}])
-
-    Args:
-      region (str): Optional. The region from which to list instances.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-      filters (List[Dict]): Optional. Filters for the query. Filters are
-          given as a list of dictionaries, e.g.: {'Name': 'someFilter',
-          'Values': ['value1', 'value2']}.
-      show_terminated (bool): Optional. Include terminated instances in the
-          list.
-
-    Returns:
-      Dict[str, AWInstance]: Dictionary mapping instance IDs (str) to their
-          respective AWSInstance object.
-
-    Raises:
-      RuntimeError: If instances can't be listed.
-    """
-
-    if not filters:
-      filters = []
-
-    instances = {}
-    client = self.ClientApi(common.EC2_SERVICE, region=region)
-    responses = common.ExecuteRequest(
-        client, 'describe_instances', {'Filters': filters})
-
-    for response in responses:
-      for reservation in response['Reservations']:
-        for instance in reservation['Instances']:
-          # If reservation['Instances'] contains any entry, then the
-          # instance's state is expected to be present in the API's response.
-          if instance['State']['Name'] == 'terminated' and not show_terminated:
-            continue
-
-          zone = instance['Placement']['AvailabilityZone']
-          instance_id = instance['InstanceId']
-          aws_instance = ec2.AWSInstance(
-              self, instance_id, zone[:-1], zone)
-
-          for tag in instance.get('Tags', []):
-            if tag.get('Key') == 'Name':
-              aws_instance.name = tag.get('Value')
-              break
-
-          instances[instance_id] = aws_instance
-    return instances
-
-  def ListVolumes(
-      self,
-      region: Optional[str] = None,
-      # pylint: disable=line-too-long
-      filters: Optional[List[Dict[str, Any]]] = None) -> Dict[str, ebs.AWSVolume]:
-      # pylint: enable=line-too-long
-    """List volumes of an AWS account.
-
-    Example usage:
-      # List volumes attached to the instance 'some-instance-id'
-      ListVolumes(filters=[
-          {'Name':'attachment.instance-id', 'Values':['some-instance-id']}])
-
-    Args:
-      region (str): Optional. The region from which to list the volumes.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-      filters (List[Dict]): Optional. Filters for the query. Filters are
-          given as a list of dictionaries, e.g.: {'Name': 'someFilter',
-          'Values': ['value1', 'value2']}.
-
-    Returns:
-      Dict[str, AWSVolume]: Dictionary mapping volume IDs (str) to their
-          respective AWSVolume object.
-
-    Raises:
-      RuntimeError: If volumes can't be listed.
-    """
-
-    if not filters:
-      filters = []
-
-    volumes = {}
-    client = self.ClientApi(common.EC2_SERVICE, region=region)
-    responses = common.ExecuteRequest(
-        client, 'describe_volumes', {'Filters': filters})
-    for response in responses:
-      for volume in response['Volumes']:
-        volume_id = volume['VolumeId']
-        aws_volume = ebs.AWSVolume(volume_id,
-                                   self,
-                                   self.default_region,
-                                   volume['AvailabilityZone'],
-                                   volume['Encrypted'])
-
-        for tag in volume.get('Tags', []):
-          if tag.get('Key') == 'Name':
-            aws_volume.name = tag.get('Value')
-            break
-
-        for attachment in volume.get('Attachments', []):
-          if attachment.get('State') == 'attached':
-            aws_volume.device_name = attachment.get('Device')
-            break
-
-        volumes[volume_id] = aws_volume
-    return volumes
-
-  def GetInstancesByNameOrId(
-      self,
-      instance_name: Optional[str] = None,
-      instance_id: Optional[str] = None,
-      region: Optional[str] = None) -> List[ec2.AWSInstance]:
-    """Get instances from an AWS account by their name tag or an ID.
-
-    Exactly one of [instance_name, instance_id] must be specified. If looking up
-    an instance by its ID, the method returns a list with exactly one
-    element. If looking up instances by their name tag (which are not unique
-    across instances), then the method will return a list of all instances
-    with that name tag, or an empty list if no instances with matching name
-    tag could be found.
-
-    Args:
-      instance_name (str): Optional. The instance name tag of the instance to
-          get.
-      instance_id (str): Optional. The instance id of the instance to get.
-      region (str): Optional. The region to look the instance in.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-
-    Returns:
-      List[AWSInstance]: A list of Amazon EC2 Instance objects.
-
-    Raises:
-      ValueError: If both instance_name and instance_id are None or if both
-          are set.
-    """
-
-    if (not instance_name and not instance_id) or (instance_name and instance_id):  # pylint: disable=line-too-long
-      raise ValueError('You must specify exactly one of [instance_name, '
-                       'instance_id]. Got instance_name: {0:s}, instance_id: '
-                       '{1:s}'.format(str(instance_name), str(instance_id)))
-    if instance_name:
-      return self.GetInstancesByName(instance_name, region=region)
-    assert instance_id  # Mypy: assert that instance_id is not None
-    return [self.GetInstanceById(instance_id, region=region)]
-
-  def GetInstancesByName(self,
-                         instance_name: str,
-                         region: Optional[str] = None) -> List[ec2.AWSInstance]:
-    """Get all instances from an AWS account with matching name tag.
-
-    Args:
-      instance_name (str): The instance name tag.
-      region (str): Optional. The region to look the instance in.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-
-    Returns:
-      List[AWSInstance]: A list of EC2 Instance objects. If no instance with
-          matching name tag is found, the method returns an empty list.
-    """
-
-    matching_instances = []
-    instances = self.ListInstances(region=region)
-    for instance_id in instances:
-      aws_instance = instances[instance_id]
-      if aws_instance.name == instance_name:
-        matching_instances.append(aws_instance)
-    return matching_instances
-
-  def GetInstanceById(self,
-                      instance_id: str,
-                      region: Optional[str] = None) -> ec2.AWSInstance:
-    """Get an instance from an AWS account by its ID.
-
-    Args:
-      instance_id (str): The instance id.
-      region (str): Optional. The region to look the instance in.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-
-    Returns:
-      AWSInstance: An Amazon EC2 Instance object.
-
-    Raises:
-      RuntimeError: If instance does not exist.
-    """
-
-    instances = self.ListInstances(region=region)
-    instance = instances.get(instance_id)
-    if not instance:
-      error_msg = 'Instance {0:s} was not found in AWS account'.format(
-          instance_id)
-      raise RuntimeError(error_msg)
-    return instance
-
-  def GetVolumesByNameOrId(self,
-                           volume_name: Optional[str] = None,
-                           volume_id: Optional[str] = None,
-                           region: Optional[str] = None) -> List[ebs.AWSVolume]:
-    """Get a volume from an AWS account by its name tag or its ID.
-
-    Exactly one of [volume_name, volume_id] must be specified. If looking up
-    a volume by its ID, the method returns a list with exactly one
-    element. If looking up volumes by their name tag (which are not unique
-    across volumes), then the method will return a list of all volumes
-    with that name tag, or an empty list if no volumes with matching name tag
-    could be found.
-
-    Args:
-      volume_name (str): Optional. The volume name tag of the volume to get.
-      volume_id (str): Optional. The volume id of the volume to get.
-      region (str): Optional. The region to look the volume in.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-
-    Returns:
-      List[AWSVolume]: A list of Amazon EC2 Volume objects.
-
-    Raises:
-      ValueError: If both volume_name and volume_id are None or if both
-          are set.
-    """
-
-    if (not volume_name and not volume_id) or (volume_name and volume_id):
-      raise ValueError('You must specify exactly one of [volume_name, '
-                       'volume_id]. Got volume_name: {0:s}, volume_id: '
-                       '{1:s}'.format(str(volume_name), str(volume_id)))
-    if volume_name:
-      return self.GetVolumesByName(volume_name, region=region)
-    assert volume_id  # Mypy: assert that volume_id is not None
-    return [self.GetVolumeById(volume_id, region=region)]
-
-  def GetVolumesByName(self,
-                       volume_name: str,
-                       region: Optional[str] = None) -> List[ebs.AWSVolume]:
-    """Get all volumes from an AWS account with matching name tag.
-
-    Args:
-      volume_name (str): The volume name tag.
-      region (str): Optional. The region to look the volume in.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-
-    Returns:
-      List[AWSVolume]: A list of EC2 Volume objects. If no volume with
-          matching name tag is found, the method returns an empty list.
-    """
-
-    matching_volumes = []
-    volumes = self.ListVolumes(region=region)
-    for volume_id in volumes:
-      volume = volumes[volume_id]
-      if volume.name == volume_name:
-        matching_volumes.append(volume)
-    return matching_volumes
-
-  def GetVolumeById(self,
-                    volume_id: str,
-                    region: Optional[str] = None) -> ebs.AWSVolume:
-    """Get a volume from an AWS account by its ID.
-
-    Args:
-      volume_id (str): The volume id.
-      region (str): Optional. The region to look the volume in.
-          If none provided, the default_region associated to the AWSAccount
-          object will be used.
-
-    Returns:
-      AWSVolume: An Amazon EC2 Volume object.
-
-    Raises:
-      RuntimeError: If volume does not exist.
-    """
-
-    volumes = self.ListVolumes(region=region)
-    volume = volumes.get(volume_id)
-    if not volume:
-      error_msg = 'Volume {0:s} was not found in AWS account'.format(
-          volume_id)
-      raise RuntimeError(error_msg)
-    return volume
-
-  def CreateVolumeFromSnapshot(
-      self,
-      snapshot: ebs.AWSSnapshot,
-      volume_name: Optional[str] = None,
-      volume_name_prefix: Optional[str] = None,
-      volume_type: str = 'gp2',
-      kms_key_id: Optional[str] = None,
-      tags: Optional[Dict[str, str]] = None) -> ebs.AWSVolume:
-    """Create a new volume based on a snapshot.
-
-    Args:
-      snapshot (AWSSnapshot): Snapshot to use.
-      volume_name (str): Optional. String to use as new volume name.
-      volume_name_prefix (str): Optional. String to prefix the volume name with.
-      volume_type (str): Optional. The volume type for the volume to create.
-          Can be one of 'standard'|'io1'|'gp2'|'sc1'|'st1'. The default is
-          'gp2'.
-      kms_key_id (str): Optional. A KMS key id to encrypt the volume with.
-      tags (Dict[str, str]): Optional. A dictionary of tags to add to the
-          volume, for example {'TicketID': 'xxx'}. An entry for the volume
-          name is added by default.
-
-    Returns:
-      AWSVolume: An AWS EBS Volume.
-
-    Raises:
-      ValueError: If the volume name does not comply with the RegEx,
-          or if the volume type is invalid.
-      RuntimeError: If the volume could not be created.
-    """
-
-    if volume_type not in ['standard', 'io1', 'gp2', 'sc1', 'st1']:
-      raise ValueError('Volume type must be one of [standard, io1, gp2, sc1, '
-                       'st1]. Got: {0:s}'.format(volume_type))
-
-    if not volume_name:
-      volume_name = self._GenerateVolumeName(
-          snapshot, volume_name_prefix=volume_name_prefix)
-
-    if len(volume_name) > 255:
-      raise ValueError(
-          'Volume name {0:s} is too long (>255 chars)'.format(volume_name))
-
-    if not tags:
-      tags = {}
-    tags['Name'] = volume_name
-
-    client = self.ClientApi(common.EC2_SERVICE)
-    create_volume_args = {
-        'AvailabilityZone': snapshot.availability_zone,
-        'SnapshotId': snapshot.snapshot_id,
-        'TagSpecifications': [common.CreateTags(common.VOLUME, tags)],
-        'VolumeType': volume_type
-    }
-    if kms_key_id:
-      create_volume_args['Encrypted'] = True
-      create_volume_args['KmsKeyId'] = kms_key_id
-    if volume_type == 'io1':
-      # If using the io1 volume type, we must specify Iops, see
-      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/
-      # services/ec2.html#EC2.Client.create_volume. io1 volumes allow for a
-      # ratio of 50 IOPS per 1 GiB.
-      create_volume_args['Iops'] = self.ResourceApi(
-          common.EC2_SERVICE).Snapshot(snapshot.snapshot_id).volume_size * 50
-    try:
-      volume = client.create_volume(**create_volume_args)
-      volume_id = volume['VolumeId']
-      # Wait for volume creation completion
-      client.get_waiter('volume_available').wait(VolumeIds=[volume_id])
-    except (client.exceptions.ClientError,
-            botocore.exceptions.WaiterError) as exception:
-      raise RuntimeError('Could not create volume {0:s} from snapshot '
-                         '{1:s}: {2:s}'.format(volume_name, snapshot.name,
-                                               str(exception)))
-
-    zone = volume['AvailabilityZone']
-    encrypted = volume['Encrypted']
-
-    return ebs.AWSVolume(volume_id,
-                         self,
-                         self.default_region,
-                         zone,
-                         encrypted,
-                         name=volume_name)
-
-  def GetOrCreateAnalysisVm(
-      self,
-      vm_name: str,
-      boot_volume_size: int,
-      ami: str,
-      cpu_cores: int,
-      boot_volume_type: str = 'gp2',
-      packages: Optional[List[str]] = None,
-      ssh_key_name: Optional[str] = None,
-      tags: Optional[Dict[str, str]] = None) -> Tuple[ec2.AWSInstance, bool]:
-    """Get or create a new virtual machine for analysis purposes.
-
-    Args:
-      vm_name (str): The instance name tag of the virtual machine.
-      boot_volume_size (int): The size of the analysis VM boot volume (in GB).
-      ami (str): The Amazon Machine Image ID to use to create the VM.
-      cpu_cores (int): Number of CPU cores for the analysis VM.
-      boot_volume_type (str): Optional. The volume type for the boot volume
-          of the VM. Can be one of 'standard'|'io1'|'gp2'|'sc1'|'st1'. The
-          default is 'gp2'.
-      packages (List[str]): Optional. List of packages to install in the VM.
-      ssh_key_name (str): Optional. A SSH key pair name linked to the AWS
-          account to associate with the VM. If none provided, the VM can only
-          be accessed through in-browser SSH from the AWS management console
-          with the EC2 client connection package (ec2-instance-connect). Note
-          that if this package fails to install on the target VM, then the VM
-          will not be accessible. It is therefore recommended to fill in this
-          parameter.
-      tags (Dict[str, str]): Optional. A dictionary of tags to add to the
-          instance, for example {'TicketID': 'xxx'}. An entry for the instance
-          name is added by default.
-
-    Returns:
-      Tuple[AWSInstance, bool]: A tuple with an AWSInstance object and a
-          boolean indicating if the virtual machine was created (True) or
-          reused (False).
-
-    Raises:
-      RuntimeError: If the virtual machine cannot be found or created.
-    """
-
-    # Re-use instance if it already exists, or create a new one.
-    try:
-      instances = self.GetInstancesByName(vm_name)
-      if instances:
-        created = False
-        return instances[0], created
-    except RuntimeError:
-      pass
-
-    instance_type = common.GetInstanceTypeByCPU(cpu_cores)
-    startup_script = utils.ReadStartupScript()
-    if packages:
-      startup_script = startup_script.replace('${packages[@]}', ' '.join(
-          packages))
-
-    # Install ec2-instance-connect to allow SSH connections from the browser.
-    startup_script = startup_script.replace(
-        '(exit ${exit_code})',
-        'apt -y install ec2-instance-connect && (exit ${exit_code})')
-
-    if not tags:
-      tags = {}
-    tags['Name'] = vm_name
-
-    client = self.ClientApi(common.EC2_SERVICE)
-    vm_args = {
-        'BlockDeviceMappings':
-            [self._GetBootVolumeConfigByAmi(
-                ami, boot_volume_size, boot_volume_type)],
-        'ImageId': ami,
-        'MinCount': 1,
-        'MaxCount': 1,
-        'InstanceType': instance_type,
-        'TagSpecifications': [common.CreateTags(common.INSTANCE, tags)],
-        'UserData': startup_script,
-        'Placement': {'AvailabilityZone': self.default_availability_zone}
-    }
-    if ssh_key_name:
-      vm_args['KeyName'] = ssh_key_name
-    # Create the instance in AWS
-    try:
-      instance = client.run_instances(**vm_args)
-      # If the call to run_instances was successful, then the API response
-      # contains the instance ID for the new instance.
-      instance_id = instance['Instances'][0]['InstanceId']
-      # Wait for the instance to be running
-      client.get_waiter('instance_running').wait(InstanceIds=[instance_id])
-      # Wait for the status checks to pass
-      client.get_waiter('instance_status_ok').wait(InstanceIds=[instance_id])
-    except (client.exceptions.ClientError,
-            botocore.exceptions.WaiterError) as exception:
-      raise RuntimeError('Could not create instance {0:s}: {1:s}'.format(
-          vm_name, str(exception)))
-
-    instance = ec2.AWSInstance(self,
-                               instance_id,
-                               self.default_region,
-                               self.default_availability_zone,
-                               name=vm_name)
-    created = True
-    return instance, created
-
-  def GetAccountInformation(self, info: str) -> str:
-    """Get information about the AWS account in use.
-
-    If the call succeeds, then the response from the STS API is expected to
-    have the following entries:
-      - UserId
-      - Account
-      - Arn
-    See https://boto3.amazonaws.com/v1/documentation/api/1.9.42/reference/services/sts.html#STS.Client.get_caller_identity for more details. # pylint: disable=line-too-long
-
-    Args:
-      info (str): The account information to retrieve. Must be one of [UserID,
-          Account, Arn]
-    Returns:
-      str: The information requested.
-
-    Raises:
-      KeyError: If the requested information doesn't exist.
-    """
-
-    account_information = self.ClientApi(
-        common.ACCOUNT_SERVICE).get_caller_identity()  # type: Dict[str, str]
-    if not account_information.get(info):
-      raise KeyError('Key must be one of ["UserId", "Account", "Arn"]')
-    return account_information[info]
-
-  def CreateKMSKey(self) -> str:
-    """Create a KMS key.
-
-    Returns:
-      str: The KMS key ID for the key that was created.
-
-    Raises:
-      RuntimeError: If the key could not be created.
-    """
-
-    client = self.ClientApi(common.KMS_SERVICE)
-    try:
-      kms_key = client.create_key()
-    except client.exceptions.ClientError as exception:
-      raise RuntimeError('Could not create KMS key: {0:s}'.format(
-          str(exception)))
-
-    # The response contains the key ID
-    key_id = kms_key['KeyMetadata']['KeyId']  # type: str
-    return key_id
-
-  def ShareKMSKeyWithAWSAccount(self,
-                                kms_key_id: str,
-                                aws_account_id: str) -> None:
-    """Share a KMS key.
-
-    Args:
-      kms_key_id (str): The KMS key ID of the key to share.
-      aws_account_id (str): The AWS Account ID to share the KMS key with.
-
-    Raises:
-      RuntimeError: If the key could not be shared.
-    """
-
-    share_policy = {
-        'Sid': 'Allow use of the key',
-        'Effect': 'Allow',
-        'Principal': {
-            'AWS': 'arn:aws:iam::{0:s}:root'.format(aws_account_id)
-        },
-        'Action': [
-            # kms:*crypt and kms:ReEncrypt* are necessary to transfer
-            # encrypted EBS resources across accounts.
-            'kms:Encrypt',
-            'kms:Decrypt',
-            'kms:ReEncrypt*',
-            # kms:CreateGrant is necessary to transfer encrypted EBS
-            # resources across regions.
-            'kms:CreateGrant'
-        ],
-        'Resource': '*'
-    }
-    client = self.ClientApi(common.KMS_SERVICE)
-    try:
-      policy = json.loads(client.get_key_policy(
-          KeyId=kms_key_id, PolicyName='default')['Policy'])
-      policy['Statement'].append(share_policy)
-      # Update the key policy so that it is shared with the AWS account.
-      client.put_key_policy(
-          KeyId=kms_key_id, PolicyName='default', Policy=json.dumps(policy))
-    except client.exceptions.ClientError as exception:
-      raise RuntimeError('Could not share KMS key {0:s}: {1:s}'.format(
-          kms_key_id, str(exception)))
-
-  def DeleteKMSKey(self, kms_key_id: Optional[str] = None) -> None:
-    """Delete a KMS key.
-
-    Schedule the KMS key for deletion. By default, users have a 30 days
-        window before the key gets deleted.
-
-    Args:
-      kms_key_id (str): The ID of the KMS key to delete.
-
-    Raises:
-      RuntimeError: If the key could not be scheduled for deletion.
-    """
-
-    if not kms_key_id:
-      return
-
-    client = self.ClientApi(common.KMS_SERVICE)
-    try:
-      client.schedule_key_deletion(KeyId=kms_key_id)
-    except client.exceptions.ClientError as exception:
-      raise RuntimeError('Could not schedule the KMS key: {0:s} for '
-                         'deletion'.format(str(exception)))
-
-  def _GenerateVolumeName(self,
-                          snapshot: ebs.AWSSnapshot,
-                          volume_name_prefix: Optional[str] = None) -> str:
-    """Generate a new volume name given a volume's snapshot.
-
-    Args:
-      snapshot (AWSSnapshot): A volume's Snapshot.
-      volume_name_prefix (str): Optional. Prefix for the volume name.
-
-    Returns:
-      str: A name for the volume.
-
-    Raises:
-      ValueError: If the volume name does not comply with the RegEx.
-    """
-
-    # Max length of tag values in AWS is 255 characters
-    user_id = self.GetAccountInformation('UserId')
-    volume_id = user_id + snapshot.volume.volume_id
-    volume_id_crc32 = '{0:08x}'.format(
-        binascii.crc32(volume_id.encode()) & 0xffffffff)
-    truncate_at = 255 - len(volume_id_crc32) - len('-copy') - 1
-    assert snapshot.name  # Mypy: assert that snapshot.name is not None
-    if volume_name_prefix:
-      volume_name_prefix += '-'
-      if len(volume_name_prefix) > truncate_at:
-        # The volume name prefix is too long
-        volume_name_prefix = volume_name_prefix[:truncate_at]
-      truncate_at -= len(volume_name_prefix)
-      volume_name = '{0:s}{1:s}-{2:s}-copy'.format(
-          volume_name_prefix,
-          snapshot.name[:truncate_at],
-          volume_id_crc32)
-    else:
-      volume_name = '{0:s}-{1:s}-copy'.format(
-          snapshot.name[:truncate_at], volume_id_crc32)
-
-    return volume_name
-
-  def _GetBootVolumeConfigByAmi(self,
-                                ami: str,
-                                boot_volume_size: int,
-                                boot_volume_type: str) -> Dict[str, Any]:
-    """Return a boot volume configuration for a given AMI and boot volume size.
-
-    Args:
-      ami (str): The Amazon Machine Image ID.
-      boot_volume_size (int): Size of the boot volume, in GB.
-      boot_volume_type (str): Type of the boot volume.
-
-    Returns:
-      Dict[str, str|Dict]]: A BlockDeviceMappings configuration for the
-          specified AMI.
-
-    Raises:
-      RuntimeError: If AMI details cannot be found.
-    """
-
-    client = self.ClientApi(common.EC2_SERVICE)
-    try:
-      image = client.describe_images(ImageIds=[ami])
-    except client.exceptions.ClientError as exception:
-      raise RuntimeError(
-          'Could not find image information for AMI {0:s}: {1:s}'.format(
-              ami, str(exception)))
-
-    # If the call to describe_images was successful, then the API's response
-    # is expected to contain at least one image and its corresponding block
-    # device mappings information.
-    # pylint: disable=line-too-long
-    block_device_mapping = image['Images'][0]['BlockDeviceMappings'][0]  # type: Dict[str, Any]
-    # pylint: enable=line-too-long
-    block_device_mapping['Ebs']['VolumeSize'] = boot_volume_size
-    block_device_mapping['Ebs']['VolumeType'] = boot_volume_type
-    if boot_volume_type == 'io1':
-      # If using the io1 volume type, we must specify Iops, see
-      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/
-      # services/ec2.html#EC2.Client.create_volume. io1 volumes allow for a
-      # ratio of 50 IOPS per 1 GiB.
-      block_device_mapping['Ebs']['Iops'] = boot_volume_size * 50
-    return block_device_mapping
-
-  def ListImages(
-      self,
-      qfilter: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
-    """List AMI images.
-
-    Args:
-      qfilter (List[Dict]): The filter expression.
-      See https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html#EC2.Client.describe_images  # pylint: disable=line-too-long
-
-    Returns:
-      List[Dict[str, Any]]: The list of images with their properties.
-
-    Raises:
-      RuntimeError: If the images could not be listed.
-    """
-    if not qfilter:
-      qfilter = []
-
-    client = self.ClientApi(common.EC2_SERVICE)
-    try:
-      images = client.describe_images(
-          Filters=qfilter)  # type: Dict[str, List[Dict[str, Any]]]
-    except client.exceptions.ClientError as exception:
-      raise RuntimeError(str(exception))
-
-    return images['Images']
-
-  def GenerateSSHKeyPair(self, vm_name: str) -> Tuple[str, str]:
-    """Generate a SSH key pair and returns its name and private key.
-
-    Args:
-      vm_name (str): The VM name for which to generate the key pair.
-
-    Returns:
-      Tuple[str, str]: A tuple containing the key name and the private key for
-          the generated SSH key pair.
-
-    Raises:
-      ValueError: If vm_name is None.
-      RuntimeError: If the key could not be created.
-    """
-
-    if not vm_name:
-      raise ValueError('Parameter vm_name must not be None.')
-
-    # SSH key names need to be unique, therefore we add a random 10 chars hex
-    # string.
-    key_name = '{0:s}-{1:s}-ssh'.format(
-        vm_name, binascii.b2a_hex(os.urandom(10)).decode('utf-8'))
-    client = self.ClientApi(common.EC2_SERVICE)
-    try:
-      key = client.create_key_pair(KeyName=key_name)
-    except client.exceptions.ClientError as exception:
-      raise RuntimeError('Could not create SSH key pair: {0:s}'.format(
-          str(exception)))
-    # If the call was successful, the response contains key information
-    return key['KeyName'], key['KeyMaterial']
diff --git a/libcloudforensics/providers/aws/internal/ebs.py b/libcloudforensics/providers/aws/internal/ebs.py
index 5a4e40b..3519890 100644
--- a/libcloudforensics/providers/aws/internal/ebs.py
+++ b/libcloudforensics/providers/aws/internal/ebs.py
@@ -13,8 +13,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Disk functionality."""
-
-from typing import TYPE_CHECKING, Dict, Optional, Union
+import binascii
+from typing import TYPE_CHECKING, Dict, Optional, Union, List, Any
 
 import botocore
 
@@ -305,3 +305,301 @@ class AWSSnapshot(AWSElasticBlockStore):
         },
         OperationType='add'
     )
+
+
+class EBS:
+  """Class that represents AWS EBS storage services."""
+
+  def __init__(self,
+               aws_account: 'account.AWSAccount') -> None:
+    """Initialize the AWS EBS client object.
+
+    Args:
+      aws_account (AWSAccount): An AWS account object.
+    """
+    self.aws_account = aws_account
+
+  def ListVolumes(
+      self,
+      region: Optional[str] = None,
+      filters: Optional[List[Dict[str, Any]]] = None) -> Dict[str, AWSVolume]:
+    """List volumes of an AWS account.
+
+    Example usage:
+      # List volumes attached to the instance 'some-instance-id'
+      ListVolumes(filters=[
+          {'Name':'attachment.instance-id', 'Values':['some-instance-id']}])
+
+    Args:
+      region (str): Optional. The region from which to list the volumes.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+      filters (List[Dict]): Optional. Filters for the query. Filters are
+          given as a list of dictionaries, e.g.: {'Name': 'someFilter',
+          'Values': ['value1', 'value2']}.
+
+    Returns:
+      Dict[str, AWSVolume]: Dictionary mapping volume IDs (str) to their
+          respective AWSVolume object.
+
+    Raises:
+      RuntimeError: If volumes can't be listed.
+    """
+
+    if not filters:
+      filters = []
+
+    volumes = {}
+    client = self.aws_account.ClientApi(common.EC2_SERVICE, region=region)
+    responses = common.ExecuteRequest(
+        client, 'describe_volumes', {'Filters': filters})
+    for response in responses:
+      for volume in response['Volumes']:
+        volume_id = volume['VolumeId']
+        aws_volume = AWSVolume(volume_id,
+                               self.aws_account,
+                               self.aws_account.default_region,
+                               volume['AvailabilityZone'],
+                               volume['Encrypted'])
+
+        for tag in volume.get('Tags', []):
+          if tag.get('Key') == 'Name':
+            aws_volume.name = tag.get('Value')
+            break
+
+        for attachment in volume.get('Attachments', []):
+          if attachment.get('State') == 'attached':
+            aws_volume.device_name = attachment.get('Device')
+            break
+
+        volumes[volume_id] = aws_volume
+    return volumes
+
+  def GetVolumesByNameOrId(self,
+                           volume_name: Optional[str] = None,
+                           volume_id: Optional[str] = None,
+                           region: Optional[str] = None) -> List[AWSVolume]:
+    """Get a volume from an AWS account by its name tag or its ID.
+
+    Exactly one of [volume_name, volume_id] must be specified. If looking up
+    a volume by its ID, the method returns a list with exactly one
+    element. If looking up volumes by their name tag (which are not unique
+    across volumes), then the method will return a list of all volumes
+    with that name tag, or an empty list if no volumes with matching name tag
+    could be found.
+
+    Args:
+      volume_name (str): Optional. The volume name tag of the volume to get.
+      volume_id (str): Optional. The volume id of the volume to get.
+      region (str): Optional. The region to look the volume in.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+
+    Returns:
+      List[AWSVolume]: A list of Amazon EC2 Volume objects.
+
+    Raises:
+      ValueError: If both volume_name and volume_id are None or if both
+          are set.
+    """
+
+    if (not volume_name and not volume_id) or (volume_name and volume_id):
+      raise ValueError('You must specify exactly one of [volume_name, '
+                       'volume_id]. Got volume_name: {0:s}, volume_id: '
+                       '{1:s}'.format(str(volume_name), str(volume_id)))
+    if volume_name:
+      return self.GetVolumesByName(volume_name, region=region)
+    # mypy complains that volume_id may be None here, but at this point in the
+    # code it never is, so it's safe to ignore the warning.
+    return [self.GetVolumeById(volume_id, region=region)]  # type: ignore
+
+  def GetVolumesByName(self,
+                       volume_name: str,
+                       region: Optional[str] = None) -> List[AWSVolume]:
+    """Get all volumes from an AWS account with matching name tag.
+
+    Args:
+      volume_name (str): The volume name tag.
+      region (str): Optional. The region to look the volume in.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+
+    Returns:
+      List[AWSVolume]: A list of EC2 Volume objects. If no volume with
+          matching name tag is found, the method returns an empty list.
+    """
+
+    volumes = self.ListVolumes(region=region)
+    return [volume for volume in volumes.values() if
+            volume.name == volume_name]
+
+  def GetVolumeById(self,
+                    volume_id: str,
+                    region: Optional[str] = None) -> AWSVolume:
+    """Get a volume from an AWS account by its ID.
+
+    Args:
+      volume_id (str): The volume id.
+      region (str): Optional. The region to look the volume in.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+
+    Returns:
+      AWSVolume: An Amazon EC2 Volume object.
+
+    Raises:
+      RuntimeError: If volume does not exist.
+    """
+
+    volumes = self.ListVolumes(region=region)
+    volume = volumes.get(volume_id)
+    if not volume:
+      error_msg = 'Volume {0:s} was not found in AWS account'.format(
+          volume_id)
+      raise RuntimeError(error_msg)
+    return volume
+
+  def CreateVolumeFromSnapshot(
+      self,
+      snapshot: AWSSnapshot,
+      volume_name: Optional[str] = None,
+      volume_name_prefix: Optional[str] = None,
+      volume_type: str = 'gp2',
+      kms_key_id: Optional[str] = None,
+      tags: Optional[Dict[str, str]] = None) -> AWSVolume:
+    """Create a new volume based on a snapshot.
+
+    Args:
+      snapshot (AWSSnapshot): Snapshot to use.
+      volume_name (str): Optional. String to use as new volume name.
+      volume_name_prefix (str): Optional. String to prefix the volume name with.
+      volume_type (str): Optional. The volume type for the volume to create.
+          Can be one of 'standard'|'io1'|'gp2'|'sc1'|'st1'. The default is
+          'gp2'.
+      kms_key_id (str): Optional. A KMS key id to encrypt the volume with.
+      tags (Dict[str, str]): Optional. A dictionary of tags to add to the
+          volume, for example {'TicketID': 'xxx'}. An entry for the volume
+          name is added by default.
+
+    Returns:
+      AWSVolume: An AWS EBS Volume.
+
+    Raises:
+      ValueError: If the volume name does not comply with the RegEx,
+          or if the volume type is invalid.
+      RuntimeError: If the volume could not be created.
+    """
+
+    if volume_type not in ['standard', 'io1', 'gp2', 'sc1', 'st1']:
+      raise ValueError('Volume type must be one of [standard, io1, gp2, sc1, '
+                       'st1]. Got: {0:s}'.format(volume_type))
+
+    if not volume_name:
+      volume_name = self._GenerateVolumeName(
+          snapshot, volume_name_prefix=volume_name_prefix)
+
+    if len(volume_name) > 255:
+      raise ValueError(
+          'Volume name {0:s} is too long (>255 chars)'.format(volume_name))
+
+    if not tags:
+      tags = {}
+    tags['Name'] = volume_name
+
+    client = self.aws_account.ClientApi(common.EC2_SERVICE)
+    create_volume_args = {
+        'AvailabilityZone': snapshot.availability_zone,
+        'SnapshotId': snapshot.snapshot_id,
+        'TagSpecifications': [common.CreateTags(common.VOLUME, tags)],
+        'VolumeType': volume_type
+    }
+    if kms_key_id:
+      create_volume_args['Encrypted'] = True
+      create_volume_args['KmsKeyId'] = kms_key_id
+    if volume_type == 'io1':
+      # If using the io1 volume type, we must specify Iops, see
+      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/
+      # services/ec2.html#EC2.Client.create_volume. io1 volumes allow for a
+      # ratio of 50 IOPS per 1 GiB.
+      create_volume_args['Iops'] = self.aws_account.ResourceApi(
+          common.EC2_SERVICE).Snapshot(snapshot.snapshot_id).volume_size * 50
+    try:
+      volume = client.create_volume(**create_volume_args)
+      volume_id = volume['VolumeId']
+      # Wait for volume creation completion
+      client.get_waiter('volume_available').wait(VolumeIds=[volume_id])
+    except (client.exceptions.ClientError,
+            botocore.exceptions.WaiterError) as exception:
+      raise RuntimeError('Could not create volume {0:s} from snapshot '
+                         '{1:s}: {2:s}'.format(volume_name, snapshot.name,
+                                               str(exception)))
+
+    zone = volume['AvailabilityZone']
+    encrypted = volume['Encrypted']
+
+    return AWSVolume(volume_id,
+                     self.aws_account,
+                     self.aws_account.default_region,
+                     zone,
+                     encrypted,
+                     name=volume_name)
+
+  def GetAccountInformation(self) -> Dict[str, str]:
+    """Get information about the AWS account in use.
+
+    If the call succeeds, then the response from the STS API is expected to
+    have the following entries:
+      - UserId
+      - Account
+      - Arn
+    See https://boto3.amazonaws.com/v1/documentation/api/1.9.42/reference/services/sts.html#STS.Client.get_caller_identity for more details. # pylint: disable=line-too-long
+
+    Returns:
+      Dict[str, str]: The AWS account information.
+    """
+    account_information = self.aws_account.ClientApi(
+        common.ACCOUNT_SERVICE).get_caller_identity()  # type: Dict[str, str]
+    return account_information
+
+  def _GenerateVolumeName(self,
+                          snapshot: AWSSnapshot,
+                          volume_name_prefix: Optional[str] = None) -> str:
+    """Generate a new volume name given a volume's snapshot.
+
+    Args:
+      snapshot (AWSSnapshot): A volume's Snapshot.
+      volume_name_prefix (str): Optional. Prefix for the volume name.
+
+    Returns:
+      str: A name for the volume.
+
+    Raises:
+      ValueError: If the volume name does not comply with the RegEx,
+          or if AWS account information could not be retrieved.
+    """
+
+    # Max length of tag values in AWS is 255 characters
+    user_id = self.GetAccountInformation().get('UserId')
+    if not user_id:
+      raise ValueError('Could not fetch AWS user ID')
+    volume_id = user_id + snapshot.volume.volume_id
+    volume_id_crc32 = '{0:08x}'.format(
+        binascii.crc32(volume_id.encode()) & 0xffffffff)
+    truncate_at = 255 - len(volume_id_crc32) - len('-copy') - 1
+    if not snapshot.name:
+      snapshot.name = snapshot.snapshot_id
+    if volume_name_prefix:
+      volume_name_prefix += '-'
+      if len(volume_name_prefix) > truncate_at:
+        # The volume name prefix is too long
+        volume_name_prefix = volume_name_prefix[:truncate_at]
+      truncate_at -= len(volume_name_prefix)
+      volume_name = '{0:s}{1:s}-{2:s}-copy'.format(
+          volume_name_prefix,
+          snapshot.name[:truncate_at],
+          volume_id_crc32)
+    else:
+      volume_name = '{0:s}-{1:s}-copy'.format(
+          snapshot.name[:truncate_at], volume_id_crc32)
+
+    return volume_name
diff --git a/libcloudforensics/providers/aws/internal/ec2.py b/libcloudforensics/providers/aws/internal/ec2.py
index e4f1a69..5209385 100644
--- a/libcloudforensics/providers/aws/internal/ec2.py
+++ b/libcloudforensics/providers/aws/internal/ec2.py
@@ -13,7 +13,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Instance functionality."""
-from typing import TYPE_CHECKING, Dict, Optional
+import binascii
+import os
+from typing import TYPE_CHECKING, Dict, Optional, List, Any, Tuple
+
+import botocore
+from libcloudforensics.scripts import utils
 
 from libcloudforensics.providers.aws.internal import common
 
@@ -109,7 +114,7 @@ class AWSInstance:
           AWSVolume object.
     """
 
-    return self.aws_account.ListVolumes(
+    return self.aws_account.ebs.ListVolumes(
         filters=[{
             'Name': 'attachment.instance-id',
             'Values': [self.instance_id]}])
@@ -137,3 +142,365 @@ class AWSInstance:
           volume.volume_id, str(exception)))
 
     volume.device_name = device_name
+
+
+class EC2:
+  """Class that represents AWS EC2 instance services."""
+
+  def __init__(self,
+               aws_account: 'account.AWSAccount') -> None:
+    """Initialize the AWS ec2 client object.
+
+    Args:
+      aws_account (AWSAccount): An AWS account object.
+    """
+    self.aws_account = aws_account
+
+  def ListInstances(
+      self,
+      region: Optional[str] = None,
+      filters: Optional[List[Dict[str, Any]]] = None,
+      show_terminated: bool = False) -> Dict[str, AWSInstance]:
+    """List instances of an AWS account.
+
+    Example usage:
+      ListInstances(region='us-east-1', filters=[
+          {'Name':'instance-id', 'Values':['some-instance-id']}])
+
+    Args:
+      region (str): Optional. The region from which to list instances.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+      filters (List[Dict]): Optional. Filters for the query. Filters are
+          given as a list of dictionaries, e.g.: {'Name': 'someFilter',
+          'Values': ['value1', 'value2']}.
+      show_terminated (bool): Optional. Include terminated instances in the
+          list.
+
+    Returns:
+      Dict[str, AWInstance]: Dictionary mapping instance IDs (str) to their
+          respective AWSInstance object.
+
+    Raises:
+      RuntimeError: If instances can't be listed.
+    """
+
+    if not filters:
+      filters = []
+
+    instances = {}
+    client = self.aws_account.ClientApi(common.EC2_SERVICE, region=region)
+    responses = common.ExecuteRequest(
+        client, 'describe_instances', {'Filters': filters})
+
+    for response in responses:
+      for reservation in response['Reservations']:
+        for instance in reservation['Instances']:
+          # If reservation['Instances'] contains any entry, then the
+          # instance's state is expected to be present in the API's response.
+          if instance['State']['Name'] == 'terminated' and not show_terminated:
+            continue
+
+          zone = instance['Placement']['AvailabilityZone']
+          instance_id = instance['InstanceId']
+          aws_instance = AWSInstance(
+              self.aws_account, instance_id, zone[:-1], zone)
+
+          for tag in instance.get('Tags', []):
+            if tag.get('Key') == 'Name':
+              aws_instance.name = tag.get('Value')
+              break
+
+          instances[instance_id] = aws_instance
+    return instances
+
+  def GetInstancesByNameOrId(
+      self,
+      instance_name: Optional[str] = None,
+      instance_id: Optional[str] = None,
+      region: Optional[str] = None) -> List[AWSInstance]:
+    """Get instances from an AWS account by their name tag or an ID.
+
+    Exactly one of [instance_name, instance_id] must be specified. If looking up
+    an instance by its ID, the method returns a list with exactly one
+    element. If looking up instances by their name tag (which are not unique
+    across instances), then the method will return a list of all instances
+    with that name tag, or an empty list if no instances with matching name
+    tag could be found.
+
+    Args:
+      instance_name (str): Optional. The instance name tag of the instance to
+          get.
+      instance_id (str): Optional. The instance id of the instance to get.
+      region (str): Optional. The region to look the instance in.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+
+    Returns:
+      List[AWSInstance]: A list of Amazon EC2 Instance objects.
+
+    Raises:
+      ValueError: If both instance_name and instance_id are None or if both
+          are set.
+    """
+
+    if (not instance_name and not instance_id) or (instance_name and instance_id):  # pylint: disable=line-too-long
+      raise ValueError('You must specify exactly one of [instance_name, '
+                       'instance_id]. Got instance_name: {0:s}, instance_id: '
+                       '{1:s}'.format(str(instance_name), str(instance_id)))
+    if instance_name:
+      return self.GetInstancesByName(instance_name, region=region)
+    # mypy complains that instance_id may be None here, but at this point in the
+    # code it never is, so it's safe to ignore the warning.
+    return [self.GetInstanceById(instance_id, region=region)]  # type: ignore
+
+  def GetInstancesByName(self,
+                         instance_name: str,
+                         region: Optional[str] = None) -> List[AWSInstance]:
+    """Get all instances from an AWS account with matching name tag.
+
+    Args:
+      instance_name (str): The instance name tag.
+      region (str): Optional. The region to look the instance in.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+
+    Returns:
+      List[AWSInstance]: A list of EC2 Instance objects. If no instance with
+          matching name tag is found, the method returns an empty list.
+    """
+
+    instances = self.ListInstances(region=region)
+    return [instance for instance in instances.values() if
+            instance.name == instance_name]
+
+  def GetInstanceById(self,
+                      instance_id: str,
+                      region: Optional[str] = None) -> AWSInstance:
+    """Get an instance from an AWS account by its ID.
+
+    Args:
+      instance_id (str): The instance id.
+      region (str): Optional. The region to look the instance in.
+          If none provided, the default_region associated to the AWSAccount
+          object will be used.
+
+    Returns:
+      AWSInstance: An Amazon EC2 Instance object.
+
+    Raises:
+      RuntimeError: If instance does not exist.
+    """
+
+    instances = self.ListInstances(region=region)
+    instance = instances.get(instance_id)
+    if not instance:
+      error_msg = 'Instance {0:s} was not found in AWS account'.format(
+          instance_id)
+      raise RuntimeError(error_msg)
+    return instance
+
+  def ListImages(
+      self,
+      qfilter: Optional[List[Dict[str, Any]]] = None) -> List[Dict[str, Any]]:
+    """List AMI images.
+
+    Args:
+      qfilter (List[Dict]): The filter expression.
+      See https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2.html#EC2.Client.describe_images  # pylint: disable=line-too-long
+
+    Returns:
+      List[Dict[str, Any]]: The list of images with their properties.
+
+    Raises:
+      RuntimeError: If the images could not be listed.
+    """
+    if not qfilter:
+      qfilter = []
+
+    client = self.aws_account.ClientApi(common.EC2_SERVICE)
+    try:
+      images = client.describe_images(
+          Filters=qfilter)  # type: Dict[str, List[Dict[str, Any]]]
+    except client.exceptions.ClientError as exception:
+      raise RuntimeError(str(exception))
+
+    return images['Images']
+
+  def GetOrCreateAnalysisVm(
+      self,
+      vm_name: str,
+      boot_volume_size: int,
+      ami: str,
+      cpu_cores: int,
+      boot_volume_type: str = 'gp2',
+      packages: Optional[List[str]] = None,
+      ssh_key_name: Optional[str] = None,
+      tags: Optional[Dict[str, str]] = None) -> Tuple[AWSInstance, bool]:
+    """Get or create a new virtual machine for analysis purposes.
+
+    Args:
+      vm_name (str): The instance name tag of the virtual machine.
+      boot_volume_size (int): The size of the analysis VM boot volume (in GB).
+      ami (str): The Amazon Machine Image ID to use to create the VM.
+      cpu_cores (int): Number of CPU cores for the analysis VM.
+      boot_volume_type (str): Optional. The volume type for the boot volume
+          of the VM. Can be one of 'standard'|'io1'|'gp2'|'sc1'|'st1'. The
+          default is 'gp2'.
+      packages (List[str]): Optional. List of packages to install in the VM.
+      ssh_key_name (str): Optional. A SSH key pair name linked to the AWS
+          account to associate with the VM. If none provided, the VM can only
+          be accessed through in-browser SSH from the AWS management console
+          with the EC2 client connection package (ec2-instance-connect). Note
+          that if this package fails to install on the target VM, then the VM
+          will not be accessible. It is therefore recommended to fill in this
+          parameter.
+      tags (Dict[str, str]): Optional. A dictionary of tags to add to the
+          instance, for example {'TicketID': 'xxx'}. An entry for the instance
+          name is added by default.
+
+    Returns:
+      Tuple[AWSInstance, bool]: A tuple with an AWSInstance object and a
+          boolean indicating if the virtual machine was created (True) or
+          reused (False).
+
+    Raises:
+      RuntimeError: If the virtual machine cannot be found or created.
+    """
+
+    # Re-use instance if it already exists, or create a new one.
+    try:
+      instances = self.GetInstancesByName(vm_name)
+      if instances:
+        created = False
+        return instances[0], created
+    except RuntimeError:
+      pass
+
+    instance_type = common.GetInstanceTypeByCPU(cpu_cores)
+    startup_script = utils.ReadStartupScript()
+    if packages:
+      startup_script = startup_script.replace('${packages[@]}', ' '.join(
+          packages))
+
+    # Install ec2-instance-connect to allow SSH connections from the browser.
+    startup_script = startup_script.replace(
+        '(exit ${exit_code})',
+        'apt -y install ec2-instance-connect && (exit ${exit_code})')
+
+    if not tags:
+      tags = {}
+    tags['Name'] = vm_name
+
+    client = self.aws_account.ClientApi(common.EC2_SERVICE)
+    vm_args = {
+        'BlockDeviceMappings':
+            [self._GetBootVolumeConfigByAmi(
+                ami, boot_volume_size, boot_volume_type)],
+        'ImageId': ami,
+        'MinCount': 1,
+        'MaxCount': 1,
+        'InstanceType': instance_type,
+        'TagSpecifications': [common.CreateTags(common.INSTANCE, tags)],
+        'UserData': startup_script,
+        'Placement': {
+            'AvailabilityZone': self.aws_account.default_availability_zone}
+    }
+    if ssh_key_name:
+      vm_args['KeyName'] = ssh_key_name
+    # Create the instance in AWS
+    try:
+      instance = client.run_instances(**vm_args)
+      # If the call to run_instances was successful, then the API response
+      # contains the instance ID for the new instance.
+      instance_id = instance['Instances'][0]['InstanceId']
+      # Wait for the instance to be running
+      client.get_waiter('instance_running').wait(InstanceIds=[instance_id])
+      # Wait for the status checks to pass
+      client.get_waiter('instance_status_ok').wait(InstanceIds=[instance_id])
+    except (client.exceptions.ClientError,
+            botocore.exceptions.WaiterError) as exception:
+      raise RuntimeError('Could not create instance {0:s}: {1:s}'.format(
+          vm_name, str(exception)))
+
+    instance = AWSInstance(self.aws_account,
+                           instance_id,
+                           self.aws_account.default_region,
+                           self.aws_account.default_availability_zone,
+                           name=vm_name)
+    created = True
+    return instance, created
+
+  def _GetBootVolumeConfigByAmi(self,
+                                ami: str,
+                                boot_volume_size: int,
+                                boot_volume_type: str) -> Dict[str, Any]:
+    """Return a boot volume configuration for a given AMI and boot volume size.
+
+    Args:
+      ami (str): The Amazon Machine Image ID.
+      boot_volume_size (int): Size of the boot volume, in GB.
+      boot_volume_type (str): Type of the boot volume.
+
+    Returns:
+      Dict[str, str|Dict]]: A BlockDeviceMappings configuration for the
+          specified AMI.
+
+    Raises:
+      RuntimeError: If AMI details cannot be found.
+    """
+
+    client = self.aws_account.ClientApi(common.EC2_SERVICE)
+    try:
+      image = client.describe_images(ImageIds=[ami])
+    except client.exceptions.ClientError as exception:
+      raise RuntimeError(
+          'Could not find image information for AMI {0:s}: {1:s}'.format(
+              ami, str(exception)))
+
+    # If the call to describe_images was successful, then the API's response
+    # is expected to contain at least one image and its corresponding block
+    # device mappings information.
+    # pylint: disable=line-too-long
+    block_device_mapping = image['Images'][0]['BlockDeviceMappings'][0]  # type: Dict[str, Any]
+    # pylint: enable=line-too-long
+    block_device_mapping['Ebs']['VolumeSize'] = boot_volume_size
+    block_device_mapping['Ebs']['VolumeType'] = boot_volume_type
+    if boot_volume_type == 'io1':
+      # If using the io1 volume type, we must specify Iops, see
+      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/
+      # services/ec2.html#EC2.Client.create_volume. io1 volumes allow for a
+      # ratio of 50 IOPS per 1 GiB.
+      block_device_mapping['Ebs']['Iops'] = boot_volume_size * 50
+    return block_device_mapping
+
+  def GenerateSSHKeyPair(self, vm_name: str) -> Tuple[str, str]:
+    """Generate a SSH key pair and returns its name and private key.
+
+    Args:
+      vm_name (str): The VM name for which to generate the key pair.
+
+    Returns:
+      Tuple[str, str]: A tuple containing the key name and the private key for
+          the generated SSH key pair.
+
+    Raises:
+      ValueError: If vm_name is None.
+      RuntimeError: If the key could not be created.
+    """
+
+    if not vm_name:
+      raise ValueError('Parameter vm_name must not be None.')
+
+    # SSH key names need to be unique, therefore we add a random 10 chars hex
+    # string.
+    key_name = '{0:s}-{1:s}-ssh'.format(
+        vm_name, binascii.b2a_hex(os.urandom(10)).decode('utf-8'))
+    client = self.aws_account.ClientApi(common.EC2_SERVICE)
+    try:
+      key = client.create_key_pair(KeyName=key_name)
+    except client.exceptions.ClientError as exception:
+      raise RuntimeError('Could not create SSH key pair: {0:s}'.format(
+          str(exception)))
+    # If the call was successful, the response contains key information
+    return key['KeyName'], key['KeyMaterial']
diff --git a/libcloudforensics/providers/aws/internal/kms.py b/libcloudforensics/providers/aws/internal/kms.py
new file mode 100644
index 0000000..daf2949
--- /dev/null
+++ b/libcloudforensics/providers/aws/internal/kms.py
@@ -0,0 +1,124 @@
+# -*- coding: utf-8 -*-
+# Copyright 2020 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""KMS functionality."""
+import json
+from typing import Optional, TYPE_CHECKING
+
+from libcloudforensics.providers.aws.internal import common
+
+if TYPE_CHECKING:
+  # TYPE_CHECKING is always False at runtime, therefore it is safe to ignore
+  # the following cyclic import, as it it only used for type hints
+  from libcloudforensics.providers.aws.internal import account  # pylint: disable=cyclic-import
+
+
+class KMS:
+  """Class that represents AWS KMS services."""
+
+  def __init__(self,
+               aws_account: 'account.AWSAccount') -> None:
+    """Initialize the AWS KMS client object.
+
+    Args:
+      aws_account (AWSAccount): An AWS account object.
+    """
+    self.aws_account = aws_account
+
+  def CreateKMSKey(self) -> str:
+    """Create a KMS key.
+
+    Returns:
+      str: The KMS key ID for the key that was created.
+
+    Raises:
+      RuntimeError: If the key could not be created.
+    """
+
+    client = self.aws_account.ClientApi(common.KMS_SERVICE)
+    try:
+      kms_key = client.create_key()
+    except client.exceptions.ClientError as exception:
+      raise RuntimeError('Could not create KMS key: {0:s}'.format(
+          str(exception)))
+
+    # The response contains the key ID
+    key_id = kms_key['KeyMetadata']['KeyId']  # type: str
+    return key_id
+
+  def ShareKMSKeyWithAWSAccount(self,
+                                kms_key_id: str,
+                                aws_account_id: str) -> None:
+    """Share a KMS key.
+
+    Args:
+      kms_key_id (str): The KMS key ID of the key to share.
+      aws_account_id (str): The AWS Account ID to share the KMS key with.
+
+    Raises:
+      RuntimeError: If the key could not be shared.
+    """
+
+    share_policy = {
+        'Sid': 'Allow use of the key',
+        'Effect': 'Allow',
+        'Principal': {
+            'AWS': 'arn:aws:iam::{0:s}:root'.format(aws_account_id)
+        },
+        'Action': [
+            # kms:*crypt and kms:ReEncrypt* are necessary to transfer
+            # encrypted EBS resources across accounts.
+            'kms:Encrypt',
+            'kms:Decrypt',
+            'kms:ReEncrypt*',
+            # kms:CreateGrant is necessary to transfer encrypted EBS
+            # resources across regions.
+            'kms:CreateGrant'
+        ],
+        'Resource': '*'
+    }
+    client = self.aws_account.ClientApi(common.KMS_SERVICE)
+    try:
+      policy = json.loads(client.get_key_policy(
+          KeyId=kms_key_id, PolicyName='default')['Policy'])
+      policy['Statement'].append(share_policy)
+      # Update the key policy so that it is shared with the AWS account.
+      client.put_key_policy(
+          KeyId=kms_key_id, PolicyName='default', Policy=json.dumps(policy))
+    except client.exceptions.ClientError as exception:
+      raise RuntimeError('Could not share KMS key {0:s}: {1:s}'.format(
+          kms_key_id, str(exception)))
+
+  def DeleteKMSKey(self, kms_key_id: Optional[str] = None) -> None:
+    """Delete a KMS key.
+
+    Schedule the KMS key for deletion. By default, users have a 30 days
+        window before the key gets deleted.
+
+    Args:
+      kms_key_id (str): The ID of the KMS key to delete.
+
+    Raises:
+      RuntimeError: If the key could not be scheduled for deletion.
+    """
+
+    if not kms_key_id:
+      return
+
+    client = self.aws_account.ClientApi(common.KMS_SERVICE)
+    try:
+      client.schedule_key_deletion(KeyId=kms_key_id)
+    except client.exceptions.ClientError as exception:
+      raise RuntimeError('Could not schedule the KMS key: {0:s} for '
+                         'deletion'.format(str(exception)))
diff --git a/tools/aws_cli.py b/tools/aws_cli.py
index c64aa65..4ca6fe2 100644
--- a/tools/aws_cli.py
+++ b/tools/aws_cli.py
@@ -43,7 +43,7 @@ def ListInstances(args: 'argparse.Namespace') -> None:
   """
 
   aws_account = account.AWSAccount(args.zone)
-  instances = aws_account.ListInstances()
+  instances = aws_account.ec2.ListInstances()
 
   logger.info('Instances found:')
   for instance in instances:
@@ -59,7 +59,7 @@ def ListVolumes(args: 'argparse.Namespace') -> None:
   """
 
   aws_account = account.AWSAccount(args.zone)
-  volumes = aws_account.ListVolumes()
+  volumes = aws_account.ebs.ListVolumes()
 
   logger.info('Volumes found:')
   for volume in volumes:
@@ -146,7 +146,8 @@ def StartAnalysisVm(args: 'argparse.Namespace') -> None:
   if args.generate_ssh_key_pair:
     logger.info('Generating SSH key pair for the analysis VM.')
     aws_account = account.AWSAccount(args.zone)
-    key_name, private_key = aws_account.GenerateSSHKeyPair(args.instance_name)
+    key_name, private_key = aws_account.ec2.GenerateSSHKeyPair(
+        args.instance_name)
     path = os.path.join(os.getcwd(), key_name + '.pem')
     with open(path, 'w') as f:
       f.write(private_key)
@@ -181,7 +182,7 @@ def ListImages(args: 'argparse.Namespace') -> None:
 
   qfilter = [{'Name': 'name', 'Values': [args.filter]}]
 
-  images = aws_account.ListImages(qfilter)
+  images = aws_account.ec2.ListImages(qfilter)
 
   for image in images:
     logger.info('Name: {0:s}, ImageId: {1:s}, Location: {2:s}'.format(


Test patch:
diff --git a/tests/providers/aws/aws_e2e.py b/tests/providers/aws/aws_e2e.py
index 5d80779..f576af0 100644
--- a/tests/providers/aws/aws_e2e.py
+++ b/tests/providers/aws/aws_e2e.py
@@ -153,7 +153,7 @@ class EndToEndTest(unittest.TestCase):
 
     aws_account = account.AWSAccount(self.zone)
     qfilter = [{'Name': 'name', 'Values': ['Ubuntu 18.04*']}]
-    images = aws_account.ListImages(qfilter)
+    images = aws_account.ec2.ListImages(qfilter)
 
     self.assertGreater(len(images), 0)
     self.assertIn('Name', images[0])
diff --git a/tests/providers/aws/aws_test.py b/tests/providers/aws/aws_test.py
index d3988e4..2925f3c 100644
--- a/tests/providers/aws/aws_test.py
+++ b/tests/providers/aws/aws_test.py
@@ -13,6 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Tests for aws module."""
+
 import typing
 import unittest
 
@@ -156,7 +157,8 @@ MOCK_CREATE_SNAPSHOT = {
 }
 
 MOCK_CALLER_IDENTITY = {
-    'UserId': 'fake-user-id'
+    'UserId': 'fake-user-id',
+    'Account': 'fake-account-id'
 }
 
 MOCK_DESCRIBE_AMI = {
@@ -191,7 +193,7 @@ class AWSAccountTest(unittest.TestCase):
     """Test that instances of an account are correctly listed."""
     describe_instances = mock_ec2_api.return_value.describe_instances
     describe_instances.return_value = MOCK_DESCRIBE_INSTANCES
-    instances = FAKE_AWS_ACCOUNT.ListInstances()
+    instances = FAKE_AWS_ACCOUNT.ec2.ListInstances()
     self.assertEqual(1, len(instances))
     self.assertIn('fake-instance-id', instances)
     self.assertEqual('fake-zone-2', instances['fake-instance-id'].region)
@@ -199,7 +201,7 @@ class AWSAccountTest(unittest.TestCase):
         'fake-zone-2b', instances['fake-instance-id'].availability_zone)
 
     describe_instances.return_value = MOCK_DESCRIBE_INSTANCES_TAGS
-    instances = FAKE_AWS_ACCOUNT.ListInstances()
+    instances = FAKE_AWS_ACCOUNT.ec2.ListInstances()
     self.assertIn('fake-instance-with-name-id', instances)
     self.assertEqual(
         'fake-instance', instances['fake-instance-with-name-id'].name)
@@ -210,7 +212,7 @@ class AWSAccountTest(unittest.TestCase):
     """Test that volumes of an account are correctly listed."""
     describe_volumes = mock_ec2_api.return_value.describe_volumes
     describe_volumes.return_value = MOCK_DESCRIBE_VOLUMES
-    volumes = FAKE_AWS_ACCOUNT.ListVolumes()
+    volumes = FAKE_AWS_ACCOUNT.ebs.ListVolumes()
     self.assertEqual(2, len(volumes))
     self.assertIn('fake-volume-id', volumes)
     self.assertIn('fake-boot-volume-id', volumes)
@@ -219,31 +221,31 @@ class AWSAccountTest(unittest.TestCase):
         'fake-zone-2b', volumes['fake-volume-id'].availability_zone)
 
     describe_volumes.return_value = MOCK_DESCRIBE_VOLUMES_TAGS
-    volumes = FAKE_AWS_ACCOUNT.ListVolumes()
+    volumes = FAKE_AWS_ACCOUNT.ebs.ListVolumes()
     self.assertIn('fake-boot-volume-id', volumes)
     self.assertEqual('fake-boot-volume', volumes['fake-boot-volume-id'].name)
     self.assertEqual('/dev/spf', volumes['fake-boot-volume-id'].device_name)
 
   @typing.no_type_check
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListInstances')
+  @mock.patch('libcloudforensics.providers.aws.internal.ec2.EC2.ListInstances')
   def testGetInstanceById(self, mock_list_instances):
     """Test that an instance of an account can be found by its ID."""
     mock_list_instances.return_value = MOCK_LIST_INSTANCES
-    found_instance = FAKE_AWS_ACCOUNT.GetInstanceById(
+    found_instance = FAKE_AWS_ACCOUNT.ec2.GetInstanceById(
         FAKE_INSTANCE.instance_id)
     self.assertIsInstance(found_instance, ec2.AWSInstance)
     self.assertEqual('fake-instance-id', found_instance.instance_id)
     self.assertEqual('fake-zone-2', found_instance.region)
     self.assertEqual('fake-zone-2b', found_instance.availability_zone)
     with self.assertRaises(RuntimeError):
-      FAKE_AWS_ACCOUNT.GetInstanceById('non-existent-instance-id')
+      FAKE_AWS_ACCOUNT.ec2.GetInstanceById('non-existent-instance-id')
 
   @typing.no_type_check
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListInstances')
+  @mock.patch('libcloudforensics.providers.aws.internal.ec2.EC2.ListInstances')
   def testGetInstancesByName(self, mock_list_instances):
     """Test that an instance of an account can be found by its name."""
     mock_list_instances.return_value = MOCK_LIST_INSTANCES
-    found_instances = FAKE_AWS_ACCOUNT.GetInstancesByName(
+    found_instances = FAKE_AWS_ACCOUNT.ec2.GetInstancesByName(
         FAKE_INSTANCE_WITH_NAME.name)
     self.assertEqual(1, len(found_instances))
     self.assertIsInstance(found_instances[0], ec2.AWSInstance)
@@ -252,40 +254,40 @@ class AWSAccountTest(unittest.TestCase):
     self.assertEqual('fake-zone-2', found_instances[0].region)
     self.assertEqual('fake-zone-2b', found_instances[0].availability_zone)
 
-    found_instances = FAKE_AWS_ACCOUNT.GetInstancesByName(
+    found_instances = FAKE_AWS_ACCOUNT.ec2.GetInstancesByName(
         'non-existent-instance-name')
     self.assertEqual(0, len(found_instances))
 
   @typing.no_type_check
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListInstances')
+  @mock.patch('libcloudforensics.providers.aws.internal.ec2.EC2.ListInstances')
   def testGetInstancesByNameOrId(self, mock_list_instances):
     """Test that an instance of an account can be found by its name or ID."""
     mock_list_instances.return_value = MOCK_LIST_INSTANCES
-    found_instances = FAKE_AWS_ACCOUNT.GetInstancesByNameOrId(
+    found_instances = FAKE_AWS_ACCOUNT.ec2.GetInstancesByNameOrId(
         instance_id=FAKE_INSTANCE.instance_id)
     self.assertEqual(1, len(found_instances))
     self.assertEqual('fake-instance-id', found_instances[0].instance_id)
 
-    found_instances = FAKE_AWS_ACCOUNT.GetInstancesByNameOrId(
+    found_instances = FAKE_AWS_ACCOUNT.ec2.GetInstancesByNameOrId(
         instance_name=FAKE_INSTANCE_WITH_NAME.name)
     self.assertEqual(1, len(found_instances))
     self.assertEqual(
         'fake-instance-with-name-id', found_instances[0].instance_id)
 
     with self.assertRaises(ValueError):
-      FAKE_AWS_ACCOUNT.GetInstancesByNameOrId()
+      FAKE_AWS_ACCOUNT.ec2.GetInstancesByNameOrId()
 
     with self.assertRaises(ValueError):
-      FAKE_AWS_ACCOUNT.GetInstancesByNameOrId(
+      FAKE_AWS_ACCOUNT.ec2.GetInstancesByNameOrId(
           instance_id=FAKE_INSTANCE.instance_id,
           instance_name=FAKE_INSTANCE_WITH_NAME.name)
 
   @typing.no_type_check
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListVolumes')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.ListVolumes')
   def testGetVolumeById(self, mock_list_volumes):
     """Test that a volume of an account can be found by its ID."""
     mock_list_volumes.return_value = MOCK_LIST_VOLUMES
-    found_volume = FAKE_AWS_ACCOUNT.GetVolumeById(
+    found_volume = FAKE_AWS_ACCOUNT.ebs.GetVolumeById(
         FAKE_VOLUME.volume_id)
     self.assertIsInstance(found_volume, ebs.AWSVolume)
     self.assertEqual('fake-volume-id', found_volume.volume_id)
@@ -293,42 +295,42 @@ class AWSAccountTest(unittest.TestCase):
     self.assertEqual('fake-zone-2b', found_volume.availability_zone)
 
   @typing.no_type_check
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListVolumes')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.ListVolumes')
   def testGetVolumesByName(self, mock_list_volumes):
     """Test that a volume of an account can be found by its name."""
     mock_list_volumes.return_value = MOCK_LIST_VOLUMES
-    found_volumes = FAKE_AWS_ACCOUNT.GetVolumesByName(
+    found_volumes = FAKE_AWS_ACCOUNT.ebs.GetVolumesByName(
         FAKE_BOOT_VOLUME.name)
     self.assertEqual(1, len(found_volumes))
     self.assertEqual('fake-boot-volume-id', found_volumes[0].volume_id)
     self.assertEqual('fake-zone-2', found_volumes[0].region)
     self.assertEqual('fake-zone-2b', found_volumes[0].availability_zone)
 
-    found_volumes = FAKE_AWS_ACCOUNT.GetVolumesByName(
+    found_volumes = FAKE_AWS_ACCOUNT.ebs.GetVolumesByName(
         'non-existent-volume-name')
     self.assertEqual(0, len(found_volumes))
 
   @typing.no_type_check
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListVolumes')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.ListVolumes')
   def testGetVolumesByNameOrId(self, mock_list_volumes):
     """Test that a volume of an account can be found by its name or ID."""
     mock_list_volumes.return_value = MOCK_LIST_VOLUMES
-    found_volumes = FAKE_AWS_ACCOUNT.GetVolumesByNameOrId(
+    found_volumes = FAKE_AWS_ACCOUNT.ebs.GetVolumesByNameOrId(
         volume_id=FAKE_VOLUME.volume_id)
     self.assertEqual(1, len(found_volumes))
     self.assertEqual('fake-volume-id', found_volumes[0].volume_id)
 
-    found_volumes = FAKE_AWS_ACCOUNT.GetVolumesByNameOrId(
+    found_volumes = FAKE_AWS_ACCOUNT.ebs.GetVolumesByNameOrId(
         volume_name=FAKE_BOOT_VOLUME.name)
     self.assertEqual(1, len(found_volumes))
     self.assertEqual(
         'fake-boot-volume-id', found_volumes[0].volume_id)
 
     with self.assertRaises(ValueError):
-      FAKE_AWS_ACCOUNT.GetVolumesByNameOrId()
+      FAKE_AWS_ACCOUNT.ebs.GetVolumesByNameOrId()
 
     with self.assertRaises(ValueError):
-      FAKE_AWS_ACCOUNT.GetVolumesByNameOrId(
+      FAKE_AWS_ACCOUNT.ebs.GetVolumesByNameOrId(
           volume_id=FAKE_VOLUME.volume_id,
           volume_name=FAKE_BOOT_VOLUME.name)
 
@@ -342,7 +344,7 @@ class AWSAccountTest(unittest.TestCase):
 
     # CreateVolumeFromSnapshot(
     #     Snapshot=FAKE_SNAPSHOT, volume_name=None, volume_name_prefix='')
-    volume_from_snapshot = FAKE_AWS_ACCOUNT.CreateVolumeFromSnapshot(
+    volume_from_snapshot = FAKE_AWS_ACCOUNT.ebs.CreateVolumeFromSnapshot(
         FAKE_SNAPSHOT)
     self.assertIsInstance(volume_from_snapshot, ebs.AWSVolume)
     self.assertEqual(
@@ -353,7 +355,7 @@ class AWSAccountTest(unittest.TestCase):
     #     Snapshot=FAKE_SNAPSHOT,
     #     volume_name='new-forensics-volume',
     #     volume_name_prefix='')
-    volume_from_snapshot = FAKE_AWS_ACCOUNT.CreateVolumeFromSnapshot(
+    volume_from_snapshot = FAKE_AWS_ACCOUNT.ebs.CreateVolumeFromSnapshot(
         FAKE_SNAPSHOT, volume_name='new-forensics-volume')
     self.assertIsInstance(volume_from_snapshot, ebs.AWSVolume)
     self.assertEqual(
@@ -362,7 +364,7 @@ class AWSAccountTest(unittest.TestCase):
 
     # CreateVolumeFromSnapshot(
     #     Snapshot=FAKE_SNAPSHOT, volume_name=None, volume_name_prefix='prefix')
-    volume_from_snapshot = FAKE_AWS_ACCOUNT.CreateVolumeFromSnapshot(
+    volume_from_snapshot = FAKE_AWS_ACCOUNT.ebs.CreateVolumeFromSnapshot(
         FAKE_SNAPSHOT, volume_name_prefix='prefix')
     self.assertIsInstance(volume_from_snapshot, ebs.AWSVolume)
     self.assertEqual(
@@ -371,14 +373,14 @@ class AWSAccountTest(unittest.TestCase):
         'prefix-fake-snapshot-d69d57c3-copy', volume_from_snapshot.name)
 
     with self.assertRaises(ValueError) as error:
-      FAKE_AWS_ACCOUNT.CreateVolumeFromSnapshot(
+      FAKE_AWS_ACCOUNT.ebs.CreateVolumeFromSnapshot(
           FAKE_SNAPSHOT, volume_type='invalid-volume-type')
     self.assertEqual('Volume type must be one of [standard, io1, gp2, sc1, '
                      'st1]. Got: invalid-volume-type', str(error.exception))
 
   @typing.no_type_check
   @mock.patch('libcloudforensics.scripts.utils.ReadStartupScript')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.GetInstancesByName')
+  @mock.patch('libcloudforensics.providers.aws.internal.ec2.EC2.GetInstancesByName')
   @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ClientApi')
   def testGetOrCreateAnalysisVm(self,
                                 mock_ec2_api,
@@ -389,7 +391,7 @@ class AWSAccountTest(unittest.TestCase):
     mock_script.return_value = ''
     # GetOrCreateAnalysisVm(vm_name, boot_volume_size, AMI, cpu_cores) where
     # vm_name is the name of an analysis instance that already exists.
-    vm, created = FAKE_AWS_ACCOUNT.GetOrCreateAnalysisVm(
+    vm, created = FAKE_AWS_ACCOUNT.ec2.GetOrCreateAnalysisVm(
         FAKE_INSTANCE_WITH_NAME.name, 1, 'ami-id', 2)
     mock_get_instance.assert_called_with(FAKE_INSTANCE_WITH_NAME.name)
     mock_ec2_api.return_value.run_instances.assert_not_called()
@@ -402,7 +404,7 @@ class AWSAccountTest(unittest.TestCase):
     # an instance that wasn't found. This should trigger run_instances to be
     # called.
     mock_get_instance.side_effect = RuntimeError()
-    vm, created = FAKE_AWS_ACCOUNT.GetOrCreateAnalysisVm(
+    vm, created = FAKE_AWS_ACCOUNT.ec2.GetOrCreateAnalysisVm(
         'non-existent-instance-name', 1, 'ami-id', 2)
     mock_get_instance.assert_called_with('non-existent-instance-name')
     mock_ec2_api.return_value.run_instances.assert_called()
@@ -421,10 +423,10 @@ class AWSAccountTest(unittest.TestCase):
     caller_identity = mock_ec2_api.return_value.get_caller_identity
     caller_identity.return_value = MOCK_CALLER_IDENTITY
     # pylint: disable=protected-access
-    volume_name = FAKE_AWS_ACCOUNT._GenerateVolumeName(FAKE_SNAPSHOT)
+    volume_name = FAKE_AWS_ACCOUNT.ebs._GenerateVolumeName(FAKE_SNAPSHOT)
     self.assertEqual('fake-snapshot-d69d57c3-copy', volume_name)
 
-    volume_name = FAKE_AWS_ACCOUNT._GenerateVolumeName(
+    volume_name = FAKE_AWS_ACCOUNT.ebs._GenerateVolumeName(
         FAKE_SNAPSHOT, volume_name_prefix='prefix')
     # pylint: enable=protected-access
     self.assertEqual('prefix-fake-snapshot-d69d57c3-copy', volume_name)
@@ -439,7 +441,7 @@ class AWSAccountTest(unittest.TestCase):
         MOCK_DESCRIBE_AMI['Images'][0]['BlockDeviceMappings'][0]['Ebs']['VolumeSize'])
     self.assertIsNone(
         MOCK_DESCRIBE_AMI['Images'][0]['BlockDeviceMappings'][0]['Ebs']['VolumeType'])
-    config = FAKE_AWS_ACCOUNT._GetBootVolumeConfigByAmi(
+    config = FAKE_AWS_ACCOUNT.ec2._GetBootVolumeConfigByAmi(
         'ami-id', 50, 'fake-volume-type')
     # pylint: enable=protected-access,line-too-long
     self.assertEqual(50, config['Ebs']['VolumeSize'])
@@ -451,7 +453,7 @@ class AWSAccountTest(unittest.TestCase):
     """Test that AMI images are correctly listed."""
     describe_images = mock_ec2_api.return_value.describe_images
     describe_images.return_value = MOCK_DESCRIBE_IMAGES
-    images = FAKE_AWS_ACCOUNT.ListImages()
+    images = FAKE_AWS_ACCOUNT.ec2.ListImages()
     self.assertEqual(2, len(images))
     self.assertIn('Name', images[0])
 
@@ -572,8 +574,8 @@ class AWSTest(unittest.TestCase):
   @mock.patch('boto3.session.Session._setup_loader')
   @mock.patch('libcloudforensics.providers.aws.internal.ebs.AWSVolume.GetVolumeType')
   @mock.patch('libcloudforensics.providers.aws.internal.ebs.AWSVolume.Snapshot')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.GetVolumeById')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.GetAccountInformation')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.GetVolumeById')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.GetAccountInformation')
   @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ClientApi')
   def testCreateVolumeCopy1(self,
                             mock_ec2_api,
@@ -585,7 +587,7 @@ class AWSTest(unittest.TestCase):
     """Test that a volume is correctly cloned."""
     FAKE_SNAPSHOT.name = FAKE_VOLUME.volume_id
     mock_ec2_api.return_value.create_volume.return_value = MOCK_CREATE_VOLUME
-    mock_account.return_value = 'fake-account-id'
+    mock_account.return_value = MOCK_CALLER_IDENTITY
     mock_get_volume.return_value = FAKE_VOLUME
     mock_snapshot.return_value = FAKE_SNAPSHOT
     mock_volume_type.return_value = 'standard'
@@ -606,8 +608,8 @@ class AWSTest(unittest.TestCase):
   @mock.patch('libcloudforensics.providers.aws.internal.ebs.AWSVolume.GetVolumeType')
   @mock.patch('libcloudforensics.providers.aws.internal.ebs.AWSVolume.Snapshot')
   @mock.patch('libcloudforensics.providers.aws.internal.ec2.AWSInstance.GetBootVolume')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.GetInstanceById')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.GetAccountInformation')
+  @mock.patch('libcloudforensics.providers.aws.internal.ec2.EC2.GetInstanceById')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.GetAccountInformation')
   @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ClientApi')
   def testCreateVolumeCopy2(self,
                             mock_ec2_api,
@@ -620,7 +622,7 @@ class AWSTest(unittest.TestCase):
     """Test that a volume is correctly cloned."""
     FAKE_SNAPSHOT.name = FAKE_BOOT_VOLUME.volume_id
     mock_ec2_api.return_value.create_volume.return_value = MOCK_CREATE_VOLUME
-    mock_account.return_value = 'fake-account-id'
+    mock_account.return_value = MOCK_CALLER_IDENTITY
     mock_get_instance.return_value = FAKE_INSTANCE
     mock_get_volume.return_value = FAKE_BOOT_VOLUME
     mock_snapshot.return_value = FAKE_SNAPSHOT
@@ -639,8 +641,8 @@ class AWSTest(unittest.TestCase):
 
   @typing.no_type_check
   @mock.patch('boto3.session.Session._setup_loader')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListVolumes')
-  @mock.patch('libcloudforensics.providers.aws.internal.account.AWSAccount.ListInstances')
+  @mock.patch('libcloudforensics.providers.aws.internal.ebs.EBS.ListVolumes')
+  @mock.patch('libcloudforensics.providers.aws.internal.ec2.EC2.ListInstances')
   def testCreateVolumeCopy3(self,
                             mock_list_instances,
                             mock_list_volumes,