James E. Blair fc2d0ba4b8 Add EBS direct image upload to AWS
This adds a third (!) method of uploading images to AWS.  It uploads
blocks directly to an EBS snapshot.  It bypasses S3 which makes it
faster and more efficient, but it may incur additional costs, so it
does not replace the existing less-costly options.

The process is very similar to the existing process used in the Azure
driver, so a new helper abstraction is created that should help
support both drivers.  A later change may update the Azure driver to
use the new abstraction.

Change-Id: I5cb707386b4b61987f94862d70067350ae17d80d
Co-Authored-By: Tobias Henkel <tobias.henkel@bmw.de>
2024-07-09 16:04:59 -07:00

386 lines
14 KiB
Python

# Copyright 2018 Red Hat
# Copyright 2022 Acme Gating, LLC
#
# 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.
from collections import defaultdict
import math
import voluptuous as v
from nodepool.driver import ConfigPool
from nodepool.driver import ConfigValue
from nodepool.driver import ProviderConfig
class AwsProviderCloudImage(ConfigValue):
def __init__(self, image):
default_port_mapping = {
'ssh': 22,
'winrm': 5986,
}
self.name = image['name']
self.username = image['username']
self.image_id = image.get('image-id')
self.python_path = image.get('python-path', 'auto')
self.shell_type = image.get('shell-type')
self.connection_type = image.get('connection-type', 'ssh')
self.connection_port = image.get(
'connection-port',
default_port_mapping.get(self.connection_type, 22))
image_filters = image.get("image-filters", None)
if image_filters is not None:
# ensure 'name' and 'values' keys are capitalized for boto
def capitalize_keys(image_filter):
return {
k.capitalize(): v for (k, v) in image_filter.items()
}
image_filters = [capitalize_keys(f) for f in image_filters]
self.image_filters = image_filters
@property
def external_name(self):
'''Human readable version of external.'''
return (self.image_id or self.name)
@staticmethod
def getSchema():
image_filters = {
v.Any('Name', 'name'): str,
v.Any('Values', 'values'): [str]
}
return v.All({
v.Required('name'): str,
v.Required('username'): str,
v.Exclusive('image-id', 'spec'): str,
v.Exclusive('image-filters', 'spec'): [image_filters],
'connection-type': str,
'connection-port': int,
'python-path': str,
'shell-type': str,
}, {
v.Required(
v.Any('image-id', 'image-filters'),
msg=('Provide either '
'"image-filters", or "image-id" keys')
): object,
object: object,
})
class AwsProviderDiskImage(ConfigValue):
def __init__(self, image_type, image, diskimage):
default_port_mapping = {
'ssh': 22,
'winrm': 5986,
}
self.name = image['name']
diskimage.image_types.add(image_type)
self.pause = bool(image.get('pause', False))
self.python_path = image.get('python-path', 'auto')
self.shell_type = image.get('shell-type')
self.username = image.get('username', diskimage.username)
self.connection_type = image.get('connection-type', 'ssh')
self.connection_port = image.get(
'connection-port',
default_port_mapping.get(self.connection_type, 22))
self.meta = image.get('tags', {})
self.architecture = image.get('architecture', 'x86_64')
self.ena_support = image.get('ena-support', True)
self.volume_size = image.get('volume-size', None)
self.volume_type = image.get('volume-type', 'gp3')
self.import_method = image.get('import-method', 'snapshot')
self.imds_support = image.get('imds-support', None)
if (self.imds_support == 'v2.0' and
self.import_method == 'image'):
raise Exception("IMDSv2 requires 'snapshot' or 'ebs-direct' "
"import method")
self.iops = image.get('iops', None)
self.throughput = image.get('throughput', None)
@property
def external_name(self):
'''Human readable version of external.'''
return self.name
@staticmethod
def getSchema():
return {
v.Required('name'): str,
'username': str,
'pause': bool,
'connection-type': str,
'connection-port': int,
'python-path': str,
'shell-type': str,
'architecture': str,
'ena-support': bool,
'volume-size': int,
'volume-type': str,
'import-method': v.Any('snapshot', 'ebs-direct', 'image'),
'imds-support': v.Any('v2.0', None),
'iops': int,
'throughput': int,
'tags': dict,
}
class AwsLabel(ConfigValue):
ignore_equality = ['pool']
def __init__(self, label, provider_config, provider_pool):
self.name = label['name']
self.pool = provider_pool
cloud_image_name = label.get('cloud-image', None)
if cloud_image_name:
cloud_image = provider_config.cloud_images.get(
cloud_image_name, None)
if not cloud_image:
raise ValueError(
"cloud-image %s does not exist in provider %s"
" but is referenced in label %s" %
(cloud_image_name, provider_config.name, self.name))
self.cloud_image = cloud_image
else:
self.cloud_image = None
diskimage_name = label.get('diskimage')
if diskimage_name:
diskimage = provider_config.diskimages.get(
diskimage_name, None)
if not diskimage:
raise ValueError(
"diskimage %s does not exist in provider %s"
" but is referenced in label %s" %
(diskimage_name, provider_config.name, self.name))
self.diskimage = diskimage
else:
self.diskimage = None
self.ebs_optimized = bool(label.get('ebs-optimized', False))
self.instance_type = label['instance-type']
self.key_name = label.get('key-name')
self.volume_type = label.get('volume-type')
self.volume_size = label.get('volume-size')
self.iops = label.get('iops', None)
self.throughput = label.get('throughput', None)
self.userdata = label.get('userdata', None)
self.iam_instance_profile = label.get('iam-instance-profile', None)
self.tags = label.get('tags', {})
self.dynamic_tags = label.get('dynamic-tags', {})
self.host_key_checking = self.pool.host_key_checking
self.use_spot = bool(label.get('use-spot', False))
self.imdsv2 = label.get('imdsv2', None)
self.dedicated_host = bool(label.get('dedicated-host', False))
if self.dedicated_host:
if self.use_spot:
raise Exception(
"Spot instances can not be used on dedicated hosts")
if not self.pool.az:
raise Exception(
"Availability-zone is required for dedicated hosts")
@staticmethod
def getSchema():
return {
v.Required('name'): str,
v.Exclusive('cloud-image', 'image'): str,
v.Exclusive('diskimage', 'image'): str,
v.Required('instance-type'): str,
v.Required('key-name'): str,
'ebs-optimized': bool,
'volume-type': str,
'volume-size': int,
'iops': int,
'throughput': int,
'userdata': str,
'iam-instance-profile': {
v.Exclusive('name', 'iam_instance_profile_id'): str,
v.Exclusive('arn', 'iam_instance_profile_id'): str
},
'tags': dict,
'dynamic-tags': dict,
'use-spot': bool,
'imdsv2': v.Any(None, 'required', 'optional'),
'dedicated-host': bool,
}
class AwsPool(ConfigPool):
ignore_equality = ['provider']
def __init__(self, provider_config, pool_config):
super().__init__()
self.provider = provider_config
self.load(pool_config)
def load(self, pool_config):
super().load(pool_config)
self.name = pool_config['name']
self.security_group_id = pool_config.get('security-group-id')
self.subnet_id = pool_config.get('subnet-id')
self.public_ipv4 = pool_config.get(
'public-ipv4', self.provider.public_ipv4)
self.public_ipv6 = pool_config.get(
'public-ipv6', self.provider.public_ipv6)
# TODO: Deprecate public-ip-address
self.public_ipv4 = pool_config.get(
'public-ip-address', self.public_ipv4)
self.use_internal_ip = pool_config.get(
'use-internal-ip', self.provider.use_internal_ip)
self.host_key_checking = pool_config.get(
'host-key-checking', self.provider.host_key_checking)
self.max_servers = pool_config.get(
'max-servers', self.provider.max_servers)
self.max_cores = pool_config.get('max-cores', self.provider.max_cores)
self.max_ram = pool_config.get('max-ram', self.provider.max_ram)
self.max_resources = self.provider.max_resources.copy()
for k, val in pool_config.get('max-resources', {}).items():
self.max_resources[k] = val
self.az = pool_config.get('availability-zone')
@staticmethod
def getSchema():
aws_label = AwsLabel.getSchema()
pool = ConfigPool.getCommonSchemaDict()
pool.update({
v.Required('name'): str,
v.Required('labels'): [aws_label],
'security-group-id': str,
'subnet-id': str,
'public-ip-address': bool,
'public-ipv4': bool,
'public-ipv6': bool,
'host-key-checking': bool,
'max-cores': int,
'max-ram': int,
'max-resources': {str: int},
'availability-zone': str,
})
return pool
class AwsProviderConfig(ProviderConfig):
def __init__(self, driver, provider):
super().__init__(provider)
self._pools = {}
self.rate = None
self.launch_retries = None
self.profile_name = None
self.region_name = None
self.boot_timeout = None
self.launch_retries = None
self.cloud_images = {}
self.diskimages = {}
@property
def pools(self):
return self._pools
@property
def manage_images(self):
return True
@staticmethod
def reset():
pass
def load(self, config):
self.profile_name = self.provider.get('profile-name')
self.region_name = self.provider.get('region-name')
self.rate = self.provider.get('rate', 2)
self.launch_retries = self.provider.get('launch-retries', 3)
self.launch_timeout = self.provider.get('launch-timeout', 3600)
self.boot_timeout = self.provider.get('boot-timeout', 180)
self.use_internal_ip = self.provider.get('use-internal-ip', False)
self.host_key_checking = self.provider.get('host-key-checking', True)
self.public_ipv4 = self.provider.get('public-ipv4', True)
self.public_ipv6 = self.provider.get('public-ipv6', False)
self.object_storage = self.provider.get('object-storage')
self.image_type = self.provider.get('image-format', 'raw')
self.image_name_format = '{image_name}-{timestamp}'
self.image_import_timeout = self.provider.get(
'image-import-timeout', None)
self.post_upload_hook = self.provider.get('post-upload-hook')
self.max_servers = self.provider.get('max-servers', math.inf)
self.max_cores = self.provider.get('max-cores', math.inf)
self.max_ram = self.provider.get('max-ram', math.inf)
self.max_resources = defaultdict(lambda: math.inf)
for k, val in self.provider.get('max-resources', {}).items():
self.max_resources[k] = val
self.cloud_images = {}
for image in self.provider.get('cloud-images', []):
i = AwsProviderCloudImage(image)
self.cloud_images[i.name] = i
self.diskimages = {}
for image in self.provider.get('diskimages', []):
diskimage = config.diskimages[image['name']]
i = AwsProviderDiskImage(self.image_type, image, diskimage)
self.diskimages[i.name] = i
for pool in self.provider.get('pools', []):
pp = AwsPool(self, pool)
self._pools[pp.name] = pp
for label in pool.get('labels', []):
pl = AwsLabel(label, self, pp)
pp.labels[pl.name] = pl
config.labels[pl.name].pools.append(pp)
def getSchema(self):
pool = AwsPool.getSchema()
provider_cloud_images = AwsProviderCloudImage.getSchema()
provider_diskimages = AwsProviderDiskImage.getSchema()
object_storage = {
v.Required('bucket-name'): str,
}
provider = ProviderConfig.getCommonSchemaDict()
provider.update({
v.Required('pools'): [pool],
v.Required('region-name'): str,
'rate': v.Any(int, float),
'profile-name': str,
'cloud-images': [provider_cloud_images],
'diskimages': [provider_diskimages],
'boot-timeout': int,
'launch-timeout': int,
'launch-retries': int,
'object-storage': object_storage,
'image-format': v.Any('ova', 'vhd', 'vhdx', 'vmdk', 'raw'),
'image-import-timeout': int,
'max-servers': int,
'max-cores': int,
'max-ram': int,
'max-resources': {str: int},
'post-upload-hook': str,
})
return v.Schema(provider)
def getSupportedLabels(self, pool_name=None):
labels = set()
for pool in self.pools.values():
if not pool_name or (pool.name == pool_name):
labels.update(pool.labels.keys())
return labels