Add support for AWS IMDSv2

This is an authenticated http metadata service which is typically
available by default, but a more secure setup is to enforce its
usage.

This change adds the ability to do that for both instances and
AMIs.

Change-Id: Ia8554ff0baec260289da0574b92932b37ffe5f04
This commit is contained in:
James E. Blair 2024-01-24 11:19:49 -08:00
parent 42f9100d82
commit 3f4fb008b0
8 changed files with 290 additions and 2 deletions

View File

@ -431,6 +431,15 @@ Selecting the ``aws`` driver adds the following options to the
:value:`providers.[aws].diskimages.import-method.snapshot`
import method.
.. attr:: imds-support
:type: str
To enforce usage of IMDSv2 by default on instances created
from the image, set this value to `v2.0`. If omitted, IMDSv2
is optional by default. This is only supported using the
:value:`providers.[aws].diskimages.import-method.snapshot`
import method.
.. attr:: import-method
:default: snapshot
@ -669,6 +678,29 @@ Selecting the ``aws`` driver adds the following options to the
ARN identifier of the profile.
Mutually exclusive with :attr:`providers.[aws].pools.labels.iam-instance-profile.name`
.. attr:: imdsv2
:type: str
Specify whether IMDSv2 is required. If this is omitted,
then AWS defaults are used (usually equivalent to
`optional` but may be influenced by the image used).
.. value:: optional
Allows usage of IMDSv2 but do not require it. This
sets the following metadata options:
* `HttpTokens` is `optional`
* `HttpEndpoint` is `enabled`
.. value:: required
Require IMDSv2. This sets the following metadata
options:
* `HttpTokens` is `required`
* `HttpEndpoint` is `enabled`
.. attr:: key-name
:type: string
:required:

View File

@ -512,6 +512,12 @@ class AwsAdapter(statemachine.Adapter):
bucket = self.s3.Bucket(bucket_name)
object_filename = f'{image_name}.{image_format}'
extra_args = {'Tagging': urllib.parse.urlencode(metadata)}
# There is no IMDS support option for the import_image call
if (provider_image.import_method == 'image' and
provider_image.imds_support == 'v2.0'):
raise Exception("IMDSv2 requires 'snapshot' import method")
with open(filename, "rb") as fobj:
with self.rate_limiter:
bucket.upload_fileobj(fobj, object_filename,
@ -618,7 +624,7 @@ class AwsAdapter(statemachine.Adapter):
if provider_image.throughput:
bdm['Ebs']['Throughput'] = provider_image.throughput
register_response = self.ec2_client.register_image(
args = dict(
Architecture=provider_image.architecture,
BlockDeviceMappings=[bdm],
RootDeviceName='/dev/sda1',
@ -626,6 +632,9 @@ class AwsAdapter(statemachine.Adapter):
EnaSupport=provider_image.ena_support,
Name=image_name,
)
if provider_image.imds_support == 'v2.0':
args['ImdsSupport'] = 'v2.0'
register_response = self.ec2_client.register_image(**args)
# Tag the AMI
try:
@ -1211,6 +1220,17 @@ class AwsAdapter(statemachine.Adapter):
}
}
if label.imdsv2 == 'required':
args['MetadataOptions'] = {
'HttpTokens': 'required',
'HttpEndpoint': 'enabled',
}
elif label.imdsv2 == 'optional':
args['MetadataOptions'] = {
'HttpTokens': 'optional',
'HttpEndpoint': 'enabled',
}
with self.rate_limiter(log.debug, "Created instance"):
log.debug("Creating VM %s", hostname)
resp = self.ec2_client.run_instances(**args)

View File

@ -105,6 +105,10 @@ class AwsProviderDiskImage(ConfigValue):
self.volume_size = image.get('volume-size', None)
self.volume_type = image.get('volume-type', 'gp2')
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 != 'snapshot'):
raise Exception("IMDSv2 requires 'snapshot' import method")
self.iops = image.get('iops', None)
self.throughput = image.get('throughput', None)
@ -128,6 +132,7 @@ class AwsProviderDiskImage(ConfigValue):
'volume-size': int,
'volume-type': str,
'import-method': v.Any('snapshot', 'image'),
'imds-support': v.Any('v2.0', None),
'iops': int,
'throughput': int,
'tags': dict,
@ -180,6 +185,7 @@ class AwsLabel(ConfigValue):
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)
@staticmethod
def getSchema():
@ -202,6 +208,7 @@ class AwsLabel(ConfigValue):
'tags': dict,
'dynamic-tags': dict,
'use-spot': bool,
'imdsv2': v.Any(None, 'required', 'optional'),
}

View File

