diff --git a/doc/source/aws.rst b/doc/source/aws.rst index 8d3d79ae2..3acb1822b 100644 --- a/doc/source/aws.rst +++ b/doc/source/aws.rst @@ -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: diff --git a/nodepool/driver/aws/adapter.py b/nodepool/driver/aws/adapter.py index fdda2451d..98628dad1 100644 --- a/nodepool/driver/aws/adapter.py +++ b/nodepool/driver/aws/adapter.py @@ -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) diff --git a/nodepool/driver/aws/config.py b/nodepool/driver/aws/config.py index 37348755d..93a2e9d6a 100644 --- a/nodepool/driver/aws/config.py +++ b/nodepool/driver/aws/config.py @@ -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'), } diff --git a/nodepool/tests/fixtures/aws/aws.yaml b/nodepool/tests/fixtures/aws/aws.yaml index 642115361..7c2a75965 100644 --- a/nodepool/tests/fixtures/aws/aws.yaml +++ b/nodepool/tests/fixtures/aws/aws.yaml @@ -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 diff --git a/nodepool/tests/fixtures/aws/diskimage-imdsv2-image.yaml b/nodepool/tests/fixtures/aws/diskimage-imdsv2-image.yaml new file mode 100644 index 000000000..c55f38aa4 --- /dev/null +++ b/nodepool/tests/fixtures/aws/diskimage-imdsv2-image.yaml @@ -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 diff --git a/nodepool/tests/fixtures/aws/diskimage-imdsv2-snapshot.yaml b/nodepool/tests/fixtures/aws/diskimage-imdsv2-snapshot.yaml new file mode 100644 index 000000000..fd7b1d9c7 --- /dev/null +++ b/nodepool/tests/fixtures/aws/diskimage-imdsv2-snapshot.yaml @@ -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 diff --git a/nodepool/tests/unit/test_driver_aws.py b/nodepool/tests/unit/test_driver_aws.py index 2937befdc..a5c5dc6e2 100644 --- a/nodepool/tests/unit/test_driver_aws.py +++ b/nodepool/tests/unit/test_driver_aws.py @@ -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) diff --git a/releasenotes/notes/imdsv2-44e9e973b6c2a562.yaml b/releasenotes/notes/imdsv2-44e9e973b6c2a562.yaml new file mode 100644 index 000000000..aba6f94d6 --- /dev/null +++ b/releasenotes/notes/imdsv2-44e9e973b6c2a562.yaml @@ -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`