From 4ca0186c53891206b00be1a8534c8958bed09134 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Mon, 21 Sep 2020 11:25:04 -0400 Subject: [PATCH 01/15] Issue #477 - ignore EC2 instances with dedicated or host tenancy from On-Demand Running Instances limits --- CHANGES.rst | 5 + awslimitchecker/services/ec2.py | 12 ++ .../tests/services/result_fixtures.py | 138 +++++++++++++++--- 3 files changed, 131 insertions(+), 24 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ab807a22..a6c44460 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +Unreleased Changes +------------------ + +* `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. + .. _changelog.8_1_0: 8.1.0 (2020-09-18) diff --git a/awslimitchecker/services/ec2.py b/awslimitchecker/services/ec2.py index cbc70f56..6d68a746 100644 --- a/awslimitchecker/services/ec2.py +++ b/awslimitchecker/services/ec2.py @@ -314,6 +314,12 @@ def _instance_usage(self): logger.info("Spot instance found (%s); skipping from " "Running On-Demand Instances count", inst.id) continue + if inst.placement.get('Tenancy', 'default') != 'default': + logger.info( + 'Skipping instance %s with Tenancy %s', + inst.id, inst.placement['Tenancy'] + ) + continue if inst.state['Name'] in ['stopped', 'terminated']: logger.debug("Ignoring instance %s in state %s", inst.id, inst.state['Name']) @@ -347,6 +353,12 @@ def _instance_usage_vcpu(self, ris): logger.info("Spot instance found (%s); skipping from " "Running On-Demand Instances count", inst.id) continue + if inst.placement.get('Tenancy', 'default') != 'default': + logger.info( + 'Skipping instance %s with Tenancy %s', + inst.id, inst.placement['Tenancy'] + ) + continue if inst.state['Name'] in ['stopped', 'terminated']: logger.debug("Ignoring instance %s in state %s", inst.id, inst.state['Name']) diff --git a/awslimitchecker/tests/services/result_fixtures.py b/awslimitchecker/tests/services/result_fixtures.py index 737fa984..6ece80d4 100644 --- a/awslimitchecker/tests/services/result_fixtures.py +++ b/awslimitchecker/tests/services/result_fixtures.py @@ -1849,54 +1849,88 @@ def test_instance_usage(self): type(mock_inst1A).id = '1A' type(mock_inst1A).instance_type = 't2.micro' type(mock_inst1A).spot_instance_request_id = None - type(mock_inst1A).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst1A).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst1A).state = {'Code': 16, 'Name': 'running'} mock_inst1B = Mock(spec_set=Instance) type(mock_inst1B).id = '1B' type(mock_inst1B).instance_type = 'r3.2xlarge' type(mock_inst1B).spot_instance_request_id = None - type(mock_inst1B).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst1B).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst1B).state = {'Code': 0, 'Name': 'pending'} + mock_inst1C = Mock(spec_set=Instance) + type(mock_inst1C).id = '1C' + type(mock_inst1C).instance_type = 't2.micro' + type(mock_inst1C).spot_instance_request_id = None + type(mock_inst1C).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'host' + } + type(mock_inst1C).state = {'Code': 16, 'Name': 'running'} + + mock_inst1D = Mock(spec_set=Instance) + type(mock_inst1D).id = '1D' + type(mock_inst1D).instance_type = 't2.micro' + type(mock_inst1D).spot_instance_request_id = None + type(mock_inst1D).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'dedicated' + } + type(mock_inst1D).state = {'Code': 16, 'Name': 'running'} + mock_inst2A = Mock(spec_set=Instance) type(mock_inst2A).id = '2A' type(mock_inst2A).instance_type = 'c4.4xlarge' type(mock_inst2A).spot_instance_request_id = None - type(mock_inst2A).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2A).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2A).state = {'Code': 32, 'Name': 'shutting-down'} mock_inst2B = Mock(spec_set=Instance) type(mock_inst2B).id = '2B' type(mock_inst2B).instance_type = 't2.micro' type(mock_inst2B).spot_instance_request_id = '1234' - type(mock_inst2B).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2B).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2B).state = {'Code': 64, 'Name': 'stopping'} mock_inst2C = Mock(spec_set=Instance) type(mock_inst2C).id = '2C' type(mock_inst2C).instance_type = 'm4.8xlarge' type(mock_inst2C).spot_instance_request_id = None - type(mock_inst2C).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2C).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2C).state = {'Code': 16, 'Name': 'running'} mock_instStopped = Mock(spec_set=Instance) type(mock_instStopped).id = '2C' type(mock_instStopped).instance_type = 'm4.8xlarge' type(mock_instStopped).spot_instance_request_id = None - type(mock_instStopped).placement = {'AvailabilityZone': 'az1a'} + type(mock_instStopped).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_instStopped).state = {'Code': 80, 'Name': 'stopped'} mock_instTerm = Mock(spec_set=Instance) type(mock_instTerm).id = '2C' type(mock_instTerm).instance_type = 'm4.8xlarge' type(mock_instTerm).spot_instance_request_id = None - type(mock_instTerm).placement = {'AvailabilityZone': 'az1a'} + type(mock_instTerm).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_instTerm).state = {'Code': 48, 'Name': 'terminated'} return_value = [ mock_inst1A, mock_inst1B, + mock_inst1C, + mock_inst1D, mock_inst2A, mock_inst2B, mock_inst2C, @@ -1911,7 +1945,9 @@ def test_instance_usage_vcpu(self): type(mock_inst1A).id = '1A' type(mock_inst1A).instance_type = 't2.micro' type(mock_inst1A).spot_instance_request_id = None - type(mock_inst1A).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst1A).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst1A).state = {'Code': 16, 'Name': 'running'} type(mock_inst1A).cpu_options = {'CoreCount': 1, 'ThreadsPerCore': 2} @@ -1919,10 +1955,32 @@ def test_instance_usage_vcpu(self): type(mock_inst1B).id = '1B' type(mock_inst1B).instance_type = 'r3.2xlarge' type(mock_inst1B).spot_instance_request_id = None - type(mock_inst1B).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst1B).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst1B).state = {'Code': 0, 'Name': 'pending'} type(mock_inst1B).cpu_options = {'CoreCount': 4, 'ThreadsPerCore': 2} + mock_inst1C = Mock(spec_set=Instance) + type(mock_inst1C).id = '1C' + type(mock_inst1C).instance_type = 't2.micro' + type(mock_inst1C).spot_instance_request_id = None + type(mock_inst1C).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'host' + } + type(mock_inst1C).state = {'Code': 16, 'Name': 'running'} + type(mock_inst1C).cpu_options = {'CoreCount': 1, 'ThreadsPerCore': 2} + + mock_inst1D = Mock(spec_set=Instance) + type(mock_inst1D).id = '1D' + type(mock_inst1D).instance_type = 't2.micro' + type(mock_inst1D).spot_instance_request_id = None + type(mock_inst1D).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'dedicated' + } + type(mock_inst1D).state = {'Code': 16, 'Name': 'running'} + type(mock_inst1D).cpu_options = {'CoreCount': 1, 'ThreadsPerCore': 2} + mock_inst2A = Mock(spec_set=Instance) type(mock_inst2A).id = '2A' type(mock_inst2A).instance_type = 'c4.4xlarge' @@ -1935,7 +1993,9 @@ def test_instance_usage_vcpu(self): type(mock_inst2B).id = '2B' type(mock_inst2B).instance_type = 't2.micro' type(mock_inst2B).spot_instance_request_id = '1234' - type(mock_inst2B).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2B).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2B).state = {'Code': 64, 'Name': 'stopping'} type(mock_inst2B).cpu_options = {'CoreCount': 1, 'ThreadsPerCore': 2} @@ -1943,7 +2003,9 @@ def test_instance_usage_vcpu(self): type(mock_inst2C).id = '2C' type(mock_inst2C).instance_type = 'm4.8xlarge' type(mock_inst2C).spot_instance_request_id = None - type(mock_inst2C).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2C).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2C).state = {'Code': 16, 'Name': 'running'} type(mock_inst2C).cpu_options = {'CoreCount': 16, 'ThreadsPerCore': 2} @@ -1951,7 +2013,9 @@ def test_instance_usage_vcpu(self): type(mock_instStopped).id = 'instStopped' type(mock_instStopped).instance_type = 'm4.8xlarge' type(mock_instStopped).spot_instance_request_id = None - type(mock_instStopped).placement = {'AvailabilityZone': 'az1a'} + type(mock_instStopped).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_instStopped).state = {'Code': 80, 'Name': 'stopped'} type(mock_instStopped).cpu_options = { 'CoreCount': 16, 'ThreadsPerCore': 2 @@ -1961,7 +2025,9 @@ def test_instance_usage_vcpu(self): type(mock_instTerm).id = '2C' type(mock_instTerm).instance_type = 'm4.8xlarge' type(mock_instTerm).spot_instance_request_id = None - type(mock_instTerm).placement = {'AvailabilityZone': 'az1a'} + type(mock_instTerm).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_instTerm).state = {'Code': 48, 'Name': 'terminated'} type(mock_instTerm).cpu_options = {'CoreCount': 16, 'ThreadsPerCore': 2} @@ -1969,7 +2035,9 @@ def test_instance_usage_vcpu(self): type(mock_inst2D).id = '2D' type(mock_inst2D).instance_type = 'f1.16xlarge' type(mock_inst2D).spot_instance_request_id = None - type(mock_inst2D).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2D).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2D).state = {'Code': 16, 'Name': 'running'} type(mock_inst2D).cpu_options = {'CoreCount': 32, 'ThreadsPerCore': 2} @@ -1977,7 +2045,9 @@ def test_instance_usage_vcpu(self): type(mock_inst2E).id = '2E' type(mock_inst2E).instance_type = 'f1.2xlarge' type(mock_inst2E).spot_instance_request_id = None - type(mock_inst2E).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2E).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2E).state = {'Code': 16, 'Name': 'running'} type(mock_inst2E).cpu_options = {'CoreCount': 4, 'ThreadsPerCore': 2} @@ -1985,7 +2055,9 @@ def test_instance_usage_vcpu(self): type(mock_inst2F).id = '2F' type(mock_inst2F).instance_type = 'g4dn.12xlarge' type(mock_inst2F).spot_instance_request_id = None - type(mock_inst2F).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst2F).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst2F).state = {'Code': 16, 'Name': 'running'} type(mock_inst2F).cpu_options = {'CoreCount': 12, 'ThreadsPerCore': 4} @@ -1993,7 +2065,9 @@ def test_instance_usage_vcpu(self): type(mock_inst1A).id = '3A' type(mock_inst3A).instance_type = 'p2.16xlarge' type(mock_inst3A).spot_instance_request_id = None - type(mock_inst3A).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3A).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3A).state = {'Code': 16, 'Name': 'running'} type(mock_inst3A).cpu_options = {'CoreCount': 32, 'ThreadsPerCore': 2} @@ -2001,7 +2075,9 @@ def test_instance_usage_vcpu(self): type(mock_inst3F).id = '3F' type(mock_inst3F).instance_type = 'p2.8xlarge' type(mock_inst3F).spot_instance_request_id = None - type(mock_inst3F).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3F).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3F).state = {'Code': 16, 'Name': 'running'} type(mock_inst3F).cpu_options = {'CoreCount': 16, 'ThreadsPerCore': 2} @@ -2009,7 +2085,9 @@ def test_instance_usage_vcpu(self): type(mock_inst3G).id = '3G' type(mock_inst3G).instance_type = 'p2.8xlarge' type(mock_inst3G).spot_instance_request_id = None - type(mock_inst3G).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3G).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3G).state = {'Code': 16, 'Name': 'running'} type(mock_inst3G).cpu_options = {'CoreCount': 16, 'ThreadsPerCore': 2} @@ -2017,7 +2095,9 @@ def test_instance_usage_vcpu(self): type(mock_inst3B).id = '3B' type(mock_inst3B).instance_type = 'r3.2xlarge' type(mock_inst3B).spot_instance_request_id = None - type(mock_inst3B).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3B).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3B).state = {'Code': 16, 'Name': 'running'} type(mock_inst3B).cpu_options = {'CoreCount': 4, 'ThreadsPerCore': 2} @@ -2025,7 +2105,9 @@ def test_instance_usage_vcpu(self): type(mock_inst3C).id = '3C' type(mock_inst3C).instance_type = 'x1e.32xlarge' type(mock_inst3C).spot_instance_request_id = None - type(mock_inst3C).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3C).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3C).state = {'Code': 32, 'Name': 'stopped'} type(mock_inst3C).cpu_options = {'CoreCount': 32, 'ThreadsPerCore': 4} @@ -2033,7 +2115,9 @@ def test_instance_usage_vcpu(self): type(mock_inst3D).id = '3D' type(mock_inst3D).instance_type = 'x1e.32xlarge' type(mock_inst3D).spot_instance_request_id = None - type(mock_inst3D).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3D).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3D).state = {'Code': 16, 'Name': 'running'} type(mock_inst3D).cpu_options = {'CoreCount': 32, 'ThreadsPerCore': 4} @@ -2041,13 +2125,17 @@ def test_instance_usage_vcpu(self): type(mock_inst3E).id = '3E' type(mock_inst3E).instance_type = 'x1e.32xlarge' type(mock_inst3E).spot_instance_request_id = None - type(mock_inst3E).placement = {'AvailabilityZone': 'az1c'} + type(mock_inst3E).placement = { + 'AvailabilityZone': 'az1c', 'Tenancy': 'default' + } type(mock_inst3E).state = {'Code': 16, 'Name': 'running'} type(mock_inst3E).cpu_options = {'CoreCount': 32, 'ThreadsPerCore': 4} return_value = [ mock_inst1A, mock_inst1B, + mock_inst1C, + mock_inst1D, mock_inst2A, mock_inst2B, mock_inst2C, @@ -2072,7 +2160,9 @@ def test_instance_usage_key_error(self): type(mock_inst1A).id = '1A' type(mock_inst1A).instance_type = 'foobar' type(mock_inst1A).spot_instance_request_id = None - type(mock_inst1A).placement = {'AvailabilityZone': 'az1a'} + type(mock_inst1A).placement = { + 'AvailabilityZone': 'az1a', 'Tenancy': 'default' + } type(mock_inst1A).state = {'Code': 16, 'Name': 'running'} return [mock_inst1A] From d920daacc46f61d1d6c42400ff82052a07d9c1ac Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Mon, 21 Sep 2020 12:00:05 -0400 Subject: [PATCH 02/15] Add sts:GetCallerIdentity permission requirement, and add current_account_id helper property to _AwsService --- CHANGES.rst | 2 ++ awslimitchecker/checker.py | 1 + awslimitchecker/services/base.py | 22 ++++++++++++ awslimitchecker/tests/services/test_base.py | 37 +++++++++++++++++++++ awslimitchecker/tests/test_checker.py | 1 + 5 files changed, 63 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index a6c44460..7bfa0fa0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,8 @@ Changelog Unreleased Changes ------------------ +**Important:** This release requires new IAM permissions: ``sts:GetCallerIdentity`` + * `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. .. _changelog.8_1_0: diff --git a/awslimitchecker/checker.py b/awslimitchecker/checker.py index 395115d9..e873abab 100644 --- a/awslimitchecker/checker.py +++ b/awslimitchecker/checker.py @@ -650,6 +650,7 @@ def get_required_iam_policy(self): required_actions = [ 'servicequotas:ListServiceQuotas', 'support:*', + 'sts:GetCallerIdentity', 'trustedadvisor:Describe*', 'trustedadvisor:RefreshCheck' ] diff --git a/awslimitchecker/services/base.py b/awslimitchecker/services/base.py index 5a1a715b..1fe6c5d2 100644 --- a/awslimitchecker/services/base.py +++ b/awslimitchecker/services/base.py @@ -39,6 +39,7 @@ import abc import logging +import boto3 from awslimitchecker.connectable import Connectable logger = logging.getLogger(__name__) @@ -90,6 +91,27 @@ def __init__(self, warning_threshold, critical_threshold, self.limits = {} self.limits = self.get_limits() self._have_usage = False + self._current_account_id = None + + @property + def current_account_id(self): + """ + Return the numeric Account ID for the account that we are currently + running against. + + :return: current account ID + :rtype: str + """ + if self._current_account_id is not None: + return self._current_account_id + kwargs = dict(self._boto3_connection_kwargs) + sts = boto3.client('sts', **kwargs) + logger.info( + "Connected to STS in region %s", sts._client_config.region_name + ) + cid = sts.get_caller_identity() + self._current_account_id = cid['Account'] + return cid['Account'] @abc.abstractmethod def find_usage(self): diff --git a/awslimitchecker/tests/services/test_base.py b/awslimitchecker/tests/services/test_base.py index 447df035..9b8f6e36 100644 --- a/awslimitchecker/tests/services/test_base.py +++ b/awslimitchecker/tests/services/test_base.py @@ -102,6 +102,7 @@ def test_init_subclass(self): assert cls._have_usage is False assert cls._boto3_connection_kwargs == {} assert cls._quotas_client == m_quota + assert cls._current_account_id is None def test_init_subclass_boto_xargs(self): boto_args = {'region_name': 'myregion', @@ -117,6 +118,7 @@ def test_init_subclass_boto_xargs(self): assert cls._have_usage is False assert cls._boto3_connection_kwargs == boto_args assert cls._quotas_client is None + assert cls._current_account_id is None def test_set_limit_override(self): mock_limit = Mock(spec_set=AwsLimit) @@ -374,6 +376,40 @@ def se_get_quota_value(_, quota_name, **kwargs): assert mock_limit1.mock_calls == [] assert mock_limit2.mock_calls == [] + def test_current_account_id_needed(self): + mock_conf = Mock(region_name='foo') + mock_sts = Mock(_client_config=mock_conf) + mock_sts.get_caller_identity.return_value = { + 'UserId': 'something', + 'Account': '123456789', + 'Arn': 'something' + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + cls._current_account_id = '987654321' + with patch('awslimitchecker.services.base.boto3.client') as m_boto: + m_boto.return_value = mock_sts + res = cls.current_account_id + assert res == '987654321' + assert m_boto.mock_calls == [] + + def test_current_account_id_stored(self): + mock_conf = Mock(region_name='foo') + mock_sts = Mock(_client_config=mock_conf) + mock_sts.get_caller_identity.return_value = { + 'UserId': 'something', + 'Account': '123456789', + 'Arn': 'something' + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch('awslimitchecker.services.base.boto3.client') as m_boto: + m_boto.return_value = mock_sts + res = cls.current_account_id + assert res == '123456789' + assert m_boto.mock_calls == [ + call('sts', foo='bar'), + call().get_caller_identity() + ] + class Test_AwsServiceSubclasses(object): @@ -397,6 +433,7 @@ def test_subclass_init(self, cls): assert inst.warning_threshold == 3 assert inst.critical_threshold == 7 assert not inst._boto3_connection_kwargs + assert inst._current_account_id is None boto_args = dict(region_name='myregion', aws_access_key_id='myaccesskey', diff --git a/awslimitchecker/tests/test_checker.py b/awslimitchecker/tests/test_checker.py index c09dce75..d7ad173e 100644 --- a/awslimitchecker/tests/test_checker.py +++ b/awslimitchecker/tests/test_checker.py @@ -851,6 +851,7 @@ def test_get_required_iam_policy(self): 'foo:perm1', 'foo:perm2', 'servicequotas:ListServiceQuotas', + 'sts:GetCallerIdentity', 'support:*', 'trustedadvisor:Describe*', 'trustedadvisor:RefreshCheck' From 3ac96f1f0dbbaba0549fcbf84379516b8b645a24 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Mon, 21 Sep 2020 12:14:09 -0400 Subject: [PATCH 03/15] Fixes #477 - Filter all VPC resources to the owner-id of the current account --- CHANGES.rst | 1 + awslimitchecker/services/vpc.py | 33 ++++++++++++++-------- awslimitchecker/tests/services/test_vpc.py | 33 ++++++++++++++++++---- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 7bfa0fa0..0818c115 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -7,6 +7,7 @@ Unreleased Changes **Important:** This release requires new IAM permissions: ``sts:GetCallerIdentity`` * `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. +* `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. .. _changelog.8_1_0: diff --git a/awslimitchecker/services/vpc.py b/awslimitchecker/services/vpc.py index 477560f5..3cc8742f 100644 --- a/awslimitchecker/services/vpc.py +++ b/awslimitchecker/services/vpc.py @@ -81,7 +81,9 @@ def find_usage(self): def _find_usage_vpcs(self): """find usage for VPCs""" # overall number of VPCs - vpcs = self.conn.describe_vpcs() + vpcs = self.conn.describe_vpcs( + Filters=[{'Name': 'owner-id', 'Values': [self.current_account_id]}] + ) self.limits['VPCs']._add_current_usage( len(vpcs['Vpcs']), aws_type='AWS::EC2::VPC' @@ -92,7 +94,9 @@ def _find_usage_subnets(self): # subnets per VPC subnet_to_az = {} subnets = defaultdict(int) - for subnet in self.conn.describe_subnets()['Subnets']: + for subnet in self.conn.describe_subnets( + Filters=[{'Name': 'owner-id', 'Values': [self.current_account_id]}] + )['Subnets']: subnets[subnet['VpcId']] += 1 subnet_to_az[subnet['SubnetId']] = subnet['AvailabilityZone'] for vpc_id in subnets: @@ -107,7 +111,9 @@ def _find_usage_ACLs(self): """find usage for ACLs""" # Network ACLs per VPC acls = defaultdict(int) - for acl in self.conn.describe_network_acls()['NetworkAcls']: + for acl in self.conn.describe_network_acls( + Filters=[{'Name': 'owner-id', 'Values': [self.current_account_id]}] + )['NetworkAcls']: acls[acl['VpcId']] += 1 # Rules per network ACL self.limits['Rules per network ACL']._add_current_usage( @@ -126,7 +132,9 @@ def _find_usage_route_tables(self): """find usage for route tables""" # Route tables per VPC tables = defaultdict(int) - for table in self.conn.describe_route_tables()['RouteTables']: + for table in self.conn.describe_route_tables( + Filters=[{'Name': 'owner-id', 'Values': [self.current_account_id]}] + )['RouteTables']: tables[table['VpcId']] += 1 # Entries per route table routes = [ @@ -148,7 +156,9 @@ def _find_usage_route_tables(self): def _find_usage_gateways(self): """find usage for Internet Gateways""" # Internet gateways - gws = self.conn.describe_internet_gateways() + gws = self.conn.describe_internet_gateways( + Filters=[{'Name': 'owner-id', 'Values': [self.current_account_id]}] + ) self.limits['Internet gateways']._add_current_usage( len(gws['InternetGateways']), aws_type='AWS::EC2::InternetGateway', @@ -166,11 +176,11 @@ def _find_usage_nat_gateways(self, subnet_to_az): # "This request has been administratively disabled." try: gws_per_az = defaultdict(int) - for gw in paginate_dict(self.conn.describe_nat_gateways, - alc_marker_path=['NextToken'], - alc_data_path=['NatGateways'], - alc_marker_param='NextToken' - )['NatGateways']: + for gw in paginate_dict( + self.conn.describe_nat_gateways, + alc_marker_path=['NextToken'], alc_data_path=['NatGateways'], + alc_marker_param='NextToken' + )['NatGateways']: if gw['State'] not in ['pending', 'available']: logger.debug( 'Skipping NAT Gateway %s in state: %s', @@ -220,7 +230,8 @@ def _find_usage_network_interfaces(self): self.conn.describe_network_interfaces, alc_marker_path=['NextToken'], alc_data_path=['NetworkInterfaces'], - alc_marker_param='NextToken' + alc_marker_param='NextToken', + Filters=[{'Name': 'owner-id', 'Values': [self.current_account_id]}] ) self.limits['Network interfaces per Region']._add_current_usage( diff --git a/awslimitchecker/tests/services/test_vpc.py b/awslimitchecker/tests/services/test_vpc.py index ed651035..0744d5e2 100644 --- a/awslimitchecker/tests/services/test_vpc.py +++ b/awslimitchecker/tests/services/test_vpc.py @@ -142,6 +142,7 @@ def test_find_usage_vpcs(self): mock_conn.describe_vpcs.return_value = response cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn cls._find_usage_vpcs() @@ -149,7 +150,9 @@ def test_find_usage_vpcs(self): assert len(cls.limits['VPCs'].get_current_usage()) == 1 assert cls.limits['VPCs'].get_current_usage()[0].get_value() == 2 assert mock_conn.mock_calls == [ - call.describe_vpcs() + call.describe_vpcs(Filters=[{ + 'Name': 'owner-id', 'Values': ['0123456789'] + }]) ] def test_find_usage_subnets(self): @@ -158,6 +161,7 @@ def test_find_usage_subnets(self): mock_conn = Mock() mock_conn.describe_subnets.return_value = response cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn res = cls._find_usage_subnets() @@ -174,7 +178,9 @@ def test_find_usage_subnets(self): assert usage[1].get_value() == 2 assert usage[1].resource_id == 'vpc-1' assert mock_conn.mock_calls == [ - call.describe_subnets() + call.describe_subnets(Filters=[{ + 'Name': 'owner-id', 'Values': ['0123456789'] + }]) ] def test_find_usage_acls(self): @@ -182,6 +188,7 @@ def test_find_usage_acls(self): mock_conn = Mock() cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn mock_conn.describe_network_acls.return_value = response @@ -203,7 +210,9 @@ def test_find_usage_acls(self): assert entries[2].resource_id == 'acl-3' assert entries[2].get_value() == 5 assert mock_conn.mock_calls == [ - call.describe_network_acls() + call.describe_network_acls(Filters=[{ + 'Name': 'owner-id', 'Values': ['0123456789'] + }]) ] def test_find_usage_route_tables(self): @@ -213,6 +222,7 @@ def test_find_usage_route_tables(self): mock_conn.describe_route_tables.return_value = response cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn cls._find_usage_route_tables() @@ -233,7 +243,9 @@ def test_find_usage_route_tables(self): assert entries[2].resource_id == 'rt-3' assert entries[2].get_value() == 3 assert mock_conn.mock_calls == [ - call.describe_route_tables() + call.describe_route_tables(Filters=[{ + 'Name': 'owner-id', 'Values': ['0123456789'] + }]) ] def test_find_usage_internet_gateways(self): @@ -243,6 +255,7 @@ def test_find_usage_internet_gateways(self): mock_conn.describe_internet_gateways.return_value = response cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn cls._find_usage_gateways() @@ -251,7 +264,9 @@ def test_find_usage_internet_gateways(self): assert cls.limits['Internet gateways'].get_current_usage()[ 0].get_value() == 2 assert mock_conn.mock_calls == [ - call.describe_internet_gateways() + call.describe_internet_gateways(Filters=[{ + 'Name': 'owner-id', 'Values': ['0123456789'] + }]) ] def test_find_usage_nat_gateways(self): @@ -263,6 +278,7 @@ def test_find_usage_nat_gateways(self): with patch('%s.logger' % self.pbm) as mock_logger: cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn cls._find_usage_nat_gateways(subnets) @@ -300,6 +316,7 @@ def se_exc(*args, **kwargs): mock_conn.describe_nat_gateways.side_effect = se_exc cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn with patch('%s.logger' % self.pbm, autospec=True) as mock_logger: @@ -322,6 +339,7 @@ def test_find_usages_vpn_gateways(self): mock_conn.describe_vpn_gateways.return_value = response cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn cls._find_usages_vpn_gateways() @@ -349,6 +367,7 @@ def test_find_usage_network_interfaces(self): mock_conn.describe_network_interfaces.return_value = response cls = _VpcService(21, 43, {}, None) + cls._current_account_id = '0123456789' cls.conn = mock_conn cls._find_usage_network_interfaces() @@ -358,7 +377,9 @@ def test_find_usage_network_interfaces(self): assert cls.limits['Network interfaces per Region'].get_current_usage()[ 0].get_value() == 1 assert mock_conn.mock_calls == [ - call.describe_network_interfaces(), + call.describe_network_interfaces(Filters=[{ + 'Name': 'owner-id', 'Values': ['0123456789'] + }]), ] def test_update_limits_from_api_high_max_instances(self): From 0ecc7ef4f268265e3d0e86104ec419507cd40a11 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Mon, 21 Sep 2020 13:34:57 -0400 Subject: [PATCH 04/15] Fixes #475 - if Alert Provider is used, only exit non-zero if an exception is thrown --- CHANGES.rst | 1 + awslimitchecker/runner.py | 2 ++ awslimitchecker/tests/test_runner.py | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0818c115..93b23aff 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,7 @@ Unreleased Changes * `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. * `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. +* `Issue #475 `__ - When an Alert Provider is used, only exit non-zero if an exception is encountered. Exit zero even if there are warnings and/or criticals. .. _changelog.8_1_0: diff --git a/awslimitchecker/runner.py b/awslimitchecker/runner.py index 684553e1..df61babf 100644 --- a/awslimitchecker/runner.py +++ b/awslimitchecker/runner.py @@ -540,6 +540,8 @@ def console_entry_point(self): ) else: alerter.on_success(duration=time.time() - start_time) + # with alert provider, always exit zero + raise SystemExit(0) raise SystemExit(res) diff --git a/awslimitchecker/tests/test_runner.py b/awslimitchecker/tests/test_runner.py index ac234c63..e80f472d 100644 --- a/awslimitchecker/tests/test_runner.py +++ b/awslimitchecker/tests/test_runner.py @@ -1893,7 +1893,7 @@ def test_check_thresholds_warn_with_alerter(self): type(mock_alc.return_value).region_name = mock_rn mock_ct.return_value = 1, {'Foo': 'bar'}, 'FooBar' self.cls.console_entry_point() - assert excinfo.value.code == 1 + assert excinfo.value.code == 0 assert mock_ct.mock_calls == [ call(self.cls, None) ] @@ -1929,7 +1929,7 @@ def test_check_thresholds_crit_with_alerter(self): type(mock_alc.return_value).region_name = mock_rn mock_ct.return_value = 2, {'Foo': 'bar'}, 'FooBar' self.cls.console_entry_point() - assert excinfo.value.code == 2 + assert excinfo.value.code == 0 assert mock_ct.mock_calls == [ call(self.cls, None) ] From e90d2a1da2abe2df5e34984488f1f298d0b417ba Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Mon, 21 Sep 2020 13:54:39 -0400 Subject: [PATCH 05/15] fixes #467 - Fix the Service Quotas quota name for VPC 'NAT Gateways per AZ' limit --- CHANGES.rst | 1 + awslimitchecker/services/vpc.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 93b23aff..ffdba48b 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,7 @@ Unreleased Changes * `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. * `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. * `Issue #475 `__ - When an Alert Provider is used, only exit non-zero if an exception is encountered. Exit zero even if there are warnings and/or criticals. +* `Issue #467 `__ - Fix the Service Quotas quota name for VPC "NAT Gateways per AZ" limit. .. _changelog.8_1_0: diff --git a/awslimitchecker/services/vpc.py b/awslimitchecker/services/vpc.py index 3cc8742f..b3b0c181 100644 --- a/awslimitchecker/services/vpc.py +++ b/awslimitchecker/services/vpc.py @@ -328,6 +328,7 @@ def get_limits(self): self.warning_threshold, self.critical_threshold, limit_type='AWS::EC2::NatGateway', + quotas_name='NAT gateways per Availability Zone' ) limits['Virtual private gateways'] = AwsLimit( From b1e8db5525abee4b86c0349ee9822b05cecb2900 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Mon, 21 Sep 2020 15:35:58 -0400 Subject: [PATCH 06/15] Fixes #457 - instead of 'support:*' specify the exact permissions we need --- CHANGES.rst | 1 + awslimitchecker/checker.py | 6 +++++- awslimitchecker/tests/test_checker.py | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index ffdba48b..0a8d89e5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,6 +10,7 @@ Unreleased Changes * `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. * `Issue #475 `__ - When an Alert Provider is used, only exit non-zero if an exception is encountered. Exit zero even if there are warnings and/or criticals. * `Issue #467 `__ - Fix the Service Quotas quota name for VPC "NAT Gateways per AZ" limit. +* `Issue #457 `__ - In the required IAM permissions, replace ``support:*`` with the specific permissions that we need. .. _changelog.8_1_0: diff --git a/awslimitchecker/checker.py b/awslimitchecker/checker.py index e873abab..88b461bc 100644 --- a/awslimitchecker/checker.py +++ b/awslimitchecker/checker.py @@ -649,7 +649,11 @@ def get_required_iam_policy(self): """ required_actions = [ 'servicequotas:ListServiceQuotas', - 'support:*', + 'support:DescribeTrustedAdvisorCheckRefreshStatuses', + 'support:DescribeTrustedAdvisorCheckResult', + 'support:DescribeTrustedAdvisorCheckSummaries', + 'support:DescribeTrustedAdvisorChecks', + 'support:RefreshTrustedAdvisorCheck', 'sts:GetCallerIdentity', 'trustedadvisor:Describe*', 'trustedadvisor:RefreshCheck' diff --git a/awslimitchecker/tests/test_checker.py b/awslimitchecker/tests/test_checker.py index d7ad173e..48582c7d 100644 --- a/awslimitchecker/tests/test_checker.py +++ b/awslimitchecker/tests/test_checker.py @@ -852,7 +852,11 @@ def test_get_required_iam_policy(self): 'foo:perm2', 'servicequotas:ListServiceQuotas', 'sts:GetCallerIdentity', - 'support:*', + 'support:DescribeTrustedAdvisorCheckRefreshStatuses', + 'support:DescribeTrustedAdvisorCheckResult', + 'support:DescribeTrustedAdvisorCheckSummaries', + 'support:DescribeTrustedAdvisorChecks', + 'support:RefreshTrustedAdvisorCheck', 'trustedadvisor:Describe*', 'trustedadvisor:RefreshCheck' ], From ded91f6475ba1a271eebea7057707778a289b0ff Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 07:30:03 -0400 Subject: [PATCH 07/15] Fixes #463 - replace ECS/EC2 Tasks per Service (desired count) limit with ECS/Tasks per service --- CHANGES.rst | 5 +++++ awslimitchecker/services/ecs.py | 10 ++++------ awslimitchecker/tests/services/test_ecs.py | 13 ++++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0a8d89e5..f65d96c7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -6,11 +6,16 @@ Unreleased Changes **Important:** This release requires new IAM permissions: ``sts:GetCallerIdentity`` +**Important:** This release includes updates for major changes to ECS limits, which includes the renaming of some existing limits. + * `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. * `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. * `Issue #475 `__ - When an Alert Provider is used, only exit non-zero if an exception is encountered. Exit zero even if there are warnings and/or criticals. * `Issue #467 `__ - Fix the Service Quotas quota name for VPC "NAT Gateways per AZ" limit. * `Issue #457 `__ - In the required IAM permissions, replace ``support:*`` with the specific permissions that we need. +* `Issue #463 `__ - Updates for the major changes to ECS limits `in August 2020 `__ + + * The ``EC2 Tasks per Service (desired count)`` has been replaced with ``Tasks per service``, which measures the desired count of tasks of all launch types (EC2 or Fargate). The default value of this limit has increased from 1000 to 2000. .. _changelog.8_1_0: diff --git a/awslimitchecker/services/ecs.py b/awslimitchecker/services/ecs.py index 3920b5e2..37e5c40e 100644 --- a/awslimitchecker/services/ecs.py +++ b/awslimitchecker/services/ecs.py @@ -127,7 +127,7 @@ def _find_usage_one_cluster(self, cluster_name): :param cluster_name: name of the cluster to find usage for :type cluster_name: str """ - tps_lim = self.limits['EC2 Tasks per Service (desired count)'] + tps_lim = self.limits['Tasks per service'] paginator = self.conn.get_paginator('list_services') for page in paginator.paginate( cluster=cluster_name, launchType='EC2' @@ -136,8 +136,6 @@ def _find_usage_one_cluster(self, cluster_name): svc = self.conn.describe_services( cluster=cluster_name, services=[svc_arn] )['services'][0] - if svc['launchType'] != 'EC2': - continue tps_lim._add_current_usage( svc['desiredCount'], aws_type='AWS::ECS::Service', @@ -181,10 +179,10 @@ def get_limits(self): self.critical_threshold, limit_type='AWS::ECS::Service' ) - limits['EC2 Tasks per Service (desired count)'] = AwsLimit( - 'EC2 Tasks per Service (desired count)', + limits['Tasks per service'] = AwsLimit( + 'Tasks per service', self, - 1000, + 2000, self.warning_threshold, self.critical_threshold, limit_type='AWS::ECS::TaskDefinition', diff --git a/awslimitchecker/tests/services/test_ecs.py b/awslimitchecker/tests/services/test_ecs.py index d0f886ca..77cfd892 100644 --- a/awslimitchecker/tests/services/test_ecs.py +++ b/awslimitchecker/tests/services/test_ecs.py @@ -74,7 +74,7 @@ def test_get_limits(self): assert sorted(res.keys()) == sorted([ 'Clusters', 'Container Instances per Cluster', - 'EC2 Tasks per Service (desired count)', + 'Tasks per service', 'Fargate Tasks', 'Services per Cluster', ]) @@ -263,15 +263,18 @@ def se_cluster(*_, **kwargs): call.describe_services(cluster='cName', services=['s3arn']) ] u = cls.limits[ - 'EC2 Tasks per Service (desired count)' + 'Tasks per service' ].get_current_usage() - assert len(u) == 2 + assert len(u) == 3 assert u[0].get_value() == 4 assert u[0].resource_id == 'cluster=cName; service=s1' assert u[0].aws_type == 'AWS::ECS::Service' - assert u[1].get_value() == 8 - assert u[1].resource_id == 'cluster=cName; service=s3' + assert u[1].get_value() == 26 + assert u[1].resource_id == 'cluster=cName; service=s2' assert u[1].aws_type == 'AWS::ECS::Service' + assert u[2].get_value() == 8 + assert u[2].resource_id == 'cluster=cName; service=s3' + assert u[2].aws_type == 'AWS::ECS::Service' def test_required_iam_permissions(self): cls = _EcsService(21, 43, {}, None) From 14f753c5d02827528cd8df18fbe0341cd98b404b Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 07:34:47 -0400 Subject: [PATCH 08/15] Issue #463 - update ECS with new default limits --- CHANGES.rst | 5 ++++- awslimitchecker/services/ecs.py | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index f65d96c7..0a967d20 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -15,7 +15,10 @@ Unreleased Changes * `Issue #457 `__ - In the required IAM permissions, replace ``support:*`` with the specific permissions that we need. * `Issue #463 `__ - Updates for the major changes to ECS limits `in August 2020 `__ - * The ``EC2 Tasks per Service (desired count)`` has been replaced with ``Tasks per service``, which measures the desired count of tasks of all launch types (EC2 or Fargate). The default value of this limit has increased from 1000 to 2000. + * The ``EC2 Tasks per Service (desired count)`` limit has been replaced with ``Tasks per service``, which measures the desired count of tasks of all launch types (EC2 or Fargate). The default value of this limit has increased from 1000 to 2000. + * The default of ``Clusters`` has increased from 2,000 to 10,000. + * The default of ``Services per Cluster`` has increased from 1,000 to 2,000. + * The ``Fargate Tasks`` limit has been removed. .. _changelog.8_1_0: diff --git a/awslimitchecker/services/ecs.py b/awslimitchecker/services/ecs.py index 37e5c40e..a2b7fe04 100644 --- a/awslimitchecker/services/ecs.py +++ b/awslimitchecker/services/ecs.py @@ -158,7 +158,7 @@ def get_limits(self): limits['Clusters'] = AwsLimit( 'Clusters', self, - 2000, + 10000, self.warning_threshold, self.critical_threshold, limit_type='AWS::ECS::Cluster', @@ -174,7 +174,7 @@ def get_limits(self): limits['Services per Cluster'] = AwsLimit( 'Services per Cluster', self, - 1000, + 2000, self.warning_threshold, self.critical_threshold, limit_type='AWS::ECS::Service' From a242807c86da9139c3a842842408db24303528c0 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 09:05:37 -0400 Subject: [PATCH 09/15] Issue #463 - _AwsService._get_cloudwatch_usage_latest helper method --- CHANGES.rst | 4 + awslimitchecker/services/base.py | 79 ++++ awslimitchecker/tests/services/test_base.py | 424 ++++++++++++++++++-- 3 files changed, 483 insertions(+), 24 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0a967d20..6a9ed6d9 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -19,6 +19,10 @@ Unreleased Changes * The default of ``Clusters`` has increased from 2,000 to 10,000. * The default of ``Services per Cluster`` has increased from 1,000 to 2,000. * The ``Fargate Tasks`` limit has been removed. + * The ``Fargate On-Demand resource count`` limit has been added, with a default quota value of 500. This limit measures the number of ECS tasks and EKS pods running concurrently on Fargate. The current usage for this metric is obtained from CloudWatch. + * The ``Fargate Spot resource count`` limit has been added, with a default quota value of 500. This limit measures the number of ECS tasks running concurrently on Fargate Spot. The current usage for this metric is obtained from CloudWatch. + +* Add internal helper method to :py:class:`~._AwsService` to get Service Quotas usage information from CloudWatch. .. _changelog.8_1_0: diff --git a/awslimitchecker/services/base.py b/awslimitchecker/services/base.py index 1fe6c5d2..359c7186 100644 --- a/awslimitchecker/services/base.py +++ b/awslimitchecker/services/base.py @@ -40,6 +40,7 @@ import abc import logging import boto3 +from datetime import datetime, timedelta from awslimitchecker.connectable import Connectable logger = logging.getLogger(__name__) @@ -92,6 +93,7 @@ def __init__(self, warning_threshold, critical_threshold, self.limits = self.get_limits() self._have_usage = False self._current_account_id = None + self._cloudwatch_client = None @property def current_account_id(self): @@ -300,3 +302,80 @@ def _update_service_quotas(self): ) if val is not None: lim._set_quotas_limit(val) + + def _cloudwatch_connection(self): + """ + Return a connected CloudWatch client instance. ONLY to be used by + :py:meth:`_get_cloudwatch_usage_latest`. + """ + if self._cloudwatch_client is not None: + return self._cloudwatch_client + kwargs = dict(self._boto3_connection_kwargs) + if self._max_retries_config is not None: + kwargs['config'] = self._max_retries_config + self._cloudwatch_client = boto3.client('cloudwatch', **kwargs) + logger.info( + "Connected to cloudwatch in region %s", + self._cloudwatch_client._client_config.region_name + ) + return self._cloudwatch_client + + def _get_cloudwatch_usage_latest( + self, dimensions, metric_name='ResourceCount', period=60 + ): + """ + Given some metric dimensions, return the value of the latest data point + for the ``AWS/Usage`` metric specified. + + :param dimensions: list of dicts; dimensions for the metric + :type dimensions: list + :param metric_name: AWS/Usage metric name to get + :type metric_name: str + :param period: metric period + :type period: int + :return: return the metric value (float or int), or None if it cannot + be retrieved + :rtype: ``float, int or None`` + """ + conn = self._cloudwatch_connection() + kwargs = dict( + MetricDataQueries=[ + { + 'Id': 'id', + 'MetricStat': { + 'Metric': { + 'Namespace': 'AWS/Usage', + 'MetricName': metric_name, + 'Dimensions': dimensions + }, + 'Period': period, + 'Stat': 'Average' + } + } + ], + StartTime=datetime.utcnow() - timedelta(hours=1), + EndTime=datetime.utcnow(), + ScanBy='TimestampDescending', + MaxDatapoints=1 + ) + try: + logger.debug('Querying CloudWatch GetMetricData: %s', kwargs) + resp = conn.get_metric_data(**kwargs) + except Exception as ex: + logger.error( + 'Error querying CloudWatch GetMetricData for AWS/Usage %s: %s', + metric_name, ex + ) + return 0 + results = resp.get('MetricDataResults', []) + if len(results) < 1 or len(results[0]['Values']) < 1: + logger.warning( + 'No data points found for AWS/Usage metric %s with dimensions ' + '%s; using value of zero!', metric_name, dimensions + ) + return 0 + logger.debug( + 'CloudWatch metric query returned value of %s with timestamp %s', + results[0]['Values'][0], results[0]['Timestamps'][0] + ) + return results[0]['Values'][0] diff --git a/awslimitchecker/tests/services/test_base.py b/awslimitchecker/tests/services/test_base.py index 9b8f6e36..893577d3 100644 --- a/awslimitchecker/tests/services/test_base.py +++ b/awslimitchecker/tests/services/test_base.py @@ -42,6 +42,10 @@ from awslimitchecker.quotas import ServiceQuotasClient import pytest import sys +from datetime import datetime +from dateutil.tz import tzutc +from freezegun import freeze_time +from botocore.exceptions import ClientError # https://code.google.com/p/mock/issues/detail?id=249 # py>=3.4 should use unittest.mock not the mock package on pypi @@ -103,6 +107,7 @@ def test_init_subclass(self): assert cls._boto3_connection_kwargs == {} assert cls._quotas_client == m_quota assert cls._current_account_id is None + assert cls._cloudwatch_client is None def test_init_subclass_boto_xargs(self): boto_args = {'region_name': 'myregion', @@ -119,6 +124,42 @@ def test_init_subclass_boto_xargs(self): assert cls._boto3_connection_kwargs == boto_args assert cls._quotas_client is None assert cls._current_account_id is None + assert cls._cloudwatch_client is None + + def test_current_account_id_stored(self): + mock_conf = Mock(region_name='foo') + mock_sts = Mock(_client_config=mock_conf) + mock_sts.get_caller_identity.return_value = { + 'UserId': 'something', + 'Account': '123456789', + 'Arn': 'something' + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + cls._current_account_id = '987654321' + with patch('awslimitchecker.services.base.boto3.client') as m_boto: + m_boto.return_value = mock_sts + res = cls.current_account_id + assert res == '987654321' + assert m_boto.mock_calls == [] + + def test_current_account_id_needed(self): + mock_conf = Mock(region_name='foo') + mock_sts = Mock(_client_config=mock_conf) + mock_sts.get_caller_identity.return_value = { + 'UserId': 'something', + 'Account': '123456789', + 'Arn': 'something' + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch('awslimitchecker.services.base.boto3.client') as m_boto: + m_boto.return_value = mock_sts + res = cls.current_account_id + assert res == '123456789' + assert cls._current_account_id == '123456789' + assert m_boto.mock_calls == [ + call('sts', foo='bar'), + call().get_caller_identity() + ] def test_set_limit_override(self): mock_limit = Mock(spec_set=AwsLimit) @@ -376,38 +417,372 @@ def se_get_quota_value(_, quota_name, **kwargs): assert mock_limit1.mock_calls == [] assert mock_limit2.mock_calls == [] - def test_current_account_id_needed(self): + def test_cloudwatch_connection_needed(self): mock_conf = Mock(region_name='foo') - mock_sts = Mock(_client_config=mock_conf) - mock_sts.get_caller_identity.return_value = { - 'UserId': 'something', - 'Account': '123456789', - 'Arn': 'something' - } + mock_cw = Mock(_client_config=mock_conf) cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) - cls._current_account_id = '987654321' + assert cls._cloudwatch_client is None with patch('awslimitchecker.services.base.boto3.client') as m_boto: - m_boto.return_value = mock_sts - res = cls.current_account_id - assert res == '987654321' - assert m_boto.mock_calls == [] + m_boto.return_value = mock_cw + res = cls._cloudwatch_connection() + assert res == mock_cw + assert cls._cloudwatch_client == mock_cw + assert m_boto.mock_calls == [ + call.client('cloudwatch', foo='bar') + ] - def test_current_account_id_stored(self): + def test_cloudwatch_connection_needed_max_retries(self): mock_conf = Mock(region_name='foo') - mock_sts = Mock(_client_config=mock_conf) - mock_sts.get_caller_identity.return_value = { - 'UserId': 'something', - 'Account': '123456789', - 'Arn': 'something' - } + mock_cw = Mock(_client_config=mock_conf) cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + assert cls._cloudwatch_client is None with patch('awslimitchecker.services.base.boto3.client') as m_boto: - m_boto.return_value = mock_sts - res = cls.current_account_id - assert res == '123456789' + with patch( + 'awslimitchecker.connectable.Connectable._max_retries_config', + new_callable=PropertyMock + ) as m_mrc: + m_mrc.return_value = {'retries': 5} + m_boto.return_value = mock_cw + res = cls._cloudwatch_connection() + assert res == mock_cw + assert cls._cloudwatch_client == mock_cw assert m_boto.mock_calls == [ - call('sts', foo='bar'), - call().get_caller_identity() + call.client('cloudwatch', foo='bar', config={'retries': 5}) + ] + + def test_cloudwatch_connection_stored(self): + mock_conf = Mock(region_name='foo') + mock_cw = Mock(_client_config=mock_conf) + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + cls._cloudwatch_client = mock_cw + with patch('awslimitchecker.services.base.boto3.client') as m_boto: + m_boto.return_value = mock_cw + res = cls._cloudwatch_connection() + assert res == mock_cw + assert cls._cloudwatch_client == mock_cw + assert m_boto.mock_calls == [] + + +class TestGetCloudwatchUsageLatest: + + @freeze_time("2020-09-22 12:26:00", tz_offset=0) + def test_defaults(self): + mock_conn = Mock() + mock_conn.get_metric_data.return_value = { + 'MetricDataResults': [ + { + 'Id': 'id', + 'Label': 'ResourceCount', + 'Timestamps': [ + datetime(2020, 9, 22, 12, 25, tzinfo=tzutc()) + ], + 'Values': [3.0], + 'StatusCode': 'PartialData' + } + ], + 'NextToken': 'foo', + 'Messages': [], + 'ResponseMetadata': { + 'RequestId': '77a86d3c-16e1-47c5-b2d1-0f7d81d48a05', + 'HTTPStatusCode': 200, + 'HTTPHeaders': { + 'x-amzn-requestid': '70f7d81d48a05', + 'content-type': 'text/xml', + 'content-length': '796', + 'date': 'Tue, 22 Sep 2020 12:26:36 GMT' + }, + 'RetryAttempts': 0 + } + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch( + 'awslimitchecker.services.base._AwsService._cloudwatch_connection', + autospec=True + ) as m_cw_conn: + m_cw_conn.return_value = mock_conn + res = cls._get_cloudwatch_usage_latest([ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ]) + assert res == 3.0 + assert m_cw_conn.mock_calls == [ + call(cls) + ] + assert mock_conn.mock_calls == [ + call.get_metric_data( + MetricDataQueries=[ + { + 'Id': 'id', + 'MetricStat': { + 'Metric': { + 'Namespace': 'AWS/Usage', + 'MetricName': 'ResourceCount', + 'Dimensions': [ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ] + }, + 'Period': 60, + 'Stat': 'Average' + } + } + ], + StartTime=datetime(2020, 9, 22, 11, 26, 00), + EndTime=datetime(2020, 9, 22, 12, 26, 00), + ScanBy='TimestampDescending', + MaxDatapoints=1 + ) + ] + + @freeze_time("2020-09-22 12:26:00", tz_offset=0) + def test_non_default(self): + mock_conn = Mock() + mock_conn.get_metric_data.return_value = { + 'MetricDataResults': [ + { + 'Id': 'id', + 'Label': 'ResourceCount', + 'Timestamps': [ + datetime(2020, 9, 22, 12, 25, tzinfo=tzutc()) + ], + 'Values': [3.0], + 'StatusCode': 'PartialData' + } + ], + 'NextToken': 'foo', + 'Messages': [], + 'ResponseMetadata': { + 'RequestId': '77a86d3c-16e1-47c5-b2d1-0f7d81d48a05', + 'HTTPStatusCode': 200, + 'HTTPHeaders': { + 'x-amzn-requestid': '70f7d81d48a05', + 'content-type': 'text/xml', + 'content-length': '796', + 'date': 'Tue, 22 Sep 2020 12:26:36 GMT' + }, + 'RetryAttempts': 0 + } + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch( + 'awslimitchecker.services.base._AwsService._cloudwatch_connection', + autospec=True + ) as m_cw_conn: + m_cw_conn.return_value = mock_conn + res = cls._get_cloudwatch_usage_latest([ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ], metric_name='MyMetric', period=3600) + assert res == 3.0 + assert m_cw_conn.mock_calls == [ + call(cls) + ] + assert mock_conn.mock_calls == [ + call.get_metric_data( + MetricDataQueries=[ + { + 'Id': 'id', + 'MetricStat': { + 'Metric': { + 'Namespace': 'AWS/Usage', + 'MetricName': 'MyMetric', + 'Dimensions': [ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ] + }, + 'Period': 3600, + 'Stat': 'Average' + } + } + ], + StartTime=datetime(2020, 9, 22, 11, 26, 00), + EndTime=datetime(2020, 9, 22, 12, 26, 00), + ScanBy='TimestampDescending', + MaxDatapoints=1 + ) + ] + + @freeze_time("2020-09-22 12:26:00", tz_offset=0) + def test_exception(self): + mock_conn = Mock() + mock_conn.get_metric_data.side_effect = ClientError( + { + 'ResponseMetadata': { + 'HTTPStatusCode': 503, + 'RequestId': '7d74c6f0-c789-11e5-82fe-a96cdaa6d564' + }, + 'Error': { + 'Message': 'Service Unavailable', + 'Code': '503' + } + }, + 'GetMetricData' + ) + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch( + 'awslimitchecker.services.base._AwsService._cloudwatch_connection', + autospec=True + ) as m_cw_conn: + m_cw_conn.return_value = mock_conn + res = cls._get_cloudwatch_usage_latest([ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ], metric_name='MyMetric', period=3600) + assert res == 0 + assert m_cw_conn.mock_calls == [ + call(cls) + ] + assert mock_conn.mock_calls == [ + call.get_metric_data( + MetricDataQueries=[ + { + 'Id': 'id', + 'MetricStat': { + 'Metric': { + 'Namespace': 'AWS/Usage', + 'MetricName': 'MyMetric', + 'Dimensions': [ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ] + }, + 'Period': 3600, + 'Stat': 'Average' + } + } + ], + StartTime=datetime(2020, 9, 22, 11, 26, 00), + EndTime=datetime(2020, 9, 22, 12, 26, 00), + ScanBy='TimestampDescending', + MaxDatapoints=1 + ) + ] + + @freeze_time("2020-09-22 12:26:00", tz_offset=0) + def test_no_data(self): + mock_conn = Mock() + mock_conn.get_metric_data.return_value = { + 'MetricDataResults': [], + 'NextToken': 'foo', + 'Messages': [], + 'ResponseMetadata': { + 'RequestId': '77a86d3c-16e1-47c5-b2d1-0f7d81d48a05', + 'HTTPStatusCode': 200, + 'HTTPHeaders': { + 'x-amzn-requestid': '70f7d81d48a05', + 'content-type': 'text/xml', + 'content-length': '796', + 'date': 'Tue, 22 Sep 2020 12:26:36 GMT' + }, + 'RetryAttempts': 0 + } + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch( + 'awslimitchecker.services.base._AwsService._cloudwatch_connection', + autospec=True + ) as m_cw_conn: + m_cw_conn.return_value = mock_conn + res = cls._get_cloudwatch_usage_latest([ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ], metric_name='MyMetric', period=3600) + assert res == 0 + assert m_cw_conn.mock_calls == [ + call(cls) + ] + assert mock_conn.mock_calls == [ + call.get_metric_data( + MetricDataQueries=[ + { + 'Id': 'id', + 'MetricStat': { + 'Metric': { + 'Namespace': 'AWS/Usage', + 'MetricName': 'MyMetric', + 'Dimensions': [ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ] + }, + 'Period': 3600, + 'Stat': 'Average' + } + } + ], + StartTime=datetime(2020, 9, 22, 11, 26, 00), + EndTime=datetime(2020, 9, 22, 12, 26, 00), + ScanBy='TimestampDescending', + MaxDatapoints=1 + ) + ] + + @freeze_time("2020-09-22 12:26:00", tz_offset=0) + def test_no_values(self): + mock_conn = Mock() + mock_conn.get_metric_data.return_value = { + 'MetricDataResults': [ + { + 'Id': 'id', + 'Label': 'ResourceCount', + 'Timestamps': [], + 'Values': [], + 'StatusCode': 'PartialData' + } + ], + 'NextToken': 'foo', + 'Messages': [], + 'ResponseMetadata': { + 'RequestId': '77a86d3c-16e1-47c5-b2d1-0f7d81d48a05', + 'HTTPStatusCode': 200, + 'HTTPHeaders': { + 'x-amzn-requestid': '70f7d81d48a05', + 'content-type': 'text/xml', + 'content-length': '796', + 'date': 'Tue, 22 Sep 2020 12:26:36 GMT' + }, + 'RetryAttempts': 0 + } + } + cls = AwsServiceTester(1, 2, {'foo': 'bar'}, None) + with patch( + 'awslimitchecker.services.base._AwsService._cloudwatch_connection', + autospec=True + ) as m_cw_conn: + m_cw_conn.return_value = mock_conn + res = cls._get_cloudwatch_usage_latest([ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ], metric_name='MyMetric', period=3600) + assert res == 0 + assert m_cw_conn.mock_calls == [ + call(cls) + ] + assert mock_conn.mock_calls == [ + call.get_metric_data( + MetricDataQueries=[ + { + 'Id': 'id', + 'MetricStat': { + 'Metric': { + 'Namespace': 'AWS/Usage', + 'MetricName': 'MyMetric', + 'Dimensions': [ + {'Name': 'foo', 'Value': 'bar'}, + {'Name': 'baz', 'Value': 'blam'} + ] + }, + 'Period': 3600, + 'Stat': 'Average' + } + } + ], + StartTime=datetime(2020, 9, 22, 11, 26, 00), + EndTime=datetime(2020, 9, 22, 12, 26, 00), + ScanBy='TimestampDescending', + MaxDatapoints=1 + ) ] @@ -434,6 +809,7 @@ def test_subclass_init(self, cls): assert inst.critical_threshold == 7 assert not inst._boto3_connection_kwargs assert inst._current_account_id is None + assert inst._cloudwatch_client is None boto_args = dict(region_name='myregion', aws_access_key_id='myaccesskey', From ef8c6c4f0986c936e05b14dfce5cecea14327973 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 09:57:16 -0400 Subject: [PATCH 10/15] Issue #463 - implement usage collection for Fargate limits, via CloudWatch --- awslimitchecker/services/ecs.py | 58 +++++++++++++++------- awslimitchecker/tests/services/test_ecs.py | 47 ++++++++++++++---- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/awslimitchecker/services/ecs.py b/awslimitchecker/services/ecs.py index a2b7fe04..6e519568 100644 --- a/awslimitchecker/services/ecs.py +++ b/awslimitchecker/services/ecs.py @@ -71,16 +71,43 @@ def find_usage(self): for lim in self.limits.values(): lim._reset_usage() self._find_usage_clusters() + self._find_usage_fargate() self._have_usage = True logger.debug("Done checking usage.") + def _find_usage_fargate(self): + """ + Find the usage for Fargate, via CloudWatch. + """ + self.limits['Fargate On-Demand resource count']._add_current_usage( + self._get_cloudwatch_usage_latest( + [ + {'Name': 'Type', 'Value': 'Resource'}, + {'Name': 'Resource', 'Value': 'OnDemand'}, + {'Name': 'Service', 'Value': 'Fargate'}, + {'Name': 'Class', 'Value': 'None'}, + ], + ), + aws_type='AWS::ECS::TaskDefinition' + ) + self.limits['Fargate Spot resource count']._add_current_usage( + self._get_cloudwatch_usage_latest( + [ + {'Name': 'Type', 'Value': 'Resource'}, + {'Name': 'Resource', 'Value': 'Spot'}, + {'Name': 'Service', 'Value': 'Fargate'}, + {'Name': 'Class', 'Value': 'None'}, + ], + ), + aws_type='AWS::ECS::TaskDefinition' + ) + def _find_usage_clusters(self): """ Find the ECS service usage for clusters. Calls :py:meth:`~._find_usage_one_cluster` for each cluster. """ count = 0 - fargate_task_count = 0 paginator = self.conn.get_paginator('list_clusters') for page in paginator.paginate(): for cluster_arn in page['clusterArns']: @@ -101,21 +128,7 @@ def _find_usage_clusters(self): aws_type='AWS::ECS::Service', resource_id=cluster['clusterName'] ) - # Note: 'statistics' is not always present in API responses, - # even if requested. As far as I can tell, it's omitted if - # a cluster has no Fargate tasks. - for stat in cluster.get('statistics', []): - if stat['name'] != 'runningFargateTasksCount': - continue - logger.debug( - 'Found %s Fargate tasks in cluster %s', - stat['value'], cluster_arn - ) - fargate_task_count += int(stat['value']) self._find_usage_one_cluster(cluster['clusterName']) - self.limits['Fargate Tasks']._add_current_usage( - fargate_task_count, aws_type='AWS::ECS::Task' - ) self.limits['Clusters']._add_current_usage( count, aws_type='AWS::ECS::Cluster' ) @@ -188,15 +201,24 @@ def get_limits(self): limit_type='AWS::ECS::TaskDefinition', limit_subtype='EC2' ) - limits['Fargate Tasks'] = AwsLimit( - 'Fargate Tasks', + limits['Fargate On-Demand resource count'] = AwsLimit( + 'Fargate On-Demand resource count', self, - 50, + 500, self.warning_threshold, self.critical_threshold, limit_type='AWS::ECS::TaskDefinition', limit_subtype='Fargate' ) + limits['Fargate Spot resource count'] = AwsLimit( + 'Fargate Spot resource count', + self, + 500, + self.warning_threshold, + self.critical_threshold, + limit_type='AWS::ECS::TaskDefinition', + limit_subtype='FargateSpot' + ) self.limits = limits return limits diff --git a/awslimitchecker/tests/services/test_ecs.py b/awslimitchecker/tests/services/test_ecs.py index 77cfd892..8a22bdef 100644 --- a/awslimitchecker/tests/services/test_ecs.py +++ b/awslimitchecker/tests/services/test_ecs.py @@ -75,7 +75,8 @@ def test_get_limits(self): 'Clusters', 'Container Instances per Cluster', 'Tasks per service', - 'Fargate Tasks', + 'Fargate On-Demand resource count', + 'Fargate Spot resource count', 'Services per Cluster', ]) for name, limit in res.items(): @@ -96,7 +97,8 @@ def test_find_usage(self): pb, autospec=True, connect=DEFAULT, - _find_usage_clusters=DEFAULT + _find_usage_clusters=DEFAULT, + _find_usage_fargate=DEFAULT ) as mocks: cls = _EcsService(21, 43, {}, None) assert cls._have_usage is False @@ -104,6 +106,35 @@ def test_find_usage(self): assert mocks['connect'].mock_calls == [call(cls)] assert cls._have_usage is True assert mocks['connect'].return_value.mock_calls == [] + assert mocks['_find_usage_clusters'].mock_calls == [call(cls)] + assert mocks['_find_usage_fargate'].mock_calls == [call(cls)] + + def test_find_usage_fargate(self): + + def se_gcul(klass, dims, metric_name='ResourceCount', period=60): + dim_dict = {x['Name']: x['Value'] for x in dims} + if dim_dict['Resource'] == 'OnDemand': + return 6.0 + if dim_dict['Resource'] == 'Spot': + return 2.0 + return 0 + + with patch( + '%s._get_cloudwatch_usage_latest' % pb, autospec=True + ) as m_gcul: + m_gcul.side_effect = se_gcul + cls = _EcsService(21, 43, {}, None) + cls._find_usage_fargate() + ondemand = cls.limits[ + 'Fargate On-Demand resource count' + ].get_current_usage() + assert len(ondemand) == 1 + assert ondemand[0].get_value() == 6.0 + assert ondemand[0].resource_id is None + spot = cls.limits['Fargate Spot resource count'].get_current_usage() + assert len(spot) == 1 + assert spot[0].get_value() == 2.0 + assert spot[0].resource_id is None def test_find_usage_clusters(self): def se_clusters(*_, **kwargs): @@ -175,6 +206,10 @@ def se_clusters(*_, **kwargs): clusters=['c2arn'], include=['STATISTICS'] ) ] + assert m_fuoc.mock_calls == [ + call(cls, 'c1name'), + call(cls, 'c2name') + ] c = cls.limits['Container Instances per Cluster'].get_current_usage() assert len(c) == 2 assert c[0].get_value() == 11 @@ -191,14 +226,6 @@ def se_clusters(*_, **kwargs): assert len(u) == 1 assert u[0].get_value() == 2 assert u[0].resource_id is None - f = cls.limits['Fargate Tasks'].get_current_usage() - assert len(f) == 1 - assert f[0].get_value() == 4 - assert f[0].resource_id is None - assert m_fuoc.mock_calls == [ - call(cls, 'c1name'), - call(cls, 'c2name') - ] def test_find_usage_one_cluster(self): From 255edb29c303a0b6d3c203d9fffd6da8932071b0 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 10:29:59 -0400 Subject: [PATCH 11/15] Issue #463 - add forgotten cloudwatch:GetMetricData permission --- CHANGES.rst | 2 +- awslimitchecker/checker.py | 1 + awslimitchecker/tests/test_checker.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.rst b/CHANGES.rst index 6a9ed6d9..14ab7b92 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,7 +4,7 @@ Changelog Unreleased Changes ------------------ -**Important:** This release requires new IAM permissions: ``sts:GetCallerIdentity`` +**Important:** This release requires new IAM permissions: ``sts:GetCallerIdentity`` and ``cloudwatch:GetMetricData`` **Important:** This release includes updates for major changes to ECS limits, which includes the renaming of some existing limits. diff --git a/awslimitchecker/checker.py b/awslimitchecker/checker.py index 88b461bc..4ddb06e5 100644 --- a/awslimitchecker/checker.py +++ b/awslimitchecker/checker.py @@ -648,6 +648,7 @@ def get_required_iam_policy(self): :rtype: dict """ required_actions = [ + 'cloudwatch:GetMetricData', 'servicequotas:ListServiceQuotas', 'support:DescribeTrustedAdvisorCheckRefreshStatuses', 'support:DescribeTrustedAdvisorCheckResult', diff --git a/awslimitchecker/tests/test_checker.py b/awslimitchecker/tests/test_checker.py index 48582c7d..4c026c11 100644 --- a/awslimitchecker/tests/test_checker.py +++ b/awslimitchecker/tests/test_checker.py @@ -846,6 +846,7 @@ def test_get_required_iam_policy(self): 'Effect': 'Allow', 'Resource': '*', 'Action': [ + 'cloudwatch:GetMetricData', 'ec2:bar', 'ec2:foo', 'foo:perm1', From 9dbf08b9a7122f70460c0f9f9a1ef85dae3842ae Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 10:42:25 -0400 Subject: [PATCH 12/15] Issue #463 - integration tests - ignore expected WARNING message --- awslimitchecker/tests/support.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awslimitchecker/tests/support.py b/awslimitchecker/tests/support.py index d6e06352..25346df5 100644 --- a/awslimitchecker/tests/support.py +++ b/awslimitchecker/tests/support.py @@ -159,6 +159,12 @@ def unexpected_logs(self, allow_endpoint_error=False): r.funcName == 'quotas_for_service' and 'Attempted to retrieve Service Quotas' in r.msg): continue + if ( + r.levelno == logging.WARNING and r.module == 'base' and + r.funcName == '_get_cloudwatch_usage_latest' and + 'No data points found for AWS/Usage metric' in r.msg + ): + continue res.append('%s:%s.%s (%s:%s) %s - %s %s' % ( r.name, r.module, From 11522ec33d94ee5e78536a3666a93a42ee310278 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 12:50:28 -0400 Subject: [PATCH 13/15] update CHANGES --- CHANGES.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 14ab7b92..5f48c41c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,12 +8,12 @@ Unreleased Changes **Important:** This release includes updates for major changes to ECS limits, which includes the renaming of some existing limits. -* `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. -* `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. -* `Issue #475 `__ - When an Alert Provider is used, only exit non-zero if an exception is encountered. Exit zero even if there are warnings and/or criticals. -* `Issue #467 `__ - Fix the Service Quotas quota name for VPC "NAT Gateways per AZ" limit. +* `Issue #477 `__ - EC2 instances running on Dedicated Hosts (tenancy "host") or single-tenant hardware (tenancy "dedicated") do not count towards On-Demand Instances limits. They were previously being counted towards these limits; they are now excluded from the count. Thanks to `pritam2277 `__ for reporting this issue and providing details and test data. +* `Issue #477 `__ - For all VPC resources that support the ``owner-id`` filter, supply that filter when describing them, set to the current account ID. This will prevent shared resources from other accounts from being counted against the limits. Thanks to `pritam2277 `__ for reporting this issue and providing details and test data. +* `Issue #475 `__ - When an Alert Provider is used, only exit non-zero if an exception is encountered. Exit zero even if there are warnings and/or criticals. Thanks to `varuzam `__ for this feature request. +* `Issue #467 `__ - Fix the Service Quotas quota name for VPC "NAT Gateways per AZ" limit. Thanks to `xRokco `__ for reporting this issue, as well as the required fix. * `Issue #457 `__ - In the required IAM permissions, replace ``support:*`` with the specific permissions that we need. -* `Issue #463 `__ - Updates for the major changes to ECS limits `in August 2020 `__ +* `Issue #463 `__ - Updates for the major changes to ECS limits `in August 2020 `__. Thanks to `vincentclee `__ for reporting this issue. * The ``EC2 Tasks per Service (desired count)`` limit has been replaced with ``Tasks per service``, which measures the desired count of tasks of all launch types (EC2 or Fargate). The default value of this limit has increased from 1000 to 2000. * The default of ``Clusters`` has increased from 2,000 to 10,000. From d3ba502404bf5f824f53f8ef0c1fb52231f43996 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 13:23:24 -0400 Subject: [PATCH 14/15] rebuild docs locally --- docs/source/cli_usage.rst | 50 +++++++++++++++++++------------------- docs/source/iam_policy.rst | 8 +++++- docs/source/limits.rst | 20 +++++++-------- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/docs/source/cli_usage.rst b/docs/source/cli_usage.rst index 29be0b22..0baf9349 100644 --- a/docs/source/cli_usage.rst +++ b/docs/source/cli_usage.rst @@ -227,14 +227,14 @@ and limits followed by ``(API)`` have been obtained from the service's API. ApiGateway/Client certificates per account 60.0 (Quotas) ApiGateway/Custom authorizers per API 10 ApiGateway/Documentation parts per API 2000 - ApiGateway/Edge APIs per account 300.0 (Quotas) + ApiGateway/Edge APIs per account 120.0 (Quotas) (...) - AutoScaling/Auto Scaling groups 1500 (API) + AutoScaling/Auto Scaling groups 200 (API) (...) Lambda/Function Count None (...) - VPC/Subnets per VPC 200 - VPC/VPCs 1000.0 (Quotas) + VPC/Subnets per VPC 200.0 (Quotas) + VPC/VPCs 5.0 (Quotas) VPC/Virtual private gateways 5 @@ -254,12 +254,12 @@ from the Service Quotas service. ApiGateway/Documentation parts per API 2000 ApiGateway/Edge APIs per account 120 (...) - AutoScaling/Auto Scaling groups 1500 (API) + AutoScaling/Auto Scaling groups 200 (API) (...) Lambda/Function Count None (...) VPC/Subnets per VPC 200 - VPC/VPCs 1000 (TA) + VPC/VPCs 5 VPC/Virtual private gateways 5 @@ -277,14 +277,14 @@ from Trusted Advisor for all commands. ApiGateway/Client certificates per account 60.0 (Quotas) ApiGateway/Custom authorizers per API 10 ApiGateway/Documentation parts per API 2000 - ApiGateway/Edge APIs per account 300.0 (Quotas) + ApiGateway/Edge APIs per account 120.0 (Quotas) (...) - AutoScaling/Auto Scaling groups 1500 (API) + AutoScaling/Auto Scaling groups 200 (API) (...) Lambda/Function Count None (...) - VPC/Subnets per VPC 200 - VPC/VPCs 1000.0 (Quotas) + VPC/Subnets per VPC 200.0 (Quotas) + VPC/VPCs 5.0 (Quotas) VPC/Virtual private gateways 5 @@ -342,15 +342,15 @@ using their IDs). .. code-block:: console (venv)$ awslimitchecker -u - ApiGateway/API keys per account 22 - ApiGateway/Client certificates per account 3 - ApiGateway/Custom authorizers per API max: 00e87qs7ci=2 (00e87qs (...) - ApiGateway/Documentation parts per API max: 00e87qs7ci=2 (00e87qs (...) - ApiGateway/Edge APIs per account 207 + ApiGateway/API keys per account 2 + ApiGateway/Client certificates per account 0 + ApiGateway/Custom authorizers per API max: 2d7q4kzcmh=2 (2d7q4kz (...) + ApiGateway/Documentation parts per API max: 2d7q4kzcmh=2 (2d7q4kz (...) + ApiGateway/Edge APIs per account 9 (...) - VPC/Subnets per VPC max: vpc-c89074a9=41 (vpc- (...) - VPC/VPCs 39 - VPC/Virtual private gateways 4 + VPC/Subnets per VPC max: vpc-f4279a92=6 (vpc-f (...) + VPC/VPCs 2 + VPC/Virtual private gateways 1 @@ -379,14 +379,14 @@ For example, to override the limits of EC2's "EC2-Classic Elastic IPs" and ApiGateway/Client certificates per account 60.0 (Quotas) ApiGateway/Custom authorizers per API 10 ApiGateway/Documentation parts per API 2000 - ApiGateway/Edge APIs per account 300.0 (Quotas) + ApiGateway/Edge APIs per account 120.0 (Quotas) (...) - CloudFormation/Stacks 4000 (API) + CloudFormation/Stacks 200 (API) (...) Lambda/Function Count None (...) - VPC/Subnets per VPC 200 - VPC/VPCs 1000.0 (Quotas) + VPC/Subnets per VPC 200.0 (Quotas) + VPC/VPCs 5.0 (Quotas) VPC/Virtual private gateways 5 @@ -414,10 +414,10 @@ Using a command like: ApiGateway/Client certificates per account 60.0 (Quotas) ApiGateway/Custom authorizers per API 10 ApiGateway/Documentation parts per API 2000 - ApiGateway/Edge APIs per account 300.0 (Quotas) + ApiGateway/Edge APIs per account 120.0 (Quotas) (...) - VPC/Subnets per VPC 200 - VPC/VPCs 1000.0 (Quotas) + VPC/Subnets per VPC 200.0 (Quotas) + VPC/VPCs 5.0 (Quotas) VPC/Virtual private gateways 5 diff --git a/docs/source/iam_policy.rst b/docs/source/iam_policy.rst index 8d1795cc..daa85e87 100644 --- a/docs/source/iam_policy.rst +++ b/docs/source/iam_policy.rst @@ -41,6 +41,7 @@ services that do not affect the results of this program. "cloudformation:DescribeStacks", "cloudtrail:DescribeTrails", "cloudtrail:GetEventSelectors", + "cloudwatch:GetMetricData", "ds:GetDirectoryLimits", "dynamodb:DescribeLimits", "dynamodb:DescribeTable", @@ -103,7 +104,12 @@ services that do not affect the results of this program. "s3:ListAllMyBuckets", "servicequotas:ListServiceQuotas", "ses:GetSendQuota", - "support:*", + "sts:GetCallerIdentity", + "support:DescribeTrustedAdvisorCheckRefreshStatuses", + "support:DescribeTrustedAdvisorCheckResult", + "support:DescribeTrustedAdvisorCheckSummaries", + "support:DescribeTrustedAdvisorChecks", + "support:RefreshTrustedAdvisorCheck", "trustedadvisor:Describe*", "trustedadvisor:RefreshCheck" ], diff --git a/docs/source/limits.rst b/docs/source/limits.rst index 8977165c..be14de68 100644 --- a/docs/source/limits.rst +++ b/docs/source/limits.rst @@ -179,7 +179,6 @@ Max launch specifications per spot fleet Max spot instance requests per region 20 Max target capacity for all spot fleets in region 5000 Max target capacity per spot fleet 3000 -Rules per VPC security group 50 Rules per VPC security group 60 Running On-Demand All F instances |check| 128 Running On-Demand All G instances |check| 128 @@ -495,15 +494,16 @@ VPC security groups per elastic network interface |check| 5 ECS ---- -===================================== =============== ======== ======= ==== -Limit Trusted Advisor Quotas API Default -===================================== =============== ======== ======= ==== -Clusters 2000 -Container Instances per Cluster 2000 -EC2 Tasks per Service (desired count) 1000 -Fargate Tasks 50 -Services per Cluster 1000 -===================================== =============== ======== ======= ==== +================================ =============== ======== ======= ===== +Limit Trusted Advisor Quotas API Default +================================ =============== ======== ======= ===== +Clusters 10000 +Container Instances per Cluster 2000 +Fargate On-Demand resource count 500 +Fargate Spot resource count 500 +Services per Cluster 2000 +Tasks per service 2000 +================================ =============== ======== ======= ===== .. _limits.EFS: From 93680d55939c2dcf620edf2b398871f6f529fe78 Mon Sep 17 00:00:00 2001 From: Jason Antman Date: Tue, 22 Sep 2020 13:24:16 -0400 Subject: [PATCH 15/15] bump version to 9.0.0 --- CHANGES.rst | 4 +++- awslimitchecker/version.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5f48c41c..95746971 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,9 @@ Changelog ========= -Unreleased Changes +.. _changelog.9_0_0: + +9.0.0 (2020-09-22) ------------------ **Important:** This release requires new IAM permissions: ``sts:GetCallerIdentity`` and ``cloudwatch:GetMetricData`` diff --git a/awslimitchecker/version.py b/awslimitchecker/version.py index dbbc9927..35342f25 100644 --- a/awslimitchecker/version.py +++ b/awslimitchecker/version.py @@ -47,7 +47,7 @@ except ImportError: logger.error("Unable to import versionfinder", exc_info=True) -_VERSION_TUP = (8, 1, 0) +_VERSION_TUP = (9, 0, 0) _VERSION = '.'.join([str(x) for x in _VERSION_TUP]) _PROJECT_URL = 'https://github.com/jantman/awslimitchecker'