@ -24,6 +24,7 @@ labels:
- name: ubuntu1404-with-tags
- name: ubuntu1404-with-shell-type
- name: ubuntu1404-ebs-optimized
- name: ubuntu1404-imdsv2
providers:
- name: ec2-us-west-2
@ -115,3 +116,8 @@ providers:
ebs-optimized: True
instance-type: t3.medium
key-name: zuul
- name: ubuntu1404-imdsv2
cloud-image: ubuntu1404
instance-type: t3.medium
key-name: zuul
imdsv2: required

View File

@ -0,0 +1,68 @@
elements-dir: .
images-dir: '{images_dir}'
build-log-dir: '{build_log_dir}'
build-log-retention: 1
zookeeper-servers:
- host: {zookeeper_host}
port: {zookeeper_port}
chroot: {zookeeper_chroot}
zookeeper-tls:
ca: {zookeeper_ca}
cert: {zookeeper_cert}
key: {zookeeper_key}
tenant-resource-limits:
- tenant-name: tenant-1
max-cores: 1024
labels:
- name: diskimage
providers:
- name: ec2-us-west-2
driver: aws
rate: 2
region-name: us-west-2
object-storage:
bucket-name: nodepool
image-import-timeout: 60
diskimages:
- name: fake-image
tags:
provider_metadata: provider
import-method: image
iops: 1000
throughput: 100
imds-support: v2.0
pools:
- name: main
max-servers: 1
subnet-id: {subnet_id}
security-group-id: {security_group_id}
node-attributes:
key1: value1
key2: value2
labels:
- name: diskimage
diskimage: fake-image
instance-type: t3.medium
key-name: zuul
iops: 2000
throughput: 200
diskimages:
- name: fake-image
elements:
- fedora-minimal
- vm
release: 21
dib-cmd: nodepool/tests/fake-image-create
env-vars:
TMPDIR: /opt/dib_tmp
DIB_IMAGE_CACHE: /opt/dib_cache
DIB_CLOUD_IMAGES: http://download.fedoraproject.org/pub/fedora/linux/releases/test/21-Beta/Cloud/Images/x86_64/
BASE_IMAGE_FILE: Fedora-Cloud-Base-20141029-21_Beta.x86_64.qcow2
metadata:
diskimage_metadata: diskimage

View File

@ -0,0 +1,69 @@
elements-dir: .
images-dir: '{images_dir}'
build-log-dir: '{build_log_dir}'
build-log-retention: 1
zookeeper-servers:
- host: {zookeeper_host}
port: {zookeeper_port}
chroot: {zookeeper_chroot}
zookeeper-tls:
ca: {zookeeper_ca}
cert: {zookeeper_cert}
key: {zookeeper_key}
tenant-resource-limits:
- tenant-name: tenant-1
max-cores: 1024
labels:
- name: diskimage
providers:
- name: ec2-us-west-2
driver: aws
rate: 2
region-name: us-west-2
object-storage:
bucket-name: nodepool
image-import-timeout: 60
diskimages:
- name: fake-image
tags:
provider_metadata: provider
volume-type: gp3
iops: 1000
throughput: 100
imds-support: v2.0
pools:
- name: main
max-servers: 1
subnet-id: {subnet_id}
security-group-id: {security_group_id}
node-attributes:
key1: value1
key2: value2
labels:
- name: diskimage
diskimage: fake-image
instance-type: t3.medium
key-name: zuul
iops: 2000
throughput: 200
diskimages:
- name: fake-image
elements:
- fedora-minimal
- vm
release: 21
dib-cmd: nodepool/tests/fake-image-create
env-vars:
TMPDIR: /opt/dib_tmp
DIB_IMAGE_CACHE: /opt/dib_cache
DIB_CLOUD_IMAGES: http://download.fedoraproject.org/pub/fedora/linux/releases/test/21-Beta/Cloud/Images/x86_64/
BASE_IMAGE_FILE: Fedora-Cloud-Base-20141029-21_Beta.x86_64.qcow2
metadata:
diskimage_metadata: diskimage
username: another_user

View File

