Repository: zalando-stups/senza

Issue:
Only call API for Route53 config if there is changes to be made
Only call AWS API when there is actual changes to be made.

Users reporting this exception:
```
     raise ParamValidationError(report=report.generate_report())
 botocore.exceptions.ParamValidationError: Parameter validation failed:
 Invalid length for parameter ChangeBatch.Changes, value: 0, valid range: 1-inf
```

Error comes from https://github.com/zalando-stups/senza/blob/master/senza/manaus/route53.py#L114-L115

Comments:

Patch:
diff --git a/senza/cli.py b/senza/cli.py
index f863831..fdb07f6 100755
--- a/senza/cli.py
+++ b/senza/cli.py
@@ -35,14 +35,16 @@ from .aws import (StackReference, get_account_alias, get_account_id,
 from .components import evaluate_template, get_component
 from .components.stups_auto_configuration import find_taupage_image
 from .error_handling import HandleExceptions
-from .exceptions import VPCError
+from .manaus.ec2 import EC2
+from .manaus.exceptions import VPCError
 from .manaus.route53 import Route53, Route53Record
 from .patch import patch_auto_scaling_group
 from .respawn import get_auto_scaling_group, respawn_auto_scaling_group
 from .stups.piu import Piu
 from .templates import get_template_description, get_templates
 from .templates._helper import get_mint_bucket_name
-from .traffic import change_version_traffic, get_records, print_version_traffic, resolve_to_ip_addresses
+from .traffic import (change_version_traffic, get_records,
+                      print_version_traffic, resolve_to_ip_addresses)
 from .utils import (camel_case_to_underscore, ensure_keys, named_value,
                     pystache_render)
 
@@ -316,22 +318,21 @@ class AccountArguments:
     @property
     def VpcID(self):
         if self.__VpcID is None:
-            ec2 = boto3.resource('ec2', self.Region)
-            vpc_list = list()
-            for vpc in ec2.vpcs.all():  # don't use the list from blow. .all() use a internal pageing!
-                if vpc.is_default:
-                    self.__VpcID = vpc.vpc_id
-                    break
-                vpc_list.append(vpc)
-            else:
-                if len(vpc_list) == 1:
-                    # Use the only one VPC if no default VPC found
-                    self.__VpcID = vpc_list[0].vpc_id
-                elif len(vpc_list) > 1:
-                    raise VPCError('Multiple VPCs are only supported if one '
-                                   'VPC is the default VPC (IsDefault=true)!')
-                else:
-                    raise VPCError('Can\'t find any VPC!')
+            ec2 = EC2(self.Region)
+            try:
+                vpc = ec2.get_default_vpc()
+            except VPCError as error:
+                if sys.stdin.isatty() and error.number_of_vpcs:
+                    # if running in interactive terminal and there are VPCs
+                    # to choose from
+                    vpcs = ec2.get_all_vpcs()
+                    options = [(vpc.vpc_id, str(vpc)) for vpc in vpcs]
+                    print("Can't find a default VPC")
+                    vpc = choice("Select VPC to use",
+                                 options=options)
+                else:  # if not running in interactive terminal (e.g Jenkins)
+                    raise
+            self.__VpcID = vpc.vpc_id
         return self.__VpcID
 
     @property
diff --git a/senza/exceptions.py b/senza/exceptions.py
index 822221f..cdf4812 100644
--- a/senza/exceptions.py
+++ b/senza/exceptions.py
@@ -1,6 +1,6 @@
 class SenzaException(Exception):
     """
-    Base class for Senza execeptions
+    Base class for Senza exceptions
     """
 
 
@@ -11,15 +11,6 @@ class InvalidState(SenzaException):
     """
 
 
-class VPCError(SenzaException, AttributeError):
-    """
-    Error raised when there are issues with VPCs configuration
-    """
-
-    def __init__(self, message):
-        super().__init__(message)
-
-
 class PiuNotFound(SenzaException, FileNotFoundError):
     """
     Error raised when piu executable is not found
diff --git a/senza/manaus/ec2.py b/senza/manaus/ec2.py
new file mode 100644
index 0000000..9ea2600
--- /dev/null
+++ b/senza/manaus/ec2.py
@@ -0,0 +1,84 @@
+from collections import OrderedDict
+from typing import Dict, List, Iterator
+
+import boto3
+
+from .exceptions import VPCError
+
+
+class EC2VPC:
+
+    """
+    See:
+    http://boto3.readthedocs.io/en/latest/reference/services/ec2.html#vpc
+    """
+
+    def __init__(self,
+                 vpc_id: str,
+                 is_default: bool,
+                 tags: List[Dict[str, str]]):
+        self.vpc_id = vpc_id
+        self.is_default = is_default
+        self.tags = OrderedDict([(t['Key'], t['Value']) for t in tags])  # type: Dict[str, str]
+
+        self.name = self.tags.get('Name', self.vpc_id)
+
+    def __str__(self):
+        return '{name} ({vpc_id})'.format_map(vars(self))
+
+    def __repr__(self):
+        return '<EC2VPC: {name} ({vpc_id})>'.format_map(vars(self))
+
+    @classmethod
+    def from_boto_vpc(cls, vpc) -> "EC2VPC":
+        """
+        Converts an ec2.VPC as returned by resource.vpcs.all()
+
+        See:
+        http://boto3.readthedocs.io/en/latest/reference/services/ec2.html#vpc
+        """
+
+        return cls(vpc.vpc_id, vpc.is_default, vpc.tags)
+
+
+class EC2:
+
+    def __init__(self, region: str):
+        self.region = region
+
+    def get_all_vpcs(self) -> Iterator[EC2VPC]:
+        """
+        Get all VPCs from the account
+        """
+        resource = boto3.resource('ec2', self.region)
+
+        for vpc in resource.vpcs.all():
+            yield EC2VPC.from_boto_vpc(vpc)
+
+    def get_default_vpc(self) -> EC2VPC:
+        """
+        Get one VPC from the account, either the default or, if only one
+        exists, that one.
+        """
+        resource = boto3.resource('ec2', self.region)
+
+        number_of_vpcs = 0
+        # We shouldn't use the list with .all() because it has internal paging!
+        for vpc_number, vpc in enumerate(resource.vpcs.all(), start=1):
+            number_of_vpcs = vpc_number
+
+            if vpc.is_default:
+                return EC2VPC.from_boto_vpc(vpc)
+
+            if vpc_number == 1:
+                first_vpc = vpc
+
+        if number_of_vpcs == 0:
+            raise VPCError("Can't find any VPC!", number_of_vpcs)
+        elif number_of_vpcs == 1:
+            # Use the only one VPC if it's not the default VPC found
+            return EC2VPC.from_boto_vpc(first_vpc)
+        else:
+            raise VPCError("Multiple VPCs are only supported if one "
+                           "VPC is the default VPC (IsDefault=true)!",
+                           number_of_vpcs)
diff --git a/senza/manaus/exceptions.py b/senza/manaus/exceptions.py
index 08b63be..d07b441 100644
--- a/senza/manaus/exceptions.py
+++ b/senza/manaus/exceptions.py
@@ -1,6 +1,6 @@
 class ManausException(Exception):
     """
-    Base class for Manaus execeptions
+    Base class for Manaus exceptions
     """
 
 
@@ -36,3 +36,13 @@ class RecordNotFound(ManausException):
 
     def __init__(self, name: str):
         super().__init__('Route 53 Record not found: {}'.format(name))
+
+
+class VPCError(ManausException, AttributeError):
+    """
+    Error raised when there are issues with VPCs configuration
+    """
+
+    def __init__(self, message: str, number_of_vpcs: int=None):
+        super().__init__(message)
+        self.number_of_vpcs = number_of_vpcs
diff --git a/senza/manaus/route53.py b/senza/manaus/route53.py
index 0075eb0..6aab215 100644
--- a/senza/manaus/route53.py
+++ b/senza/manaus/route53.py
@@ -112,8 +112,9 @@ class Route53HostedZone:
                       'ResourceRecordSet': record.boto_dict}
             change_batch['Changes'].append(change)
 
