diff options
29 files changed, 1508 insertions, 44 deletions
@@ -1,9 +1,9 @@ #### boto #### -boto 2.9.7 +boto 2.9.8 -Released: 08-July-2013 +Released: 18-July-2013 .. image:: https://travis-ci.org/boto/boto.png?branch=develop :target: https://travis-ci.org/boto/boto diff --git a/bin/elbadmin b/bin/elbadmin index e0aaf9d9..87dd2b14 100755 --- a/bin/elbadmin +++ b/bin/elbadmin @@ -108,7 +108,11 @@ def get(elb, name): print # Make map of all instance Id's to Name tags - ec2 = boto.connect_ec2() + if not options.region: + ec2 = boto.connect_ec2() + else: + import boto.ec2.elb + ec2 = boto.ec2.connect_to_region(options.region) instance_health = b.get_instance_health() instances = [state.instance_id for state in instance_health] @@ -236,6 +240,9 @@ if __name__ == "__main__": parser.add_option("-l", "--listener", help="Specify Listener in,out,proto", action="append", default=[], dest="listeners") + parser.add_option("-r", "--region", + help="Region to connect to", + action="store", dest="region") (options, args) = parser.parse_args() @@ -243,7 +250,11 @@ if __name__ == "__main__": parser.print_help() sys.exit(1) - elb = boto.connect_elb() + if not options.region: + elb = boto.connect_elb() + else: + import boto.ec2.elb + elb = boto.ec2.elb.connect_to_region(options.region) print "%s" % (elb.region.endpoint) diff --git a/boto/__init__.py b/boto/__init__.py index 033d24b8..20c9000d 100644 --- a/boto/__init__.py +++ b/boto/__init__.py @@ -36,7 +36,7 @@ import logging.config import urlparse from boto.exception import InvalidUriError -__version__ = '2.9.7' +__version__ = '2.9.8' Version = __version__ # for backware compatibility UserAgent = 'Boto/%s (%s)' % (__version__, sys.platform) diff --git a/boto/cloudformation/connection.py b/boto/cloudformation/connection.py index 84c7680e..5970225f 100644 --- a/boto/cloudformation/connection.py +++ b/boto/cloudformation/connection.py @@ -362,3 +362,9 @@ class CloudFormationConnection(AWSQueryConnection): " specified, only TemplateBody will be honored by the API") return self.get_object('ValidateTemplate', params, Template, verb="POST") + + def cancel_update_stack(self, stack_name_or_id=None): + params = {} + if stack_name_or_id: + params['StackName'] = stack_name_or_id + return self.get_status('CancelUpdateStack', params) diff --git a/boto/ec2/elb/__init__.py b/boto/ec2/elb/__init__.py index c5e71b9f..ed9aaeaa 100644 --- a/boto/ec2/elb/__init__.py +++ b/boto/ec2/elb/__init__.py @@ -137,8 +137,8 @@ class ELBConnection(AWSQueryConnection): return self.get_list('DescribeLoadBalancers', params, [('member', LoadBalancer)]) - def create_load_balancer(self, name, zones, listeners, subnets=None, - security_groups=None, scheme='internet-facing'): + def create_load_balancer(self, name, zones, listeners=None, subnets=None, + security_groups=None, scheme='internet-facing', complex_listeners=None): """ Create a new load balancer for your account. By default the load balancer will be created in EC2. To create a load balancer inside a @@ -157,7 +157,7 @@ class ELBConnection(AWSQueryConnection): (LoadBalancerPortNumber, InstancePortNumber, Protocol, [SSLCertificateId]) where LoadBalancerPortNumber and InstancePortNumber are integer values between 1 and 65535, - Protocol is a string containing either 'TCP', 'HTTP' or + Protocol is a string containing either 'TCP', 'SSL', HTTP', or 'HTTPS'; SSLCertificateID is the ARN of a AWS AIM certificate, and must be specified when doing HTTPS. @@ -182,20 +182,54 @@ class ELBConnection(AWSQueryConnection): This option is only available for LoadBalancers attached to an Amazon VPC. + :type complex_listeners: List of tuples + :param complex_listeners: Each tuple contains four or five values, + (LoadBalancerPortNumber, InstancePortNumber, Protocol, InstanceProtocol, + SSLCertificateId). + + Where; + - LoadBalancerPortNumber and InstancePortNumber are integer + values between 1 and 65535. + - Protocol and InstanceProtocol is a string containing either 'TCP', + 'SSL', 'HTTP', or 'HTTPS' + - SSLCertificateId is the ARN of an SSL certificate loaded into + AWS IAM + :rtype: :class:`boto.ec2.elb.loadbalancer.LoadBalancer` :return: The newly created :class:`boto.ec2.elb.loadbalancer.LoadBalancer` """ + if not listeners and not complex_listeners: + # Must specify one of the two options + return None + params = {'LoadBalancerName': name, 'Scheme': scheme} - for index, listener in enumerate(listeners): - i = index + 1 - protocol = listener[2].upper() - params['Listeners.member.%d.LoadBalancerPort' % i] = listener[0] - params['Listeners.member.%d.InstancePort' % i] = listener[1] - params['Listeners.member.%d.Protocol' % i] = listener[2] - if protocol == 'HTTPS' or protocol == 'SSL': - params['Listeners.member.%d.SSLCertificateId' % i] = listener[3] + + # Handle legacy listeners + if listeners: + for index, listener in enumerate(listeners): + i = index + 1 + protocol = listener[2].upper() + params['Listeners.member.%d.LoadBalancerPort' % i] = listener[0] + params['Listeners.member.%d.InstancePort' % i] = listener[1] + params['Listeners.member.%d.Protocol' % i] = listener[2] + if protocol == 'HTTPS' or protocol == 'SSL': + params['Listeners.member.%d.SSLCertificateId' % i] = listener[3] + + # Handle the full listeners + if complex_listeners: + for index, listener in enumerate(complex_listeners): + i = index + 1 + protocol = listener[2].upper() + InstanceProtocol = listener[3].upper() + params['Listeners.member.%d.LoadBalancerPort' % i] = listener[0] + params['Listeners.member.%d.InstancePort' % i] = listener[1] + params['Listeners.member.%d.Protocol' % i] = listener[2] + params['Listeners.member.%d.InstanceProtocol' % i] = listener[3] + if protocol == 'HTTPS' or protocol == 'SSL': + params['Listeners.member.%d.SSLCertificateId' % i] = listener[4] + if zones: self.build_list_params(params, zones, 'AvailabilityZones.member.%d') @@ -215,7 +249,7 @@ class ELBConnection(AWSQueryConnection): load_balancer.security_groups = security_groups return load_balancer - def create_load_balancer_listeners(self, name, listeners): + def create_load_balancer_listeners(self, name, listeners=None, complex_listeners=None): """ Creates a Listener (or group of listeners) for an existing Load Balancer @@ -224,26 +258,59 @@ class ELBConnection(AWSQueryConnection): :param name: The name of the load balancer to create the listeners for :type listeners: List of tuples - :param listeners: Each tuple contains three values, + :param listeners: Each tuple contains three or four values, (LoadBalancerPortNumber, InstancePortNumber, Protocol, [SSLCertificateId]) where LoadBalancerPortNumber and InstancePortNumber are integer values between 1 and 65535, - Protocol is a string containing either 'TCP', 'HTTP', - 'HTTPS', or 'SSL'; SSLCertificateID is the ARN of a AWS - AIM certificate, and must be specified when doing HTTPS or - SSL. + Protocol is a string containing either 'TCP', 'SSL', HTTP', or + 'HTTPS'; SSLCertificateID is the ARN of a AWS AIM + certificate, and must be specified when doing HTTPS. + + :type complex_listeners: List of tuples + :param complex_listeners: Each tuple contains four or five values, + (LoadBalancerPortNumber, InstancePortNumber, Protocol, InstanceProtocol, + SSLCertificateId). + + Where; + - LoadBalancerPortNumber and InstancePortNumber are integer + values between 1 and 65535. + - Protocol and InstanceProtocol is a string containing either 'TCP', + 'SSL', 'HTTP', or 'HTTPS' + - SSLCertificateId is the ARN of an SSL certificate loaded into + AWS IAM :return: The status of the request """ + if not listeners and not complex_listeners: + # Must specify one of the two options + return None + params = {'LoadBalancerName': name} - for index, listener in enumerate(listeners): - i = index + 1 - protocol = listener[2].upper() - params['Listeners.member.%d.LoadBalancerPort' % i] = listener[0] - params['Listeners.member.%d.InstancePort' % i] = listener[1] - params['Listeners.member.%d.Protocol' % i] = listener[2] - if protocol == 'HTTPS' or protocol == 'SSL': - params['Listeners.member.%d.SSLCertificateId' % i] = listener[3] + + # Handle the simple listeners + if listeners: + for index, listener in enumerate(listeners): + i = index + 1 + protocol = listener[2].upper() + params['Listeners.member.%d.LoadBalancerPort' % i] = listener[0] + params['Listeners.member.%d.InstancePort' % i] = listener[1] + params['Listeners.member.%d.Protocol' % i] = listener[2] + if protocol == 'HTTPS' or protocol == 'SSL': + params['Listeners.member.%d.SSLCertificateId' % i] = listener[3] + + # Handle the full listeners + if complex_listeners: + for index, listener in enumerate(complex_listeners): + i = index + 1 + protocol = listener[2].upper() + InstanceProtocol = listener[3].upper() + params['Listeners.member.%d.LoadBalancerPort' % i] = listener[0] + params['Listeners.member.%d.InstancePort' % i] = listener[1] + params['Listeners.member.%d.Protocol' % i] = listener[2] + params['Listeners.member.%d.InstanceProtocol' % i] = listener[3] + if protocol == 'HTTPS' or protocol == 'SSL': + params['Listeners.member.%d.SSLCertificateId' % i] = listener[4] + return self.get_status('CreateLoadBalancerListeners', params) def delete_load_balancer(self, name): diff --git a/boto/ec2/elb/listener.py b/boto/ec2/elb/listener.py index a50b02cd..cf26c41c 100644 --- a/boto/ec2/elb/listener.py +++ b/boto/ec2/elb/listener.py @@ -30,16 +30,19 @@ class Listener(object): """ def __init__(self, load_balancer=None, load_balancer_port=0, - instance_port=0, protocol='', ssl_certificate_id=None): + instance_port=0, protocol='', ssl_certificate_id=None, instance_protocol=None): self.load_balancer = load_balancer self.load_balancer_port = load_balancer_port self.instance_port = instance_port self.protocol = protocol + self.instance_protocol = instance_protocol self.ssl_certificate_id = ssl_certificate_id self.policy_names = ListElement() def __repr__(self): r = "(%d, %d, '%s'" % (self.load_balancer_port, self.instance_port, self.protocol) + if self.instance_protocol: + r += ", '%s'" % self.instance_protocol if self.ssl_certificate_id: r += ', %s' % (self.ssl_certificate_id) r += ')' @@ -55,6 +58,8 @@ class Listener(object): self.load_balancer_port = int(value) elif name == 'InstancePort': self.instance_port = int(value) + elif name == 'InstanceProtocol': + self.instance_protocol = value elif name == 'Protocol': self.protocol = value elif name == 'SSLCertificateId': @@ -65,6 +70,9 @@ class Listener(object): def get_tuple(self): return self.load_balancer_port, self.instance_port, self.protocol + def get_complex_tuple(self): + return self.load_balancer_port, self.instance_port, self.protocol, self.instance_protocol + def __getitem__(self, key): if key == 0: return self.load_balancer_port @@ -72,4 +80,6 @@ class Listener(object): return self.instance_port if key == 2: return self.protocol + if key == 4: + return self.instance_protocol raise KeyError diff --git a/boto/exception.py b/boto/exception.py index 64618368..0c871b37 100644 --- a/boto/exception.py +++ b/boto/exception.py @@ -409,6 +409,13 @@ class NoAuthHandlerFound(Exception): """Is raised when no auth handlers were found ready to authenticate.""" pass +class InvalidLifecycleConfigError(Exception): + """Exception raised when GCS lifecycle configuration XML is invalid.""" + + def __init__(self, message): + Exception.__init__(self, message) + self.message = message + # Enum class for resumable upload failure disposition. class ResumableTransferDisposition(object): # START_OVER means an attempt to resume an existing transfer failed, diff --git a/boto/gs/bucket.py b/boto/gs/bucket.py index 96c2bdc3..a8ced49a 100644 --- a/boto/gs/bucket.py +++ b/boto/gs/bucket.py @@ -30,6 +30,7 @@ from boto.gs.acl import ACL, CannedACLStrings from boto.gs.acl import SupportedPermissions as GSPermissions from boto.gs.bucketlistresultset import VersionedBucketListResultSet from boto.gs.cors import Cors +from boto.gs.lifecycle import LifecycleConfig from boto.gs.key import Key as GSKey from boto.s3.acl import Policy from boto.s3.bucket import Bucket as S3Bucket @@ -39,6 +40,7 @@ from boto.utils import get_utf8_value DEF_OBJ_ACL = 'defaultObjectAcl' STANDARD_ACL = 'acl' CORS_ARG = 'cors' +LIFECYCLE_ARG = 'lifecycle' class Bucket(S3Bucket): """Represents a Google Cloud Storage bucket.""" @@ -918,3 +920,43 @@ class Bucket(S3Bucket): else: req_body = self.VersioningBody % ('Suspended') self.set_subresource('versioning', req_body, headers=headers) + + def get_lifecycle_config(self, headers=None): + """ + Returns the current lifecycle configuration on the bucket. + + :rtype: :class:`boto.gs.lifecycle.LifecycleConfig` + :returns: A LifecycleConfig object that describes all current + lifecycle rules in effect for the bucket. + """ + response = self.connection.make_request('GET', self.name, + query_args=LIFECYCLE_ARG, headers=headers) + body = response.read() + boto.log.debug(body) + if response.status == 200: + lifecycle_config = LifecycleConfig() + h = handler.XmlHandler(lifecycle_config, self) + xml.sax.parseString(body, h) + return lifecycle_config + else: + raise self.connection.provider.storage_response_error( + response.status, response.reason, body) + + def configure_lifecycle(self, lifecycle_config, headers=None): + """ + Configure lifecycle for this bucket. + + :type lifecycle_config: :class:`boto.gs.lifecycle.LifecycleConfig` + :param lifecycle_config: The lifecycle configuration you want + to configure for this bucket. + """ + xml = lifecycle_config.to_xml() + response = self.connection.make_request( + 'PUT', get_utf8_value(self.name), data=get_utf8_value(xml), + query_args=LIFECYCLE_ARG, headers=headers) + body = response.read() + if response.status == 200: + return True + else: + raise self.connection.provider.storage_response_error( + response.status, response.reason, body) diff --git a/boto/gs/lifecycle.py b/boto/gs/lifecycle.py new file mode 100755 index 00000000..65f7d65d --- /dev/null +++ b/boto/gs/lifecycle.py @@ -0,0 +1,227 @@ +# Copyright 2013 Google Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from boto.exception import InvalidLifecycleConfigError + +# Relevant tags for the lifecycle configuration XML document. +LIFECYCLE_CONFIG = 'LifecycleConfiguration' +RULE = 'Rule' +ACTION = 'Action' +DELETE = 'Delete' +CONDITION = 'Condition' +AGE = 'Age' +CREATED_BEFORE = 'CreatedBefore' +NUM_NEWER_VERSIONS = 'NumberOfNewerVersions' +IS_LIVE = 'IsLive' + +# List of all action elements. +LEGAL_ACTIONS = [DELETE] +# List of all action parameter elements. +LEGAL_ACTION_PARAMS = [] +# List of all condition elements. +LEGAL_CONDITIONS = [AGE, CREATED_BEFORE, NUM_NEWER_VERSIONS, IS_LIVE] +# Dictionary mapping actions to supported action parameters for each action. +LEGAL_ACTION_ACTION_PARAMS = { + DELETE: [], +} + +class Rule(object): + """ + A lifecycle rule for a bucket. + + :ivar action: Action to be taken. + + :ivar action_params: A dictionary of action specific parameters. Each item + in the dictionary represents the name and value of an action parameter. + + :ivar conditions: A dictionary of conditions that specify when the action + should be taken. Each item in the dictionary represents the name and value + of a condition. + """ + + def __init__(self, action=None, action_params=None, conditions=None): + self.action = action + self.action_params = action_params or {} + self.conditions = conditions or {} + + # Name of the current enclosing tag (used to validate the schema). + self.current_tag = RULE + + def validateStartTag(self, tag, parent): + """Verify parent of the start tag.""" + if self.current_tag != parent: + raise InvalidLifecycleConfigError( + 'Invalid tag %s found inside %s tag' % (tag, self.current_tag)) + + def validateEndTag(self, tag): + """Verify end tag against the start tag.""" + if tag != self.current_tag: + raise InvalidLifecycleConfigError( + 'Mismatched start and end tags (%s/%s)' % + (self.current_tag, tag)) + + def startElement(self, name, attrs, connection): + if name == ACTION: + self.validateStartTag(name, RULE) + elif name in LEGAL_ACTIONS: + self.validateStartTag(name, ACTION) + # Verify there is only one action tag in the rule. + if self.action is not None: + raise InvalidLifecycleConfigError( + 'Only one action tag is allowed in each rule') + self.action = name + elif name in LEGAL_ACTION_PARAMS: + # Make sure this tag is found in an action tag. + if self.current_tag not in LEGAL_ACTIONS: + raise InvalidLifecycleConfigError( + 'Tag %s found outside of action' % name) + # Make sure this tag is allowed for the current action tag. + if name not in LEGAL_ACTION_ACTION_PARAMS[self.action]: + raise InvalidLifecycleConfigError( + 'Tag %s not allowed in action %s' % (name, self.action)) + elif name == CONDITION: + self.validateStartTag(name, RULE) + elif name in LEGAL_CONDITIONS: + self.validateStartTag(name, CONDITION) + # Verify there is no duplicate conditions. + if name in self.conditions: + raise InvalidLifecycleConfigError( + 'Found duplicate conditions %s' % name) + else: + raise InvalidLifecycleConfigError('Unsupported tag ' + name) + self.current_tag = name + + def endElement(self, name, value, connection): + self.validateEndTag(name) + if name == RULE: + # We have to validate the rule after it is fully populated because + # the action and condition elements could be in any order. + self.validate() + elif name == ACTION: + self.current_tag = RULE + elif name in LEGAL_ACTIONS: + self.current_tag = ACTION + elif name in LEGAL_ACTION_PARAMS: + self.current_tag = self.action + # Add the action parameter name and value to the dictionary. + self.action_params[name] = value.strip() + elif name == CONDITION: + self.current_tag = RULE + elif name in LEGAL_CONDITIONS: + self.current_tag = CONDITION + # Add the condition name and value to the dictionary. + self.conditions[name] = value.strip() + else: + raise InvalidLifecycleConfigError('Unsupported end tag ' + name) + + def validate(self): + """Validate the rule.""" + if not self.action: + raise InvalidLifecycleConfigError( + 'No action was specified in the rule') + if not self.conditions: + raise InvalidLifecycleConfigError( + 'No condition was specified for action %s' % self.action) + + def to_xml(self): + """Convert the rule into XML string representation.""" + s = '<' + RULE + '>' + s += '<' + ACTION + '>' + if self.action_params: + s += '<' + self.action + '>' + for param in LEGAL_ACTION_PARAMS: + if param in self.action_params: + s += ('<' + param + '>' + self.action_params[param] + '</' + + param + '>') + s += '</' + self.action + '>' + else: + s += '<' + self.action + '/>' + s += '</' + ACTION + '>' + s += '<' + CONDITION + '>' + for condition in LEGAL_CONDITIONS: + if condition in self.conditions: + s += ('<' + condition + '>' + self.conditions[condition] + '</' + + condition + '>') + s += '</' + CONDITION + '>' + s += '</' + RULE + '>' + return s + +class LifecycleConfig(list): + """ + A container of rules associated with a lifecycle configuration. + """ + + def __init__(self): + # Track if root tag has been seen. + self.has_root_tag = False + + def startElement(self, name, attrs, connection): + if name == LIFECYCLE_CONFIG: + if self.has_root_tag: + raise InvalidLifecycleConfigError( + 'Only one root tag is allowed in the XML') + self.has_root_tag = True + elif name == RULE: + if not self.has_root_tag: + raise InvalidLifecycleConfigError('Invalid root tag ' + name) + rule = Rule() + self.append(rule) + return rule + else: + raise InvalidLifecycleConfigError('Unsupported tag ' + name) + + def endElement(self, name, value, connection): + if name == LIFECYCLE_CONFIG: + pass + else: + raise InvalidLifecycleConfigError('Unsupported end tag ' + name) + + def to_xml(self): + """Convert LifecycleConfig object into XML string representation.""" + s = '<?xml version="1.0" encoding="UTF-8"?>' + s += '<' + LIFECYCLE_CONFIG + '>' + for rule in self: + s += rule.to_xml() + s += '</' + LIFECYCLE_CONFIG + '>' + return s + + def add_rule(self, action, action_params, conditions): + """ + Add a rule to this Lifecycle configuration. This only adds the rule to + the local copy. To install the new rule(s) on the bucket, you need to + pass this Lifecycle config object to the configure_lifecycle method of + the Bucket object. + + :type action: str + :param action: Action to be taken. + + :type action_params: dict + :param action_params: A dictionary of action specific parameters. Each + item in the dictionary represents the name and value of an action + parameter. + + :type conditions: dict + :param conditions: A dictionary of conditions that specify when the + action should be taken. Each item in the dictionary represents the name + and value of a condition. + """ + rule = Rule(action, action_params, conditions) + self.append(rule) diff --git a/boto/rds/__init__.py b/boto/rds/__init__.py index 5a4305f2..ec3305cf 100644 --- a/boto/rds/__init__.py +++ b/boto/rds/__init__.py @@ -24,6 +24,7 @@ import urllib from boto.connection import AWSQueryConnection from boto.rds.dbinstance import DBInstance from boto.rds.dbsecuritygroup import DBSecurityGroup +from boto.rds.optiongroup import OptionGroup, OptionGroupOption from boto.rds.parametergroup import ParameterGroup from boto.rds.dbsnapshot import DBSnapshot from boto.rds.event import Event @@ -82,7 +83,7 @@ class RDSConnection(AWSQueryConnection): DefaultRegionName = 'us-east-1' DefaultRegionEndpoint = 'rds.amazonaws.com' - APIVersion = '2012-09-17' + APIVersion = '2013-05-15' def __init__(self, aws_access_key_id=None, aws_secret_access_key=None, is_secure=True, port=None, proxy=None, proxy_port=None, @@ -1197,3 +1198,137 @@ class RDSConnection(AWSQueryConnection): if marker: params['Marker'] = marker return self.get_list('DescribeEvents', params, [('Event', Event)]) + + def create_option_group(self, name, engine_name, major_engine_version, + description=None): + """ + Create a new option group for your account. + This will create the option group within the region you + are currently connected to. + + :type name: string + :param name: The name of the new option group + + :type engine_name: string + :param engine_name: Specifies the name of the engine that this option + group should be associated with. + + :type major_engine_version: string + :param major_engine_version: Specifies the major version of the engine + that this option group should be + associated with. + + :type description: string + :param description: The description of the new option group + + :rtype: :class:`boto.rds.optiongroup.OptionGroup` + :return: The newly created OptionGroup + """ + params = { + 'OptionGroupName': name, + 'EngineName': engine_name, + 'MajorEngineVersion': major_engine_version, + 'OptionGroupDescription': description, + } + group = self.get_object('CreateOptionGroup', params, OptionGroup) + group.name = name + group.engine_name = engine_name + group.major_engine_version = major_engine_version + group.description = description + return group + + def delete_option_group(self, name): + """ + Delete an OptionGroup from your account. + + :type key_name: string + :param key_name: The name of the OptionGroup to delete + """ + params = {'OptionGroupName': name} + return self.get_status('DeleteOptionGroup', params) + + def describe_option_groups(self, name=None, engine_name=None, + major_engine_version=None, max_records=100, + marker=None): + """ + Describes the available option groups. + + :type name: str + :param name: The name of the option group to describe. Cannot be + supplied together with engine_name or major_engine_version. + + :type engine_name: str + :param engine_name: Filters the list of option groups to only include + groups associated with a specific database engine. + + :type major_engine_version: datetime + :param major_engine_version: Filters the list of option groups to only + include groups associated with a specific + database engine version. If specified, then + engine_name must also be specified. + + :type max_records: int + :param max_records: The maximum number of records to be returned. + If more results are available, a MoreToken will + be returned in the response that can be used to + retrieve additional records. Default is 100. + + :type marker: str + :param marker: The marker provided by a previous request. + + :rtype: list + :return: A list of class:`boto.rds.optiongroup.OptionGroup` + """ + params = {} + if name: + params['OptionGroupName'] = name + elif engine_name and major_engine_version: + params['EngineName'] = engine_name + params['MajorEngineVersion'] = major_engine_version + if max_records: + params['MaxRecords'] = int(max_records) + if marker: + params['Marker'] = marker + return self.get_list('DescribeOptionGroups', params, [ + ('OptionGroup', OptionGroup) + ]) + + def describe_option_group_options(self, engine_name=None, + major_engine_version=None, max_records=100, + marker=None): + """ + Describes the available option group options. + + :type engine_name: str + :param engine_name: Filters the list of option groups to only include + groups associated with a specific database engine. + + :type major_engine_version: datetime + :param major_engine_version: Filters the list of option groups to only + include groups associated with a specific + database engine version. If specified, then + engine_name must also be specified. + + :type max_records: int + :param max_records: The maximum number of records to be returned. + If more results are available, a MoreToken will + be returned in the response that can be used to + retrieve additional records. Default is 100. + + :type marker: str + :param marker: The marker provided by a previous request. + + :rtype: list + :return: A list of class:`boto.rds.optiongroup.Option` + """ + params = {} + if engine_name and major_engine_version: + params['EngineName'] = engine_name + params['MajorEngineVersion'] = major_engine_version + if max_records: + params['MaxRecords'] = int(max_records) + if marker: + params['Marker'] = marker + return self.get_list('DescribeOptionGroupOptions', params, [ + ('OptionGroupOptions', OptionGroupOption) + ])
\ No newline at end of file diff --git a/boto/rds/dbinstance.py b/boto/rds/dbinstance.py index 7002791c..e6b51b76 100644 --- a/boto/rds/dbinstance.py +++ b/boto/rds/dbinstance.py @@ -21,6 +21,7 @@ from boto.rds.dbsecuritygroup import DBSecurityGroup from boto.rds.parametergroup import ParameterGroup +from boto.rds.statusinfo import StatusInfo from boto.resultset import ResultSet @@ -69,6 +70,8 @@ class DBInstance(object): are pending. Specific changes are identified by subelements. :ivar read_replica_dbinstance_identifiers: List of read replicas associated with this DB instance. + :ivar status_infos: The status of a Read Replica. If the instance is not a + for a read replica, this will be blank. """ def __init__(self, connection=None, id=None): @@ -95,6 +98,7 @@ class DBInstance(object): self._in_endpoint = False self._port = None self._address = None + self.status_infos = None def __repr__(self): return 'DBInstance:%s' % self.id @@ -117,6 +121,11 @@ class DBInstance(object): self.read_replica_dbinstance_identifiers = \ ReadReplicaDBInstanceIdentifiers() return self.read_replica_dbinstance_identifiers + elif name == 'StatusInfos': + self.status_infos = ResultSet([ + ('DBInstanceStatusInfo', StatusInfo) + ]) + return self.status_infos return None def endElement(self, name, value, connection): diff --git a/boto/rds/optiongroup.py b/boto/rds/optiongroup.py new file mode 100644 index 00000000..8968b6ca --- /dev/null +++ b/boto/rds/optiongroup.py @@ -0,0 +1,404 @@ +# Copyright (c) 2013 Amazon.com, Inc. or its affiliates. +# All Rights Reserved +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +""" +Represents an OptionGroup +""" + +from boto.rds.dbsecuritygroup import DBSecurityGroup +from boto.resultset import ResultSet + + +class OptionGroup(object): + """ + Represents an RDS option group + + Properties reference available from the AWS documentation at + http://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_OptionGroup.html + + :ivar connection: :py:class:`boto.rds.RDSConnection` associated with the + current object + :ivar name: Name of the option group + :ivar description: The description of the option group + :ivar engine_name: The name of the database engine to use + :ivar major_engine_version: The major version number of the engine to use + :ivar allow_both_vpc_and_nonvpc: Indicates whether this option group can be + applied to both VPC and non-VPC instances. + The value ``True`` indicates the option + group can be applied to both VPC and + non-VPC instances. + :ivar vpc_id: If AllowsVpcAndNonVpcInstanceMemberships is 'false', this + field is blank. If AllowsVpcAndNonVpcInstanceMemberships is + ``True`` and this field is blank, then this option group can + be applied to both VPC and non-VPC instances. If this field + contains a value, then this option group can only be applied + to instances that are in the VPC indicated by this field. + :ivar options: The list of :py:class:`boto.rds.optiongroup.Option` objects + associated with the group + """ + def __init__(self, connection=None, name=None, engine_name=None, + major_engine_version=None, description=None, + allow_both_vpc_and_nonvpc=False, vpc_id=None): + self.name = name + self.engine_name = engine_name + self.major_engine_version = major_engine_version + self.description = description + self.allow_both_vpc_and_nonvpc = allow_both_vpc_and_nonvpc + self.vpc_id = vpc_id + self.options = [] + + def __repr__(self): + return 'OptionGroup:%s' % self.name + + def startElement(self, name, attrs, connection): + if name == 'Options': + self.options = ResultSet([ + ('Options', Option) + ]) + else: + return None + + def endElement(self, name, value, connection): + if name == 'OptionGroupName': + self.name = value + elif name == 'EngineName': + self.engine_name = value + elif name == 'MajorEngineVersion': + self.major_engine_version = value + elif name == 'OptionGroupDescription': + self.description = value + elif name == 'AllowsVpcAndNonVpcInstanceMemberships': + if value.lower() == 'true': + self.allow_both_vpc_and_nonvpc = True + else: + self.allow_both_vpc_and_nonvpc = False + elif name == 'VpcId': + self.vpc_id = value + else: + setattr(self, name, value) + + def delete(self): + return self.connection.delete_option_group(self.name) + + +class Option(object): + """ + Describes a Option for use in an OptionGroup + + :ivar name: The name of the option + :ivar description: The description of the option. + :ivar permanent: Indicate if this option is permanent. + :ivar persistent: Indicate if this option is persistent. + :ivar port: If required, the port configured for this option to use. + :ivar settings: The option settings for this option. + :ivar db_security_groups: If the option requires access to a port, then + this DB Security Group allows access to the port. + :ivar vpc_security_groups: If the option requires access to a port, then + this VPC Security Group allows access to the + port. + """ + def __init__(self, name=None, description=None, permanent=False, + persistent=False, port=None, settings=None, + db_security_groups=None, vpc_security_groups=None): + self.name = name + self.description = description + self.permanent = permanent + self.persistent = persistent + self.port = port + self.settings = settings + self.db_security_groups = db_security_groups + self.vpc_security_groups = vpc_security_groups + + if self.settings is None: + self.settings = [] + + if self.db_security_groups is None: + self.db_security_groups = [] + + if self.vpc_security_groups is None: + self.vpc_security_groups = [] + + def __repr__(self): + return 'Option:%s' % self.name + + def startElement(self, name, attrs, connection): + if name == 'OptionSettings': + self.settings = ResultSet([ + ('OptionSettings', OptionSetting) + ]) + elif name == 'DBSecurityGroupMemberships': + self.db_security_groups = ResultSet([ + ('DBSecurityGroupMemberships', DBSecurityGroup) + ]) + elif name == 'VpcSecurityGroupMemberships': + self.vpc_security_groups = ResultSet([ + ('VpcSecurityGroupMemberships', VpcSecurityGroup) + ]) + else: + return None + + def endElement(self, name, value, connection): + if name == 'OptionName': + self.name = value + elif name == 'OptionDescription': + self.description = value + elif name == 'Permanent': + if value.lower() == 'true': + self.permenant = True + else: + self.permenant = False + elif name == 'Persistent': + if value.lower() == 'true': + self.persistent = True + else: + self.persistent = False + elif name == 'Port': + self.port = int(value) + else: + setattr(self, name, value) + + +class OptionSetting(object): + """ + Describes a OptionSetting for use in an Option + + :ivar name: The name of the option that has settings that you can set. + :ivar description: The description of the option setting. + :ivar value: The current value of the option setting. + :ivar default_value: The default value of the option setting. + :ivar allowed_values: The allowed values of the option setting. + :ivar data_type: The data type of the option setting. + :ivar apply_type: The DB engine specific parameter type. + :ivar is_modifiable: A Boolean value that, when true, indicates the option + setting can be modified from the default. + :ivar is_collection: Indicates if the option setting is part of a + collection. + """ + + def __init__(self, name=None, description=None, value=None, + default_value=False, allowed_values=None, data_type=None, + apply_type=None, is_modifiable=False, is_collection=False): + self.name = name + self.description = description + self.value = value + self.default_value = default_value + self.allowed_values = allowed_values + self.data_type = data_type + self.apply_type = apply_type + self.is_modifiable = is_modifiable + self.is_collection = is_collection + + def __repr__(self): + return 'OptionSetting:%s' % self.name + + def startElement(self, name, attrs, connection): + return None + + def endElement(self, name, value, connection): + if name == 'Name': + self.name = value + elif name == 'Description': + self.description = value + elif name == 'Value': + self.value = value + elif name == 'DefaultValue': + self.default_value = value + elif name == 'AllowedValues': + self.allowed_values = value + elif name == 'DataType': + self.data_type = value + elif name == 'ApplyType': + self.apply_type = value + elif name == 'IsModifiable': + if value.lower() == 'true': + self.is_modifiable = True + else: + self.is_modifiable = False + elif name == 'IsCollection': + if value.lower() == 'true': + self.is_collection = True + else: + self.is_collection = False + else: + setattr(self, name, value) + + +class VpcSecurityGroup(object): + """ + Describes a VPC security group for use in a OptionGroup + """ + def __init__(self, vpc_id=None, status=None): + self.vpc_id = vpc_id + self.status = status + + def __repr__(self): + return 'VpcSecurityGroup:%s' % self.vpc_id + + def startElement(self, name, attrs, connection): + pass + + def endElement(self, name, value, connection): + if name == 'VpcSecurityGroupId': + self.vpc_id = value + elif name == 'Status': + self.status = value + else: + setattr(self, name, value) + + +class OptionGroupOption(object): + """ + Describes a OptionGroupOption for use in an OptionGroup + + :ivar name: The name of the option + :ivar description: The description of the option. + :ivar engine_name: Engine name that this option can be applied to. + :ivar major_engine_version: Indicates the major engine version that the + option is available for. + :ivar min_minor_engine_version: The minimum required engine version for the + option to be applied. + :ivar permanent: Indicate if this option is permanent. + :ivar persistent: Indicate if this option is persistent. + :ivar port_required: Specifies whether the option requires a port. + :ivar default_port: If the option requires a port, specifies the default + port for the option. + :ivar settings: The option settings for this option. + :ivar depends_on: List of all options that are prerequisites for this + option. + """ + def __init__(self, name=None, description=None, engine_name=None, + major_engine_version=None, min_minor_engine_version=None, + permanent=False, persistent=False, port_required=False, + default_port=None, settings=None, depends_on=None): + self.name = name + self.description = description + self.engine_name = engine_name + self.major_engine_version = major_engine_version + self.min_minor_engine_version = min_minor_engine_version + self.permanent = permanent + self.persistent = persistent + self.port_required = port_required + self.default_port = default_port + self.settings = settings + self.depends_on = depends_on + + if self.settings is None: + self.settings = [] + + if self.depends_on is None: + self.depends_on = [] + + def __repr__(self): + return 'OptionGroupOption:%s' % self.name + + def startElement(self, name, attrs, connection): + if name == 'OptionGroupOptionSettings': + self.settings = ResultSet([ + ('OptionGroupOptionSettings', OptionGroupOptionSetting) + ]) + elif name == 'OptionsDependedOn': + self.depends_on = [] + else: + return None + + def endElement(self, name, value, connection): + if name == 'Name': + self.name = value + elif name == 'Description': + self.description = value + elif name == 'EngineName': + self.engine_name = value + elif name == 'MajorEngineVersion': + self.major_engine_version = value + elif name == 'MinimumRequiredMinorEngineVersion': + self.min_minor_engine_version = value + elif name == 'Permanent': + if value.lower() == 'true': + self.permenant = True + else: + self.permenant = False + elif name == 'Persistent': + if value.lower() == 'true': + self.persistent = True + else: + self.persistent = False + elif name == 'PortRequired': + if value.lower() == 'true': + self.port_required = True + else: + self.port_required = False + elif name == 'DefaultPort': + self.default_port = int(value) + else: + setattr(self, name, value) + + +class OptionGroupOptionSetting(object): + """ + Describes a OptionGroupOptionSetting for use in an OptionGroupOption. + + :ivar name: The name of the option that has settings that you can set. + :ivar description: The description of the option setting. + :ivar value: The current value of the option setting. + :ivar default_value: The default value of the option setting. + :ivar allowed_values: The allowed values of the option setting. + :ivar data_type: The data type of the option setting. + :ivar apply_type: The DB engine specific parameter type. + :ivar is_modifiable: A Boolean value that, when true, indicates the option + setting can be modified from the default. + :ivar is_collection: Indicates if the option setting is part of a + collection. + """ + + def __init__(self, name=None, description=None, default_value=False, + allowed_values=None, apply_type=None, is_modifiable=False): + self.name = name + self.description = description + self.default_value = default_value + self.allowed_values = allowed_values + self.apply_type = apply_type + self.is_modifiable = is_modifiable + + def __repr__(self): + return 'OptionGroupOptionSetting:%s' % self.name + + def startElement(self, name, attrs, connection): + return None + + def endElement(self, name, value, connection): + if name == 'SettingName': + self.name = value + elif name == 'SettingDescription': + self.description = value + elif name == 'DefaultValue': + self.default_value = value + elif name == 'AllowedValues': + self.allowed_values = value + elif name == 'ApplyType': + self.apply_type = value + elif name == 'IsModifiable': + if value.lower() == 'true': + self.is_modifiable = True + else: + self.is_modifiable = False + else: + setattr(self, name, value) diff --git a/boto/rds/statusinfo.py b/boto/rds/statusinfo.py new file mode 100644 index 00000000..d4ff9b08 --- /dev/null +++ b/boto/rds/statusinfo.py @@ -0,0 +1,54 @@ +# Copyright (c) 2013 Amazon.com, Inc. or its affiliates. +# All Rights Reserved +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# + +class StatusInfo(object): + """ + Describes a status message. + """ + + def __init__(self, status_type=None, normal=None, status=None, message=None): + self.status_type = status_type + self.normal = normal + self.status = status + self.message = message + + def __repr__(self): + return 'StatusInfo:%s' % self.message + + def startElement(self, name, attrs, connection): + pass + + def endElement(self, name, value, connection): + if name == 'StatusType': + self.status_type = value + elif name == 'Normal': + if value.lower() == 'true': + self.normal = True + else: + self.normal = False + elif name == 'Status': + self.status = value + elif name == 'Message': + self.message = value + else: + setattr(self, name, value) diff --git a/boto/route53/record.py b/boto/route53/record.py index 643af2a1..3fe75abb 100644 --- a/boto/route53/record.py +++ b/boto/route53/record.py @@ -244,20 +244,24 @@ class Record(object): else: # Use resource record(s) records = "" + for r in self.resource_records: records += self.ResourceRecordBody % r + body = self.ResourceRecordsBody % { "ttl": self.ttl, "records": records, } + weight = "" + if self.identifier != None and self.weight != None: weight = self.WRRBody % {"identifier": self.identifier, "weight": self.weight} elif self.identifier != None and self.region != None: weight = self.RRRBody % {"identifier": self.identifier, "region": self.region} - + params = { "name": self.name, "type": self.type, diff --git a/boto/sqs/message.py b/boto/sqs/message.py index c7319903..43efee38 100644 --- a/boto/sqs/message.py +++ b/boto/sqs/message.py @@ -144,7 +144,7 @@ class Message(RawMessage): encodes/decodes the message body using Base64 encoding to avoid any illegal characters in the message body. See: - http://developer.amazonwebservices.com/connect/thread.jspa?messageID=49680%EC%88%90 + https://forums.aws.amazon.com/thread.jspa?threadID=13067 for details on why this is a good idea. The encode/decode is meant to be transparent to the end-user. diff --git a/boto/storage_uri.py b/boto/storage_uri.py index f046fb15..40fee473 100755 --- a/boto/storage_uri.py +++ b/boto/storage_uri.py @@ -770,6 +770,21 @@ class BucketStorageUri(StorageUri): self._build_uri_strings() return self + def get_lifecycle_config(self, validate=False, headers=None): + """Returns a bucket's lifecycle configuration.""" + self._check_bucket_uri('get_lifecycle_config') + bucket = self.get_bucket(validate, headers) + lifecycle_config = bucket.get_lifecycle_config(headers) + self.check_response(lifecycle_config, 'lifecycle', self.uri) + return lifecycle_config + + def configure_lifecycle(self, lifecycle_config, validate=False, + headers=None): + """Sets or updates a bucket's lifecycle configuration.""" + self._check_bucket_uri('configure_lifecycle') + bucket = self.get_bucket(validate, headers) + bucket.configure_lifecycle(lifecycle_config, headers) + def exists(self, headers=None): """Returns True if the object exists or False if it doesn't""" if not self.object_name: diff --git a/boto/sts/connection.py b/boto/sts/connection.py index 5a1fdf2c..bdf21859 100644 --- a/boto/sts/connection.py +++ b/boto/sts/connection.py @@ -1,5 +1,6 @@ # Copyright (c) 2011 Mitch Garnaat http://garnaat.org/ # Copyright (c) 2011, Eucalyptus Systems, Inc. +# Copyright (c) 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the @@ -23,6 +24,7 @@ from boto.connection import AWSQueryConnection from boto.regioninfo import RegionInfo from credentials import Credentials, FederationToken, AssumedRole +from credentials import DecodeAuthorizationMessage import boto import boto.utils import datetime @@ -437,3 +439,56 @@ class STSConnection(AWSQueryConnection): AssumedRole, verb='POST' ) + + def decode_authorization_message(self, encoded_message): + """ + Decodes additional information about the authorization status + of a request from an encoded message returned in response to + an AWS request. + + For example, if a user is not authorized to perform an action + that he or she has requested, the request returns a + `Client.UnauthorizedOperation` response (an HTTP 403 + response). Some AWS actions additionally return an encoded + message that can provide details about this authorization + failure. + Only certain AWS actions return an encoded authorization + message. The documentation for an individual action indicates + whether that action returns an encoded message in addition to + returning an HTTP code. + The message is encoded because the details of the + authorization status can constitute privileged information + that the user who requested the action should not see. To + decode an authorization status message, a user must be granted + permissions via an IAM policy to request the + `DecodeAuthorizationMessage` ( + `sts:DecodeAuthorizationMessage`) action. + + The decoded message includes the following type of + information: + + + + Whether the request was denied due to an explicit deny or + due to the absence of an explicit allow. For more information, + see `Determining Whether a Request is Allowed or Denied`_ in + Using IAM . + + The principal who made the request. + + The requested action. + + The requested resource. + + The values of condition keys in the context of the user's + request. + + :type encoded_message: string + :param encoded_message: The encoded message that was returned with the + response. + + """ + params = { + 'EncodedMessage': encoded_message, + } + return self.get_object( + 'DecodeAuthorizationMessage', + params, + DecodeAuthorizationMessage, + verb='POST' + ) diff --git a/boto/sts/credentials.py b/boto/sts/credentials.py index 33fe4ee7..a28d1067 100644 --- a/boto/sts/credentials.py +++ b/boto/sts/credentials.py @@ -213,3 +213,22 @@ class User(object): self.arn = value elif name == 'AssumedRoleId': self.assume_role_id = value + + +class DecodeAuthorizationMessage(object): + """ + :ivar request_id: The request ID. + :ivar decoded_message: The decoded authorization message (may be JSON). + """ + def __init__(self, request_id=None, decoded_message=None): + self.request_id = request_id + self.decoded_message = decoded_message + + def startElement(self, name, attrs, connection): + pass + + def endElement(self, name, value, connection): + if name == 'requestId': + self.request_id = value + elif name == 'DecodedMessage': + self.decoded_message = value diff --git a/docs/source/cloudfront_tut.rst b/docs/source/cloudfront_tut.rst index 7f41ff15..cd33056e 100644 --- a/docs/source/cloudfront_tut.rst +++ b/docs/source/cloudfront_tut.rst @@ -31,7 +31,8 @@ Working with CloudFront Distributions ------------------------------------- Create a new :class:`boto.cloudfront.distribution.Distribution`:: - >>> distro = c.create_distribution(origin='mybucket.s3.amazonaws.com', enabled=False, comment='My new Distribution') + >>> origin = boto.cloudfront.origin.S3Origin('mybucket.s3.amazonaws.com') + >>> distro = c.create_distribution(origin=origin, enabled=False, comment='My new Distribution') >>> d.domain_name u'd2oxf3980lnb8l.cloudfront.net' >>> d.id diff --git a/docs/source/index.rst b/docs/source/index.rst index dedc0d69..f720cf1b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -111,6 +111,7 @@ Release Notes .. toctree:: :titlesonly: + releasenotes/v2.9.8 releasenotes/v2.9.7 releasenotes/v2.9.6 releasenotes/v2.9.5 diff --git a/docs/source/releasenotes/v2.9.8.rst b/docs/source/releasenotes/v2.9.8.rst new file mode 100644 index 00000000..36432813 --- /dev/null +++ b/docs/source/releasenotes/v2.9.8.rst @@ -0,0 +1,35 @@ +boto v2.9.8 +=========== + +:date: 2013/07/18 + +This release is adds new methods in AWS Security Token Service (STS), AWS +CloudFormation, updates AWS Relational Database Service (RDS) & Google Storage. +It also has several bugfixes & documentation improvements. + + +Features +-------- + +* Added support for the ``DecodeAuthorizationMessage`` in STS (:sha:`1ada5ac`). +* Added support for creating/deleting/describing ``OptionGroup``s in RDS. + (:sha:`d629228` & :sha:`d059a3b`) +* Added ``CancelUpdateStack`` to CloudFormation. (:issue:`1476`, :sha:`5bae130`) +* Added support for getting/setting lifecycle configurations on GS buckets. + (:issue:`1604`, :sha:`652fc81`) + + +Bugfixes +-------- + +* Added region support to ``bin/elbadmin``. (:issue:`1586`, + :sha:`2ffbc60`) +* Changed the mock storage to use case-insensitive headers. (:issue:`1594`, + :sha:`71849cb`) +* Added ``complex_listeners`` to ELB. (:issue:`1048`, :sha:`b782ce2`) +* Added tests for Route53's ``ResourceRecordSets``. (:sha:`fad5bde`) +* Several documentation improvements/fixes: + + * Updated CloudFront docs. (:issue:`1546`, :sha:`a811197`) + * Updated the URL explaining the use of base64 in SQS messages. + (:issue:`1596`, :sha:`00de3a2`) diff --git a/tests/integration/ec2/elb/test_connection.py b/tests/integration/ec2/elb/test_connection.py index 618d0ce9..1661899b 100644 --- a/tests/integration/ec2/elb/test_connection.py +++ b/tests/integration/ec2/elb/test_connection.py @@ -30,14 +30,14 @@ from boto.ec2.elb import ELBConnection class ELBConnectionTest(unittest.TestCase): ec2 = True - def setup(self): + def setUp(self): """Creates a named load balancer that can be safely deleted at the end of each test""" self.conn = ELBConnection() self.name = 'elb-boto-unit-test' self.availability_zones = ['us-east-1a'] self.listeners = [(80, 8000, 'HTTP')] - self.balancer = self.conn.create_load_balancer(name, availability_zones, listeners) + self.balancer = self.conn.create_load_balancer(self.name, self.availability_zones, self.listeners) def tearDown(self): """ Deletes the test load balancer after every test. @@ -80,7 +80,7 @@ class ELBConnectionTest(unittest.TestCase): def test_delete_load_balancer_listeners(self): mod_listeners = [(80, 8000, 'HTTP'), (443, 8001, 'HTTP')] - mod_name = self.name + "_mod" + mod_name = self.name + "-mod" self.mod_balancer = self.conn.create_load_balancer(mod_name,\ self.availability_zones, mod_listeners) @@ -118,5 +118,26 @@ class ELBConnectionTest(unittest.TestCase): # Policy names should be checked here once they are supported # in the Listener object. + def test_create_load_balancer_complex_listeners(self): + complex_listeners = [ + (8080, 80, 'HTTP', 'HTTP'), + (2525, 25, 'TCP', 'TCP'), + ] + + self.conn.create_load_balancer_listeners( + self.name, + complex_listeners=complex_listeners + ) + + balancers = self.conn.get_all_load_balancers( + load_balancer_names=[self.name] + ) + self.assertEqual([lb.name for lb in balancers], [self.name]) + self.assertEqual( + sorted(l.get_complex_tuple() for l in balancers[0].listeners), + # We need an extra 'HTTP' here over what ``self.listeners`` uses. + sorted([(80, 8000, 'HTTP', 'HTTP')] + complex_listeners) + ) + if __name__ == '__main__': unittest.main() diff --git a/tests/integration/gs/test_basic.py b/tests/integration/gs/test_basic.py index 9ac60b91..ffc890ff 100644 --- a/tests/integration/gs/test_basic.py +++ b/tests/integration/gs/test_basic.py @@ -37,6 +37,7 @@ from boto import handler from boto import storage_uri from boto.gs.acl import ACL from boto.gs.cors import Cors +from boto.gs.lifecycle import LifecycleConfig from tests.integration.gs.testcase import GSTestCase @@ -49,6 +50,21 @@ CORS_DOC = ('<CorsConfig><Cors><Origins><Origin>origin1.example.com' '<ResponseHeader>bar</ResponseHeader></ResponseHeaders>' '</Cors></CorsConfig>') +LIFECYCLE_EMPTY = ('<?xml version="1.0" encoding="UTF-8"?>' + '<LifecycleConfiguration></LifecycleConfiguration>') +LIFECYCLE_DOC = ('<?xml version="1.0" encoding="UTF-8"?>' + '<LifecycleConfiguration><Rule>' + '<Action><Delete/></Action>' + '<Condition><Age>365</Age>' + '<CreatedBefore>2013-01-15</CreatedBefore>' + '<NumberOfNewerVersions>3</NumberOfNewerVersions>' + '<IsLive>true</IsLive></Condition>' + '</Rule></LifecycleConfiguration>') +LIFECYCLE_CONDITIONS = {'Age': '365', + 'CreatedBefore': '2013-01-15', + 'NumberOfNewerVersions': '3', + 'IsLive': 'true'} + # Regexp for matching project-private default object ACL. PROJECT_PRIVATE_RE = ('\s*<AccessControlList>\s*<Entries>\s*<Entry>' '\s*<Scope type="GroupById"><ID>[0-9a-fA-F]+</ID></Scope>' @@ -377,3 +393,36 @@ class GSBasicTest(GSTestCase): uri.set_cors(cors_obj) cors = re.sub(r'\s', '', uri.get_cors().to_xml()) self.assertEqual(cors, CORS_DOC) + + def test_lifecycle_config_bucket(self): + """Test setting and getting of lifecycle config on Bucket.""" + # create a new bucket + bucket = self._MakeBucket() + bucket_name = bucket.name + # now call get_bucket to see if it's really there + bucket = self._GetConnection().get_bucket(bucket_name) + # get lifecycle config and make sure it's empty + xml = bucket.get_lifecycle_config().to_xml() + self.assertEqual(xml, LIFECYCLE_EMPTY) + # set lifecycle config + lifecycle_config = LifecycleConfig() + lifecycle_config.add_rule('Delete', None, LIFECYCLE_CONDITIONS) + bucket.configure_lifecycle(lifecycle_config) + xml = bucket.get_lifecycle_config().to_xml() + self.assertEqual(xml, LIFECYCLE_DOC) + + def test_lifecycle_config_storage_uri(self): + """Test setting and getting of lifecycle config with storage_uri.""" + # create a new bucket + bucket = self._MakeBucket() + bucket_name = bucket.name + uri = storage_uri('gs://' + bucket_name) + # get lifecycle config and make sure it's empty + xml = uri.get_lifecycle_config().to_xml() + self.assertEqual(xml, LIFECYCLE_EMPTY) + # set lifecycle config + lifecycle_config = LifecycleConfig() + lifecycle_config.add_rule('Delete', None, LIFECYCLE_CONDITIONS) + uri.configure_lifecycle(lifecycle_config) + xml = uri.get_lifecycle_config().to_xml() + self.assertEqual(xml, LIFECYCLE_DOC) diff --git a/tests/integration/route53/test_resourcerecordsets.py b/tests/integration/route53/test_resourcerecordsets.py new file mode 100644 index 00000000..c6baadfc --- /dev/null +++ b/tests/integration/route53/test_resourcerecordsets.py @@ -0,0 +1,53 @@ +# Copyright (c) 2013 Amazon.com, Inc. or its affiliates. All Rights Reserved +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, dis- +# tribute, sublicense, and/or sell copies of the Software, and to permit +# persons to whom the Software is furnished to do so, subject to the fol- +# lowing conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- +# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. +# + +import unittest +from boto.route53.connection import Route53Connection +from boto.route53.record import ResourceRecordSets +from boto.exception import TooManyRecordsException + + +class TestRoute53ResourceRecordSets(unittest.TestCase): + def setUp(self): + super(TestRoute53ResourceRecordSets, self).setUp() + self.conn = Route53Connection() + self.zone = self.conn.create_zone('example.com') + + def tearDown(self): + self.zone.delete() + super(TestRoute53ResourceRecordSets, self).tearDown() + + def test_add_change(self): + rrs = ResourceRecordSets(self.conn, self.zone.id) + + created = rrs.add_change("CREATE", "vpn.example.com.", "A") + created.add_value('192.168.0.25') + rrs.commit() + + rrs = ResourceRecordSets(self.conn, self.zone.id) + deleted = rrs.add_change('DELETE', "vpn.example.com.", "A") + deleted.add_value('192.168.0.25') + rrs.commit() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/integration/s3/mock_storage_service.py b/tests/integration/s3/mock_storage_service.py index 507695bf..15a72adf 100644 --- a/tests/integration/s3/mock_storage_service.py +++ b/tests/integration/s3/mock_storage_service.py @@ -32,6 +32,8 @@ import base64 import re from boto.utils import compute_md5 +from boto.utils import find_matching_headers +from boto.utils import merge_headers_by_name from boto.s3.prefix import Prefix try: @@ -99,12 +101,14 @@ class MockKey(object): def _handle_headers(self, headers): if not headers: return - if 'Content-Encoding' in headers: - self.content_encoding = headers['Content-Encoding'] - if 'Content-Type' in headers: - self.content_type = headers['Content-Type'] - if 'Content-Language' in headers: - self.content_language = headers['Content-Language'] + if find_matching_headers('Content-Encoding', headers): + self.content_encoding = merge_headers_by_name('Content-Encoding', + headers) + if find_matching_headers('Content-Type', headers): + self.content_type = merge_headers_by_name('Content-Type', headers) + if find_matching_headers('Content-Language', headers): + self.content_language = merge_headers_by_name('Content-Language', + headers) # Simplistic partial implementation for headers: Just supports range GETs # of flavor 'Range: bytes=xyz-'. diff --git a/tests/integration/sts/test_session_token.py b/tests/integration/sts/test_session_token.py index 35d42ca8..3d548b9f 100644 --- a/tests/integration/sts/test_session_token.py +++ b/tests/integration/sts/test_session_token.py @@ -79,3 +79,12 @@ class SessionTokenTest (unittest.TestCase): except BotoServerError as err: self.assertEqual(err.status, 403) self.assertTrue('Not authorized' in err.body) + + def test_decode_authorization_message(self): + c = STSConnection() + + try: + creds = c.decode_authorization_message('b94d27b9934') + except BotoServerError as err: + self.assertEqual(err.status, 400) + self.assertTrue('Invalid token' in err.body) diff --git a/tests/unit/cloudformation/test_connection.py b/tests/unit/cloudformation/test_connection.py index d7f86c70..6890f152 100644 --- a/tests/unit/cloudformation/test_connection.py +++ b/tests/unit/cloudformation/test_connection.py @@ -601,5 +601,20 @@ class TestCloudFormationValidateTemplate(CloudFormationConnectionBase): }) +class TestCloudFormationCancelUpdateStack(CloudFormationConnectionBase): + def default_body(self): + return """<CancelUpdateStackResult/>""" + + def test_cancel_update_stack(self): + self.set_http_response(status_code=200) + api_response = self.service_connection.cancel_update_stack('stack_name') + self.assertEqual(api_response, True) + self.assert_request_parameters({ + 'Action': 'CancelUpdateStack', + 'StackName': 'stack_name', + 'Version': '2010-05-15', + }) + + if __name__ == '__main__': unittest.main() diff --git a/tests/unit/ec2/elb/test_listener.py b/tests/unit/ec2/elb/test_listener.py new file mode 100644 index 00000000..ff0f693c --- /dev/null +++ b/tests/unit/ec2/elb/test_listener.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python +import xml.sax +from tests.unit import unittest + +import boto.resultset +from boto.ec2.elb.loadbalancer import LoadBalancer + + +LISTENERS_RESPONSE = r"""<?xml version="1.0" encoding="UTF-8"?> +<DescribeLoadBalancersResponse xmlns="http://elasticloadbalancing.amazonaws.com/doc/2012-06-01/"> + <DescribeLoadBalancersResult> + <LoadBalancerDescriptions> + <member> + <SecurityGroups/> + <CreatedTime>2013-07-09T19:18:00.520Z</CreatedTime> + <LoadBalancerName>elb-boto-unit-test</LoadBalancerName> + <HealthCheck> + <Interval>30</Interval> + <Target>TCP:8000</Target> + <HealthyThreshold>10</HealthyThreshold> + <Timeout>5</Timeout> + <UnhealthyThreshold>2</UnhealthyThreshold> + </HealthCheck> + <ListenerDescriptions> + <member> + <PolicyNames/> + <Listener> + <Protocol>HTTP</Protocol> + <LoadBalancerPort>80</LoadBalancerPort> + <InstanceProtocol>HTTP</InstanceProtocol> + <InstancePort>8000</InstancePort> + </Listener> + </member> + <member> + <PolicyNames/> + <Listener> + <Protocol>HTTP</Protocol> + <LoadBalancerPort>8080</LoadBalancerPort> + <InstanceProtocol>HTTP</InstanceProtocol> + <InstancePort>80</InstancePort> + </Listener> + </member> + <member> + <PolicyNames/> + <Listener> + <Protocol>TCP</Protocol> + <LoadBalancerPort>2525</LoadBalancerPort> + <InstanceProtocol>TCP</InstanceProtocol> + <InstancePort>25</InstancePort> + </Listener> + </member> + </ListenerDescriptions> + <Instances/> + <Policies> + <AppCookieStickinessPolicies/> + <OtherPolicies/> + <LBCookieStickinessPolicies/> + </Policies> + <AvailabilityZones> + <member>us-east-1a</member> + </AvailabilityZones> + <CanonicalHostedZoneName>elb-boto-unit-test-408121642.us-east-1.elb.amazonaws.com</CanonicalHostedZoneName> + <CanonicalHostedZoneNameID>Z3DZXE0Q79N41H</CanonicalHostedZoneNameID> + <Scheme>internet-facing</Scheme> + <SourceSecurityGroup> + <OwnerAlias>amazon-elb</OwnerAlias> + <GroupName>amazon-elb-sg</GroupName> + </SourceSecurityGroup> + <DNSName>elb-boto-unit-test-408121642.us-east-1.elb.amazonaws.com</DNSName> + <BackendServerDescriptions/> + <Subnets/> + </member> + </LoadBalancerDescriptions> + </DescribeLoadBalancersResult> + <ResponseMetadata> + <RequestId>5763d932-e8cc-11e2-a940-11136cceffb8</RequestId> + </ResponseMetadata> +</DescribeLoadBalancersResponse> +""" + + +class TestListenerResponseParsing(unittest.TestCase): + def test_parse_complex(self): + rs = boto.resultset.ResultSet([ + ('member', LoadBalancer) + ]) + h = boto.handler.XmlHandler(rs, None) + xml.sax.parseString(LISTENERS_RESPONSE, h) + listeners = rs[0].listeners + self.assertEqual( + sorted([l.get_complex_tuple() for l in listeners]), + [ + (80, 8000, 'HTTP', 'HTTP'), + (2525, 25, 'TCP', 'TCP'), + (8080, 80, 'HTTP', 'HTTP'), + ] + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/unit/rds/test_connection.py b/tests/unit/rds/test_connection.py index ff3000fc..7eef7415 100644 --- a/tests/unit/rds/test_connection.py +++ b/tests/unit/rds/test_connection.py @@ -78,6 +78,14 @@ class TestRDSConnection(AWSMockServiceTestCase): <AllocatedStorage>200</AllocatedStorage> <DBInstanceClass>db.m1.large</DBInstanceClass> <MasterUsername>awsuser</MasterUsername> + <StatusInfos> + <DBInstanceStatusInfo> + <Message></Message> + <Normal>true</Normal> + <Status>replicating</Status> + <StatusType>read replication</StatusType> + </DBInstanceStatusInfo> + </StatusInfos> </DBInstance> </DBInstances> </DescribeDBInstancesResult> @@ -123,6 +131,108 @@ class TestRDSConnection(AWSMockServiceTestCase): self.assertEqual(db.security_group.description, None) self.assertEqual(db.security_group.ec2_groups, []) self.assertEqual(db.security_group.ip_ranges, []) + self.assertEqual(len(db.status_infos), 1) + self.assertEqual(db.status_infos[0].message, '') + self.assertEqual(db.status_infos[0].normal, True) + self.assertEqual(db.status_infos[0].status, 'replicating') + self.assertEqual(db.status_infos[0].status_type, 'read replication') + + +class TestRDSOptionGroups(AWSMockServiceTestCase): + connection_class = RDSConnection + + def setUp(self): + super(TestRDSOptionGroups, self).setUp() + + def default_body(self): + return """ + <DescribeOptionGroupsResponse xmlns="http://rds.amazonaws.com/doc/2013-05-15/"> + <DescribeOptionGroupsResult> + <OptionGroupsList> + <OptionGroup> + <MajorEngineVersion>11.2</MajorEngineVersion> + <OptionGroupName>myoptiongroup</OptionGroupName> + <EngineName>oracle-se1</EngineName> + <OptionGroupDescription>Test option group</OptionGroupDescription> + <Options/> + </OptionGroup> + <OptionGroup> + <MajorEngineVersion>11.2</MajorEngineVersion> + <OptionGroupName>default:oracle-se1-11-2</OptionGroupName> + <EngineName>oracle-se1</EngineName> + <OptionGroupDescription>Default Option Group.</OptionGroupDescription> + <Options/> + </OptionGroup> + </OptionGroupsList> + </DescribeOptionGroupsResult> + <ResponseMetadata> + <RequestId>e4b234d9-84d5-11e1-87a6-71059839a52b</RequestId> + </ResponseMetadata> + </DescribeOptionGroupsResponse> + """ + + def test_describe_option_groups(self): + self.set_http_response(status_code=200) + response = self.service_connection.describe_option_groups() + self.assertEqual(len(response), 2) + options = response[0] + self.assertEqual(options.name, 'myoptiongroup') + self.assertEqual(options.description, 'Test option group') + self.assertEqual(options.engine_name, 'oracle-se1') + self.assertEqual(options.major_engine_version, '11.2') + options = response[1] + self.assertEqual(options.name, 'default:oracle-se1-11-2') + self.assertEqual(options.description, 'Default Option Group.') + self.assertEqual(options.engine_name, 'oracle-se1') + self.assertEqual(options.major_engine_version, '11.2') + + +class TestRDSOptionGroupOptions(AWSMockServiceTestCase): + connection_class = RDSConnection + + def setUp(self): + super(TestRDSOptionGroupOptions, self).setUp() + + def default_body(self): + return """ + <DescribeOptionGroupOptionsResponse xmlns="http://rds.amazonaws.com/doc/2013-05-15/"> + <DescribeOptionGroupOptionsResult> + <OptionGroupOptions> + <OptionGroupOption> + <MajorEngineVersion>11.2</MajorEngineVersion> + <PortRequired>true</PortRequired> + <OptionsDependedOn/> + <Description>Oracle Enterprise Manager</Description> + <DefaultPort>1158</DefaultPort> + <Name>OEM</Name> + <EngineName>oracle-se1</EngineName> + <MinimumRequiredMinorEngineVersion>0.2.v3</MinimumRequiredMinorEngineVersion> + <Persistent>false</Persistent> + <Permanent>false</Permanent> + </OptionGroupOption> + </OptionGroupOptions> + </DescribeOptionGroupOptionsResult> + <ResponseMetadata> + <RequestId>d9c8f6a1-84c7-11e1-a264-0b23c28bc344</RequestId> + </ResponseMetadata> + </DescribeOptionGroupOptionsResponse> + """ + + def test_describe_option_group_options(self): + self.set_http_response(status_code=200) + response = self.service_connection.describe_option_group_options() + self.assertEqual(len(response), 1) + options = response[0] + self.assertEqual(options.name, 'OEM') + self.assertEqual(options.description, 'Oracle Enterprise Manager') + self.assertEqual(options.engine_name, 'oracle-se1') + self.assertEqual(options.major_engine_version, '11.2') + self.assertEqual(options.min_minor_engine_version, '0.2.v3') + self.assertEqual(options.port_required, True) + self.assertEqual(options.default_port, 1158) + self.assertEqual(options.permanent, False) + self.assertEqual(options.persistent, False) + self.assertEqual(options.depends_on, []) if __name__ == '__main__': |