@ -52,6 +52,11 @@ class FakeAwsAdapter(AwsAdapter):
self.__testcase.run_instance_calls.append(kwargs)
return self.ec2_client.run_instances_orig(*args, **kwargs)
# The ImdsSupport parameter isn't handled by moto
def _fake_register_image(*args, **kwargs):
self.__testcase.register_image_calls.append(kwargs)
return self.ec2_client.register_image_orig(*args, **kwargs)
def _fake_get_paginator(*args, **kwargs):
try:
return self.__testcase.fake_aws.get_paginator(*args, **kwargs)
@ -60,6 +65,8 @@ class FakeAwsAdapter(AwsAdapter):
self.ec2_client.run_instances_orig = self.ec2_client.run_instances
self.ec2_client.run_instances = _fake_run_instances
self.ec2_client.register_image_orig = self.ec2_client.register_image
self.ec2_client.register_image = _fake_register_image
self.ec2_client.import_snapshot = \
self.__testcase.fake_aws.import_snapshot
self.ec2_client.import_image = \
@ -157,8 +164,9 @@ class TestDriverAws(tests.DBTestCase):
Bucket='nodepool',
CreateBucketConfiguration={'LocationConstraint': 'us-west-2'})
# A list of args to create instance for validation
# A list of args to method calls for validation
self.run_instance_calls = []
self.register_image_calls = []
# TEST-NET-3
ipv6 = False
@ -568,6 +576,9 @@ class TestDriverAws(tests.DBTestCase):
response = instance.describe_attribute(Attribute='ebsOptimized')
self.assertFalse(response['EbsOptimized']['Value'])
self.assertFalse(
'MetadataOptions' in self.run_instance_calls[0])
node.state = zk.USED
self.zk.storeNode(node)
self.waitForNodeDeletion(node)
@ -755,6 +766,20 @@ class TestDriverAws(tests.DBTestCase):
response = instance.describe_attribute(Attribute='ebsOptimized')
self.assertTrue(response['EbsOptimized']['Value'])
def test_aws_imdsv2(self):
req = self.requestNode('aws/aws.yaml',
'ubuntu1404-imdsv2')
node = self.assertSuccess(req)
self.assertEqual(node.host_keys, ['ssh-rsa FAKEKEY'])
self.assertEqual(node.image_id, 'ubuntu1404')
self.assertEqual(
self.run_instance_calls[0]['MetadataOptions']['HttpTokens'],
'required')
self.assertEqual(
self.run_instance_calls[0]['MetadataOptions']['HttpEndpoint'],
'enabled')
def test_aws_invalid_instance_type(self):
req = self.requestNode('aws/aws-invalid.yaml', 'ubuntu-invalid')
self.assertEqual(req.state, zk.FAILED)
@ -782,6 +807,7 @@ class TestDriverAws(tests.DBTestCase):
ec2_image = self.ec2.Image(image.external_id)
self.assertEqual(ec2_image.state, 'available')
self.assertFalse('ImdsSupport' in self.register_image_calls[0])
self.assertTrue({'Key': 'diskimage_metadata', 'Value': 'diskimage'}
in ec2_image.tags)
self.assertTrue({'Key': 'provider_metadata', 'Value': 'provider'}
@ -858,6 +884,60 @@ class TestDriverAws(tests.DBTestCase):
self.run_instance_calls[0]['BlockDeviceMappings'][0]['Ebs']
['Throughput'], 200)
def test_aws_diskimage_snapshot_imdsv2(self):
self.fake_aws.fail_import_count = 1
configfile = self.setup_config('aws/diskimage-imdsv2-snapshot.yaml')
self.useBuilder(configfile)
image = self.waitForImage('ec2-us-west-2', 'fake-image')
self.assertEqual(image.username, 'another_user')
ec2_image = self.ec2.Image(image.external_id)
self.assertEqual(ec2_image.state, 'available')
self.assertEqual(
self.register_image_calls[0]['ImdsSupport'], 'v2.0')
self.assertTrue({'Key': 'diskimage_metadata', 'Value': 'diskimage'}
in ec2_image.tags)
self.assertTrue({'Key': 'provider_metadata', 'Value': 'provider'}
in ec2_image.tags)
pool = self.useNodepool(configfile, watermark_sleep=1)
self.startPool(pool)
req = zk.NodeRequest()
req.state = zk.REQUESTED
req.node_types.append('diskimage')
self.zk.storeNodeRequest(req)
req = self.waitForNodeRequest(req)
self.assertEqual(req.state, zk.FULFILLED)
self.assertNotEqual(req.nodes, [])
node = self.zk.getNode(req.nodes[0])
self.assertEqual(node.allocated_to, req.id)
self.assertEqual(node.state, zk.READY)
self.assertIsNotNone(node.launcher)
self.assertEqual(node.connection_type, 'ssh')
self.assertEqual(node.shell_type, None)
self.assertEqual(node.username, 'another_user')
self.assertEqual(node.attributes,
{'key1': 'value1', 'key2': 'value2'})
self.assertEqual(
self.run_instance_calls[0]['BlockDeviceMappings'][0]['Ebs']
['Iops'], 2000)
self.assertEqual(
self.run_instance_calls[0]['BlockDeviceMappings'][0]['Ebs']
['Throughput'], 200)
def test_aws_diskimage_image_imdsv2(self):
self.fake_aws.fail_import_count = 1
configfile = self.setup_config('aws/diskimage-imdsv2-image.yaml')
with testtools.ExpectedException(Exception, "IMDSv2 requires"):
self.useBuilder(configfile)
def test_aws_diskimage_removal(self):
configfile = self.setup_config('aws/diskimage.yaml')
self.useBuilder(configfile)

View File

@ -0,0 +1,6 @@
---
features:
- |
Support for requiring IMDSv2 in AWS is now available using these options:
:attr:`providers.[aws].pools.labels.imdsv2` and
:attr:`providers.[aws].diskimages.imds-support`