-        client.change_resource_record_sets(HostedZoneId=self.id,
-                                           ChangeBatch=change_batch)
+        if change_batch['Changes']:
+            client.change_resource_record_sets(HostedZoneId=self.id,
+                                               ChangeBatch=change_batch)
 
         return change_batch
 


Test patch:
diff --git a/tests/test_manaus/test_ec2.py b/tests/test_manaus/test_ec2.py
new file mode 100644
index 0000000..36f1588
--- /dev/null
+++ b/tests/test_manaus/test_ec2.py
@@ -0,0 +1,95 @@
+from unittest.mock import MagicMock
+
+import pytest
+from senza.manaus.ec2 import EC2, EC2VPC
+from senza.manaus.exceptions import VPCError
+
+
+def test_from_boto_vpc():
+    mock_vpc = MagicMock()
+    mock_vpc.vpc_id = 'vpc-id'
+    mock_vpc.is_default = True
+    mock_vpc.tags = [{'Key': 'mykey', 'Value': 'myvalue'},
+                     {'Key': 'theanswer', 'Value': '42'},
+                     {'Key': 'Name', 'Value': 'my-vpc'}]
+    vpc = EC2VPC.from_boto_vpc(mock_vpc)
+
+    assert vpc.vpc_id == 'vpc-id'
+    assert vpc.is_default
+    assert vpc.tags['mykey'] == 'myvalue'
+    assert vpc.tags['theanswer'] == '42'
+    assert vpc.name == 'my-vpc'
+
+
+def test_get_default_vpc(monkeypatch):
+    mock_vpc1 = MagicMock()
+    mock_vpc1.vpc_id = 'vpc-id1'
+    mock_vpc1.is_default = True
+    mock_vpc1.tags = []
+
+    mock_vpc2 = MagicMock()
+    mock_vpc2.vpc_id = 'vpc-id2'
+    mock_vpc2.is_default = False
+    mock_vpc2.tags = []
+
+    mock_vpc3 = MagicMock()
+    mock_vpc3.vpc_id = 'vpc-id3'
+    mock_vpc3.is_default = False
+    mock_vpc3.tags = []
+
+    m_resource = MagicMock()
+    m_resource.return_value = m_resource
+    monkeypatch.setattr('boto3.resource', m_resource)
+
+    ec2 = EC2('eu-test-1')
+
+    # return default vpc
+    m_resource.vpcs.all.return_value = [mock_vpc1, mock_vpc2]
+    vpc1 = ec2.get_default_vpc()
+    assert vpc1.vpc_id == 'vpc-id1'
+
+    # ony one, non default
+    m_resource.vpcs.all.return_value = [mock_vpc2]
+    vpc2 = ec2.get_default_vpc()
+    assert vpc2.vpc_id == 'vpc-id2'
+
+    # no vpcs
+    m_resource.vpcs.all.return_value = []
+    with pytest.raises(VPCError) as exc_info:
+        ec2.get_default_vpc()
+    assert str(exc_info.value) == "Can't find any VPC!"
+
+    # no vpcs
+    m_resource.vpcs.all.return_value = [mock_vpc2, mock_vpc3]
+    with pytest.raises(VPCError) as exc_info:
+        ec2.get_default_vpc()
+
+    assert str(exc_info.value) == ("Multiple VPCs are only supported if one "
+                                   "VPC is the default VPC (IsDefault=true)!")
+
+
+def test_get_all_vpc(monkeypatch):
+    mock_vpc1 = MagicMock()
+    mock_vpc1.vpc_id = 'vpc-id1'
+    mock_vpc1.is_default = True
+    mock_vpc1.tags = []
+
+    mock_vpc2 = MagicMock()
+    mock_vpc2.vpc_id = 'vpc-id2'
+    mock_vpc2.is_default = False
+    mock_vpc2.tags = []
+
+    mock_vpc3 = MagicMock()
+    mock_vpc3.vpc_id = 'vpc-id3'
+    mock_vpc3.is_default = False
+    mock_vpc3.tags = []
+
+    m_resource = MagicMock()
+    m_resource.return_value = m_resource
+    monkeypatch.setattr('boto3.resource', m_resource)
+
+    ec2 = EC2('eu-test-1')
+
+    m_resource.vpcs.all.return_value = [mock_vpc1, mock_vpc2, mock_vpc3]
+    vpcs = list(ec2.get_all_vpcs())
+    assert len(vpcs) == 3
diff --git a/tests/test_manaus/test_route53.py b/tests/test_manaus/test_route53.py
index 2441ba1..24c5441 100644
--- a/tests/test_manaus/test_route53.py
+++ b/tests/test_manaus/test_route53.py
@@ -209,6 +209,12 @@ def test_hosted_zone_upsert(monkeypatch):
         ChangeBatch={'Changes': expected_changes,
                      'Comment': 'test'})
 
+    m_client.change_resource_record_sets.reset_mock()
+    change_batch2 = hosted_zone.upsert([], comment="test")
+    assert change_batch2['Comment'] == "test"
+    assert change_batch2['Changes'] == []
+    m_client.change_resource_record_sets.assert_not_called()
+
 
 def test_hosted_zone_create(monkeypatch):
     m_client = MagicMock()