diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py index 7b8b1cdb8..44612b824 100644 --- a/moto/ec2/exceptions.py +++ b/moto/ec2/exceptions.py @@ -1,4 +1,60 @@ +from werkzeug.exceptions import BadRequest +from jinja2 import Template + class InvalidIdError(RuntimeError): def __init__(self, id_value): super(InvalidIdError, self).__init__() self.id = id_value + + +class EC2ClientError(BadRequest): + def __init__(self, code, message): + super(EC2ClientError, self).__init__() + self.description = ERROR_RESPONSE_TEMPLATE.render( + code=code, message=message) + + +class DependencyViolationError(EC2ClientError): + def __init__(self, message): + super(DependencyViolationError, self).__init__( + "DependencyViolation", message) + + +class InvalidDHCPOptionsIdError(EC2ClientError): + def __init__(self, dhcp_options_id): + super(InvalidDHCPOptionsIdError, self).__init__( + "InvalidDhcpOptionID.NotFound", + "DhcpOptionID {0} does not exist." + .format(dhcp_options_id)) + + +class InvalidVPCIdError(EC2ClientError): + def __init__(self, vpc_id): + super(InvalidVPCIdError, self).__init__( + "InvalidVpcID.NotFound", + "VpcID {0} does not exist." + .format(vpc_id)) + + +class InvalidParameterValueError(EC2ClientError): + def __init__(self, parameter_value): + super(InvalidParameterValueError, self).__init__( + "InvalidParameterValue", + "Value ({0}) for parameter value is invalid. Invalid DHCP option value.".format( + parameter_value)) + + + + +ERROR_RESPONSE = u""" + + + + {{code}} + {{message}} + + + 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE + +""" +ERROR_RESPONSE_TEMPLATE = Template(ERROR_RESPONSE) diff --git a/moto/ec2/models.py b/moto/ec2/models.py index 57e7e7dc2..708756aff 100644 --- a/moto/ec2/models.py +++ b/moto/ec2/models.py @@ -5,9 +5,14 @@ from collections import defaultdict from boto.ec2.instance import Instance as BotoInstance, Reservation from moto.core import BaseBackend -from .exceptions import InvalidIdError +from .exceptions import ( + InvalidIdError, + DependencyViolationError, + InvalidDHCPOptionsIdError +) from .utils import ( random_ami_id, + random_dhcp_option_id, random_eip_allocation_id, random_eip_association_id, random_gateway_id, @@ -645,6 +650,7 @@ class VPC(object): def __init__(self, vpc_id, cidr_block): self.id = vpc_id self.cidr_block = cidr_block + self.dhcp_options = None @classmethod def create_from_cloudformation_json(cls, resource_name, cloudformation_json): @@ -678,7 +684,12 @@ class VPCBackend(object): return self.vpcs.values() def delete_vpc(self, vpc_id): - return self.vpcs.pop(vpc_id, None) + vpc = self.vpcs.pop(vpc_id, None) + if vpc and vpc.dhcp_options: + vpc.dhcp_options.vpc = None + self.delete_dhcp_options_set(vpc.dhcp_options.id) + vpc.dhcp_options = None + return vpc class Subnet(object): @@ -1039,12 +1050,70 @@ class ElasticAddressBackend(object): return False +class DHCPOptionsSet(object): + def __init__(self, domain_name_servers=None, domain_name=None, + ntp_servers=None, netbios_name_servers=None, + netbios_node_type=None): + self._options = { + "domain-name-servers": domain_name_servers, + "domain-name": domain_name, + "ntp-servers": ntp_servers, + "netbios-name-servers": netbios_name_servers, + "netbios-node-type": netbios_node_type, + } + self.id = random_dhcp_option_id() + self.vpc = None + + @property + def options(self): + return self._options + + +class DHCPOptionsSetBackend(object): + def __init__(self): + self.dhcp_options_sets = {} + super(DHCPOptionsSetBackend, self).__init__() + + def associate_dhcp_options(self, dhcp_options, vpc): + dhcp_options.vpc = vpc + vpc.dhcp_options = dhcp_options + + def create_dhcp_options( + self, domain_name_servers=None, domain_name=None, + ntp_servers=None, netbios_name_servers=None, + netbios_node_type=None): + options = DHCPOptionsSet( + domain_name_servers, domain_name, ntp_servers, + netbios_name_servers, netbios_node_type + ) + self.dhcp_options_sets[options.id] = options + return options + + def describe_dhcp_options(self, options_ids=None): + options_sets = [] + for option_id in options_ids or []: + if option_id in self.dhcp_options_sets: + options_sets.append(self.dhcp_options_sets[option_id]) + else: + raise InvalidDHCPOptionsIdError(option_id) + return options_sets or self.dhcp_options_sets.values() + + def delete_dhcp_options_set(self, options_id): + if options_id in self.dhcp_options_sets: + if self.dhcp_options_sets[options_id].vpc: + raise DependencyViolationError("Cannot delete assigned DHCP options.") + dhcp_opt = self.dhcp_options_sets.pop(options_id) + else: + raise InvalidDHCPOptionsIdError(options_id) + return True + + class EC2Backend(BaseBackend, InstanceBackend, TagBackend, AmiBackend, RegionsAndZonesBackend, SecurityGroupBackend, EBSBackend, VPCBackend, SubnetBackend, SubnetRouteTableAssociationBackend, RouteTableBackend, RouteBackend, InternetGatewayBackend, VPCGatewayAttachmentBackend, SpotRequestBackend, - ElasticAddressBackend, KeyPairBackend): + ElasticAddressBackend, KeyPairBackend, DHCPOptionsSetBackend): pass diff --git a/moto/ec2/responses/dhcp_options.py b/moto/ec2/responses/dhcp_options.py index f94abd9be..6f4ee8d2d 100644 --- a/moto/ec2/responses/dhcp_options.py +++ b/moto/ec2/responses/dhcp_options.py @@ -1,15 +1,156 @@ +from jinja2 import Template from moto.core.responses import BaseResponse +from moto.ec2.utils import ( + dhcp_configuration_from_querystring, + sequence_from_querystring) +from moto.ec2.models import ec2_backend +from moto.ec2.exceptions import( + InvalidVPCIdError, + InvalidParameterValueError + ) + +NETBIOS_NODE_TYPES = [1, 2, 4, 8] class DHCPOptions(BaseResponse): def associate_dhcp_options(self): - raise NotImplementedError('DHCPOptions(AmazonVPC).associate_dhcp_options is not yet implemented') + dhcp_opt_id = self.querystring.get("DhcpOptionsId", [None])[0] + vpc_id = self.querystring.get("VpcId", [None])[0] + + dhcp_opt = ec2_backend.describe_dhcp_options([dhcp_opt_id])[0] + + vpc = ec2_backend.get_vpc(vpc_id) + if not vpc: + raise InvalidVPCIdError(vpc_id) + + ec2_backend.associate_dhcp_options(dhcp_opt, vpc) + + template = Template(ASSOCIATE_DHCP_OPTIONS_RESPONSE) + return template.render() def create_dhcp_options(self): - raise NotImplementedError('DHCPOptions(AmazonVPC).create_dhcp_options is not yet implemented') + dhcp_config = dhcp_configuration_from_querystring(self.querystring) + + # TODO validate we only got the options we know about + + domain_name_servers = dhcp_config.get("domain-name-servers", None) + domain_name = dhcp_config.get("domain-name", None) + ntp_servers = dhcp_config.get("ntp-servers", None) + netbios_name_servers = dhcp_config.get("netbios-name-servers", None) + netbios_node_type = dhcp_config.get("netbios-node-type", None) + + for field_value in domain_name_servers, ntp_servers, netbios_name_servers: + if field_value and len(field_value) > 4: + raise InvalidParameterValueError(",".join(field_value)) + + if netbios_node_type and netbios_node_type[0] not in NETBIOS_NODE_TYPES: + raise InvalidParameterValueError(netbios_node_type) + + dhcp_options_set = ec2_backend.create_dhcp_options( + domain_name_servers=domain_name_servers, + domain_name=domain_name, + ntp_servers=ntp_servers, + netbios_name_servers=netbios_name_servers, + netbios_node_type=netbios_node_type + ) + + template = Template(CREATE_DHCP_OPTIONS_RESPONSE) + return template.render(dhcp_options_set=dhcp_options_set) def delete_dhcp_options(self): - raise NotImplementedError('DHCPOptions(AmazonVPC).delete_dhcp_options is not yet implemented') + # TODO InvalidDhcpOptionsId.Malformed + + delete_status = False + + if "DhcpOptionsId" in self.querystring: + dhcp_opt_id = self.querystring["DhcpOptionsId"][0] + + delete_status = ec2_backend.delete_dhcp_options_set(dhcp_opt_id) + + template = Template(DELETE_DHCP_OPTIONS_RESPONSE) + return template.render(delete_status=delete_status) def describe_dhcp_options(self): - raise NotImplementedError('DHCPOptions(AmazonVPC).describe_dhcp_options is not yet implemented') + + if "Filter.1.Name" in self.querystring: + raise NotImplementedError("Filtering not supported in describe_dhcp_options.") + elif "DhcpOptionsId.1" in self.querystring: + dhcp_opt_ids = sequence_from_querystring("DhcpOptionsId", self.querystring) + dhcp_opt = ec2_backend.describe_dhcp_options(dhcp_opt_ids) + else: + dhcp_opt = ec2_backend.describe_dhcp_options() + template = Template(DESCRIBE_DHCP_OPTIONS_RESPONSE) + return template.render(dhcp_options=dhcp_opt) + + +CREATE_DHCP_OPTIONS_RESPONSE = u""" + + 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE + + {{ dhcp_options_set.id }} + + {% for key, values in dhcp_options_set.options.iteritems() %} + {{ values }} + {% if values %} + + {{key}} + + {% for value in values %} + + {{ value }} + + {% endfor %} + + + {% endif %} + {% endfor %} + + + + +""" + +DELETE_DHCP_OPTIONS_RESPONSE = u""" + + 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE + {{delete_status}} + +""" + +DESCRIBE_DHCP_OPTIONS_RESPONSE = u""" + + 7a62c49f-347e-4fc4-9331-6e8eEXAMPLE + + {% for dhcp_options_set in dhcp_options %} + + {{ dhcp_options_set.id }} + + {% for key, values in dhcp_options_set.options.iteritems() %} + {{ values }} + {% if values %} + + {{key}} + + {% for value in values %} + + {{ value }} + + {% endfor %} + + + {% endif %} + {% endfor %} + + + + {% endfor %} + + +""" + +ASSOCIATE_DHCP_OPTIONS_RESPONSE = u""" + +7a62c49f-347e-4fc4-9331-6e8eEXAMPLE +true + +""" diff --git a/moto/ec2/utils.py b/moto/ec2/utils.py index dde213287..b41d172f5 100644 --- a/moto/ec2/utils.py +++ b/moto/ec2/utils.py @@ -62,6 +62,10 @@ def random_eip_allocation_id(): return random_id(prefix='eipalloc') +def random_dhcp_option_id(): + return random_id(prefix='dopt') + + def random_ip(): return "127.{0}.{1}.{2}".format( random.randint(0, 255), @@ -112,6 +116,44 @@ def resource_ids_from_querystring(querystring_dict): return response_values +def dhcp_configuration_from_querystring(querystring, option=u'DhcpConfiguration'): + """ + turn: + {u'AWSAccessKeyId': [u'the_key'], + u'Action': [u'CreateDhcpOptions'], + u'DhcpConfiguration.1.Key': [u'domain-name'], + u'DhcpConfiguration.1.Value.1': [u'example.com'], + u'DhcpConfiguration.2.Key': [u'domain-name-servers'], + u'DhcpConfiguration.2.Value.1': [u'10.0.0.6'], + u'DhcpConfiguration.2.Value.2': [u'10.0.0.7'], + u'Signature': [u'uUMHYOoLM6r+sT4fhYjdNT6MHw22Wj1mafUpe0P0bY4='], + u'SignatureMethod': [u'HmacSHA256'], + u'SignatureVersion': [u'2'], + u'Timestamp': [u'2014-03-18T21:54:01Z'], + u'Version': [u'2013-10-15']} + into: + {u'domain-name': [u'example.com'], u'domain-name-servers': [u'10.0.0.6', u'10.0.0.7']} + """ + + key_needle = re.compile(u'{0}.[0-9]+.Key'.format(option), re.UNICODE) + response_values = {} + + for key, value in querystring.iteritems(): + if key_needle.match(key): + values = [] + key_index = key.split(".")[1] + value_index = 1 + while True: + value_key = u'{0}.{1}.Value.{2}'.format(option, key_index, value_index) + if value_key in querystring: + values.extend(querystring[value_key]) + else: + break + value_index += 1 + response_values[value[0]] = values + return response_values + + def filters_from_querystring(querystring_dict): response_values = {} for key, value in querystring_dict.iteritems(): @@ -131,6 +173,7 @@ def keypair_names_from_querystring(querystring_dict): keypair_names.append(value[0]) return keypair_names + filter_dict_attribute_mapping = { 'instance-state-name': 'state' } @@ -161,7 +204,7 @@ def filter_reservations(reservations, filter_dict): return result -# not really random +# not really random ( http://xkcd.com/221/ ) def random_key_pair(): return { 'fingerprint': ('1f:51:ae:28:bf:89:e9:d8:1f:25:5d:37:2d:' diff --git a/tests/test_ec2/test_dhcp_options.py b/tests/test_ec2/test_dhcp_options.py index 4806db2b8..6ed5e5826 100644 --- a/tests/test_ec2/test_dhcp_options.py +++ b/tests/test_ec2/test_dhcp_options.py @@ -1,9 +1,118 @@ import boto +from boto.exception import EC2ResponseError + import sure # noqa from moto import mock_ec2 +SAMPLE_DOMAIN_NAME = u'example.com' +SAMPLE_NAME_SERVERS = [u'10.0.0.6', u'10.0.0.7'] + @mock_ec2 -def test_dhcp_options(): - pass +def test_dhcp_options_associate(): + """ associate dhcp option """ + conn = boto.connect_vpc('the_key', 'the_secret') + dhcp_options = conn.create_dhcp_options(SAMPLE_DOMAIN_NAME, SAMPLE_NAME_SERVERS) + vpc = conn.create_vpc("10.0.0.0/16") + + rval = conn.associate_dhcp_options(dhcp_options.id, vpc.id) + rval.should.be.equal(True) + + +@mock_ec2 +def test_dhcp_options_associate_invalid_dhcp_id(): + """ associate dhcp option bad dhcp options id """ + conn = boto.connect_vpc('the_key', 'the_secret') + vpc = conn.create_vpc("10.0.0.0/16") + + conn.associate_dhcp_options.when.called_with("foo", vpc.id).should.throw(EC2ResponseError) + + +@mock_ec2 +def test_dhcp_options_associate_invalid_vpc_id(): + """ associate dhcp option invalid vpc id """ + conn = boto.connect_vpc('the_key', 'the_secret') + dhcp_options = conn.create_dhcp_options(SAMPLE_DOMAIN_NAME, SAMPLE_NAME_SERVERS) + + conn.associate_dhcp_options.when.called_with(dhcp_options.id, "foo").should.throw(EC2ResponseError) + + +@mock_ec2 +def test_dhcp_options_delete_with_vpc(): + """Test deletion of dhcp options with vpc""" + conn = boto.connect_vpc('the_key', 'the_secret') + dhcp_options = conn.create_dhcp_options(SAMPLE_DOMAIN_NAME, SAMPLE_NAME_SERVERS) + dhcp_options_id = dhcp_options.id + vpc = conn.create_vpc("10.0.0.0/16") + + rval = conn.associate_dhcp_options(dhcp_options_id, vpc.id) + rval.should.be.equal(True) + + #conn.delete_dhcp_options(dhcp_options_id) + conn.delete_dhcp_options.when.called_with(dhcp_options_id).should.throw(EC2ResponseError) + vpc.delete() + + conn.get_all_dhcp_options.when.called_with([dhcp_options_id]).should.throw(EC2ResponseError) + + +@mock_ec2 +def test_create_dhcp_options(): + """Create most basic dhcp option""" + conn = boto.connect_vpc('the_key', 'the_secret') + + dhcp_option = conn.create_dhcp_options(SAMPLE_DOMAIN_NAME, SAMPLE_NAME_SERVERS) + dhcp_option.options[u'domain-name'][0].should.be.equal(SAMPLE_DOMAIN_NAME) + dhcp_option.options[u'domain-name-servers'][0].should.be.equal(SAMPLE_NAME_SERVERS[0]) + dhcp_option.options[u'domain-name-servers'][1].should.be.equal(SAMPLE_NAME_SERVERS[1]) + + +@mock_ec2 +def test_create_dhcp_options_invalid_options(): + """Create invalid dhcp options""" + conn = boto.connect_vpc('the_key', 'the_secret') + servers = ["f", "f", "f", "f", "f"] + conn.create_dhcp_options.when.called_with(ntp_servers=servers).should.throw(EC2ResponseError) + conn.create_dhcp_options.when.called_with(netbios_node_type="0").should.throw(EC2ResponseError) + + +@mock_ec2 +def test_describe_dhcp_options(): + """Test dhcp options lookup by id""" + conn = boto.connect_vpc('the_key', 'the_secret') + + dhcp_option = conn.create_dhcp_options() + dhcp_options = conn.get_all_dhcp_options([dhcp_option.id]) + dhcp_options.should.be.length_of(1) + + dhcp_options = conn.get_all_dhcp_options() + dhcp_options.should.be.length_of(1) + + +@mock_ec2 +def test_describe_dhcp_options_invalid_id(): + """get error on invalid dhcp_option_id lookup""" + conn = boto.connect_vpc('the_key', 'the_secret') + + conn.get_all_dhcp_options.when.called_with(["1"]).should.throw(EC2ResponseError) + + +@mock_ec2 +def test_delete_dhcp_options(): + """delete dhcp option""" + conn = boto.connect_vpc('the_key', 'the_secret') + + dhcp_option = conn.create_dhcp_options() + dhcp_options = conn.get_all_dhcp_options([dhcp_option.id]) + dhcp_options.should.be.length_of(1) + + conn.delete_dhcp_options(dhcp_option.id) # .should.be.equal(True) + conn.get_all_dhcp_options.when.called_with([dhcp_option.id]).should.throw(EC2ResponseError) + + +@mock_ec2 +def test_delete_dhcp_options_invalid_id(): + conn = boto.connect_vpc('the_key', 'the_secret') + + dhcp_option = conn.create_dhcp_options() + conn.delete_dhcp_options.when.called_with("1").should.throw(EC2ResponseError)