From: Steven Hardy Date: Tue, 17 Jul 2012 14:46:49 +0000 (+0100) Subject: heat api/engine : Implement UpdateStack functionality X-Git-Tag: 2014.1~1576 X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=0191587ac6a79509576e2043412020d9c4257b47;p=openstack-build%2Fheat-build.git heat api/engine : Implement UpdateStack functionality Implements initial support for UpdateStack, currently all resources default to delete/create on update. Ref #171 Change-Id: I3e6e63143d554c21ccdee19879c4dfb8b6e693d7 --- diff --git a/heat/api/v1/stacks.py b/heat/api/v1/stacks.py index 4e133ecf..41b8dbcd 100644 --- a/heat/api/v1/stacks.py +++ b/heat/api/v1/stacks.py @@ -281,10 +281,21 @@ class StackController(object): return None + CREATE_OR_UPDATE_ACTION = ( + CREATE_STACK, UPDATE_STACK + ) = ( + "CreateStack", "UpdateStack") + def create(self, req): + return self.create_or_update(req, self.CREATE_STACK) + + def update(self, req): + return self.create_or_update(req, self.UPDATE_STACK) + + def create_or_update(self, req, action=None): """ - Implements CreateStack API action - Create stack as defined in template file + Implements CreateStack and UpdateStack API actions + Create or update stack as defined in template file """ def extract_args(params): """ @@ -302,6 +313,14 @@ class StackController(object): return result + if action not in self.CREATE_OR_UPDATE_ACTION: + msg = _("Unexpected action %s" % action) + # This should not happen, so return HeatInternalFailureError + return exception.HeatInternalFailureError(detail=msg) + + engine_action = {self.CREATE_STACK: "create_stack", + self.UPDATE_STACK: "update_stack"} + con = req.context # Extract the stack input parameters @@ -328,7 +347,7 @@ class StackController(object): try: res = rpc.call(con, 'engine', - {'method': 'create_stack', + {'method': engine_action[action], 'args': {'stack_name': req.params['StackName'], 'template': stack, 'params': stack_parms, @@ -336,8 +355,7 @@ class StackController(object): except rpc_common.RemoteError as ex: return self._remote_error(ex) - return self._format_response('CreateStack', - self._stackid_addprefix(res)) + return self._format_response(action, self._stackid_addprefix(res)) def get_template(self, req): """ diff --git a/heat/db/api.py b/heat/db/api.py index 7b27efc5..62616c08 100644 --- a/heat/db/api.py +++ b/heat/db/api.py @@ -107,6 +107,10 @@ def stack_create(context, values): return IMPL.stack_create(context, values) +def stack_update(context, stack_id, values): + return IMPL.stack_update(context, stack_id, values) + + def stack_delete(context, stack_id): return IMPL.stack_delete(context, stack_id) diff --git a/heat/db/sqlalchemy/api.py b/heat/db/sqlalchemy/api.py index 31bd1393..fd20af1e 100644 --- a/heat/db/sqlalchemy/api.py +++ b/heat/db/sqlalchemy/api.py @@ -154,6 +154,27 @@ def stack_create(context, values): return stack_ref +def stack_update(context, stack_id, values): + stack = stack_get(context, stack_id) + + if not stack: + raise NotFound('Attempt to update a stack with id: %s %s' % + (stack_id, 'that does not exist')) + + old_template_id = stack.raw_template_id + + stack.update(values) + stack.save() + + # When the raw_template ID changes, we delete the old template + # after storing the new template ID + if stack.raw_template_id != old_template_id: + session = Session.object_session(stack) + rt = raw_template_get(context, old_template_id) + session.delete(rt) + session.flush() + + def stack_delete(context, stack_id): s = stack_get(context, stack_id) if not s: diff --git a/heat/engine/autoscaling.py b/heat/engine/autoscaling.py index 8146e2e9..5fe21fb5 100644 --- a/heat/engine/autoscaling.py +++ b/heat/engine/autoscaling.py @@ -62,6 +62,9 @@ class AutoScalingGroup(Resource): self.adjust(int(self.properties['MinSize']), adjustment_type='ExactCapacity') + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): if self.instance_id is not None: conf = self.properties['LaunchConfigurationName'] diff --git a/heat/engine/cloud_watch.py b/heat/engine/cloud_watch.py index cdb5c1e0..a82f9b0c 100644 --- a/heat/engine/cloud_watch.py +++ b/heat/engine/cloud_watch.py @@ -73,6 +73,9 @@ class CloudWatchAlarm(Resource): wr = db_api.watch_rule_create(self.context, wr_values) self.instance_id = wr.id + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): try: db_api.watch_rule_delete(self.context, self.name) diff --git a/heat/engine/eip.py b/heat/engine/eip.py index de22eeae..854db76f 100644 --- a/heat/engine/eip.py +++ b/heat/engine/eip.py @@ -49,6 +49,9 @@ class ElasticIp(Resource): self.ipaddress = ips.ip self.instance_id_set(ips.id) + def handle_update(self): + return self.UPDATE_REPLACE + def validate(self): ''' Validate the ip address here diff --git a/heat/engine/instance.py b/heat/engine/instance.py index 351886f8..77eb1b7f 100644 --- a/heat/engine/instance.py +++ b/heat/engine/instance.py @@ -258,6 +258,9 @@ class Instance(resources.Resource): ('nova reported unexpected', self.name, server.status)) + def handle_update(self): + return self.UPDATE_REPLACE + def validate(self): ''' Validate any of the provided params diff --git a/heat/engine/loadbalancer.py b/heat/engine/loadbalancer.py index 0bfade85..12e8115c 100644 --- a/heat/engine/loadbalancer.py +++ b/heat/engine/loadbalancer.py @@ -312,6 +312,9 @@ class LoadBalancer(stack.Stack): def FnGetRefId(self): return unicode(self.name) + def handle_update(self): + return self.UPDATE_REPLACE + def FnGetAtt(self, key): ''' We don't really support any of these yet. diff --git a/heat/engine/manager.py b/heat/engine/manager.py index 31c95e9e..5db4d3e6 100644 --- a/heat/engine/manager.py +++ b/heat/engine/manager.py @@ -122,6 +122,46 @@ class EngineManager(manager.Manager): return {'StackName': stack.name, 'StackId': stack.id} + def update_stack(self, context, stack_name, template, params, args): + """ + The update_stack method updates an existing stack based on the + provided template and parameters. + Note that at this stage the template has already been fetched from the + heat-api process if using a template-url. + arg1 -> RPC context. + arg2 -> Name of the stack you want to create. + arg3 -> Template of stack you want to create. + arg4 -> Stack Input Params + arg4 -> Request parameters/args passed from API + """ + logger.info('template is %s' % template) + + auth.authenticate(context) + + # Get the database representation of the existing stack + db_stack = db_api.stack_get_by_name(None, stack_name) + if not db_stack: + return {'Error': 'No stack exists with that name.'} + + current_stack = parser.Stack.load(context, db_stack.id) + + # Now parse the template and any parameters for the updated + # stack definition. + tmpl = parser.Template(template) + template_params = parser.Parameters(stack_name, tmpl, params) + common_params = api.extract_args(args) + + updated_stack = parser.Stack(context, stack_name, tmpl, + template_params, **common_params) + + response = updated_stack.validate() + if response['Description'] != 'Successfully validated': + return response + + greenpool.spawn_n(current_stack.update, updated_stack) + + return {'StackName': current_stack.name, 'StackId': current_stack.id} + def validate_template(self, context, template, params): """ The validate_template method uses the stack parser to check diff --git a/heat/engine/parser.py b/heat/engine/parser.py index 230b522e..f41f2a16 100644 --- a/heat/engine/parser.py +++ b/heat/engine/parser.py @@ -227,13 +227,18 @@ class Template(object): class Stack(object): - IN_PROGRESS = 'IN_PROGRESS' + CREATE_IN_PROGRESS = 'CREATE_IN_PROGRESS' CREATE_FAILED = 'CREATE_FAILED' CREATE_COMPLETE = 'CREATE_COMPLETE' + DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS' DELETE_FAILED = 'DELETE_FAILED' DELETE_COMPLETE = 'DELETE_COMPLETE' + UPDATE_IN_PROGRESS = 'UPDATE_IN_PROGRESS' + UPDATE_COMPLETE = 'UPDATE_COMPLETE' + UPDATE_FAILED = 'UPDATE_FAILED' + created_time = resources.Timestamp(db_api.stack_get, 'created_at') updated_time = resources.Timestamp(db_api.stack_get, 'updated_at') @@ -290,21 +295,26 @@ class Stack(object): return stack def store(self, owner=None): - '''Store the stack in the database and return its ID''' - if self.id is None: - new_creds = db_api.user_creds_create(self.context.to_dict()) - - s = { - 'name': self.name, - 'raw_template_id': self.t.store(), - 'parameters': self.parameters.user_parameters(), - 'owner_id': owner and owner.id, - 'user_creds_id': new_creds.id, - 'username': self.context.username, - 'status': self.state, - 'status_reason': self.state_description, - 'timeout': self.timeout_mins, - } + ''' + Store the stack in the database and return its ID + If self.id is set, we update the existing stack + ''' + new_creds = db_api.user_creds_create(self.context.to_dict()) + + s = { + 'name': self.name, + 'raw_template_id': self.t.store(), + 'parameters': self.parameters.user_parameters(), + 'owner_id': owner and owner.id, + 'user_creds_id': new_creds.id, + 'username': self.context.username, + 'status': self.state, + 'status_reason': self.state_description, + 'timeout': self.timeout_mins, + } + if self.id: + db_api.stack_update(self.context, self.id, s) + else: new_s = db_api.stack_create(self.context, s) self.id = new_s.id @@ -332,6 +342,10 @@ class Stack(object): '''Get the resource with the specified name.''' return self.resources[key] + def __setitem__(self, key, value): + '''Set the resource with the specified name to a specific value''' + self.resources[key] = value + def __contains__(self, key): '''Determine whether the stack contains the specified resource''' return key in self.resources @@ -394,7 +408,7 @@ class Stack(object): Creation will fail if it exceeds the specified timeout. The default is 60 minutes, set in the constructor ''' - self.state_set(self.IN_PROGRESS, 'Stack creation started') + self.state_set(self.CREATE_IN_PROGRESS, 'Stack creation started') stack_status = self.CREATE_COMPLETE reason = 'Stack successfully created' @@ -424,6 +438,116 @@ class Stack(object): self.state_set(stack_status, reason) + def update(self, newstack): + ''' + Compare the current stack with newstack, + and where necessary create/update/delete the resources until + this stack aligns with newstack. + + Note update of existing stack resources depends on update + being implemented in the underlying resource types + + Update will fail if it exceeds the specified timeout. The default is + 60 minutes, set in the constructor + ''' + self.state_set(self.UPDATE_IN_PROGRESS, 'Stack update started') + + # Now make the resources match the new stack definition + failures = [] + with eventlet.Timeout(self.timeout_mins * 60) as tmo: + try: + # First delete any resources which are not in newstack + for res in self: + if not res.name in newstack.keys(): + logger.debug("resource %s not found in updated stack" + % res.name + " definition, deleting") + result = res.destroy() + if result: + failures.append('Resource %s delete failed' + % res.name) + else: + del self.resources[res.name] + + # Then create any which are defined in newstack but not self + for res in newstack: + if not res.name in self.keys(): + logger.debug("resource %s not found in current stack" + % res.name + " definition, adding") + res.stack = self + self[res.name] = res + result = self[res.name].create() + if result: + failures.append('Resource %s create failed' + % res.name) + + # Now (the hard part :) update existing resources + # The Resource base class allows equality-test of resources, + # based on the parsed template snippet for the resource. + # If this test fails, we call the underlying resource.update + # + # FIXME : Implement proper update logic for the resources + # AWS define three update strategies, applied depending + # on the resource and what is being updated within a + # resource : + # - Update with no interruption + # - Update with some interruption + # - Update requires replacement + # + # Currently all resource have a default handle_update method + # which returns "requires replacement" (res.UPDATE_REPLACE) + for res in newstack: + if self[res.name] != res: + # Can fail if underlying resource class does not + # implement update logic or update requires replacement + retval = self[res.name].update(res.parsed_template()) + if retval == self[res.name].UPDATE_REPLACE: + logger.info("Resource %s for stack %s" % + (res.name, self.name) + + " update requires replacement") + # Resource requires replacement for update + result = self[res.name].destroy() + if result: + failures.append('Resource %s delete failed' + % res.name) + else: + res.stack = self + self[res.name] = res + result = self[res.name].create() + if result: + failures.append('Resource %s create failed' + % res.name) + else: + logger.warning("Cannot update resource %s," % + res.name + " reason %s" % retval) + failures.append('Resource %s update failed' + % res.name) + + # Set stack status values + if not failures: + # flip the template & parameters to the newstack values + self.t = newstack.t + self.parameters = newstack.parameters + self.outputs = self.resolve_static_data(self.t[OUTPUTS]) + self.dependencies = self._get_dependencies( + self.resources.itervalues()) + self.store() + + stack_status = self.UPDATE_COMPLETE + reason = 'Stack successfully updated' + else: + stack_status = self.UPDATE_FAILED + reason = ",".join(failures) + + except eventlet.Timeout as t: + if t is tmo: + stack_status = self.UPDATE_FAILED + reason = 'Timed out waiting for %s' % str(res) + else: + # not my timeout + raise + + self.state_set(stack_status, reason) + def delete(self): ''' Delete all of the resources, and then the stack itself. diff --git a/heat/engine/resources.py b/heat/engine/resources.py index b868d659..77453e23 100644 --- a/heat/engine/resources.py +++ b/heat/engine/resources.py @@ -100,6 +100,7 @@ class Timestamp(object): class Resource(object): + # Status strings CREATE_IN_PROGRESS = 'IN_PROGRESS' CREATE_FAILED = 'CREATE_FAILED' CREATE_COMPLETE = 'CREATE_COMPLETE' @@ -110,6 +111,12 @@ class Resource(object): UPDATE_FAILED = 'UPDATE_FAILED' UPDATE_COMPLETE = 'UPDATE_COMPLETE' + # Status values, returned from subclasses to indicate update method + UPDATE_REPLACE = 'UPDATE_REPLACE' + UPDATE_INTERRUPTION = 'UPDATE_INTERRUPTION' + UPDATE_NO_INTERRUPTION = 'UPDATE_NO_INTERRUPTION' + UPDATE_NOT_IMPLEMENTED = 'UPDATE_NOT_IMPLEMENTED' + # If True, this resource must be created before it can be referenced. strict_dependency = True @@ -153,6 +160,21 @@ class Resource(object): self._nova = {} self._keystone = None + def __eq__(self, other): + '''Allow == comparison of two resources''' + # For the purposes of comparison, we declare two resource objects + # equal if their parsed_templates are the same + if isinstance(other, Resource): + return self.parsed_template() == other.parsed_template() + return NotImplemented + + def __ne__(self, other): + '''Allow != comparison of two resources''' + result = self.__eq__(other) + if result is NotImplemented: + return result + return not result + def parsed_template(self, section=None, default={}): ''' Return the parsed template data for the resource. May be limited to @@ -232,6 +254,43 @@ class Resource(object): else: self.state_set(self.CREATE_COMPLETE) + def update(self, json_snippet=None): + ''' + update the resource. Subclasses should provide a handle_update() method + to customise update, the base-class handle_update will fail by default. + ''' + if self.state in (self.CREATE_IN_PROGRESS, self.UPDATE_IN_PROGRESS): + return 'Resource update already requested' + + if not json_snippet: + return 'Must specify json snippet for resource update!' + + logger.info('updating %s' % str(self)) + + result = self.UPDATE_NOT_IMPLEMENTED + try: + self.state_set(self.UPDATE_IN_PROGRESS) + self.t = self.stack.resolve_static_data(json_snippet) + self.properties = checkeddict.Properties(self.name, + self.properties_schema) + self.calculate_properties() + self.properties.validate() + if callable(getattr(self, 'handle_update', None)): + result = self.handle_update() + except Exception as ex: + logger.exception('update %s : %s' % (str(self), str(ex))) + self.state_set(self.UPDATE_FAILED, str(ex)) + return str(ex) + else: + # If resource was updated (with or without interruption), + # then we set the resource to UPDATE_COMPLETE + if not result == self.UPDATE_REPLACE: + self.state_set(self.UPDATE_COMPLETE) + return result + + def validate(self): + logger.info('Validating %s' % str(self)) + def validate(self): logger.info('Validating %s' % str(self)) @@ -375,6 +434,10 @@ class Resource(object): ''' return base64.b64encode(data) + def handle_update(self): + raise NotImplementedError("Update not implemented for Resource %s" + % type(self)) + class GenericResource(Resource): properties_schema = {} @@ -382,3 +445,7 @@ class GenericResource(Resource): def handle_create(self): logger.warning('Creating generic resource (Type "%s")' % self.t['Type']) + + def handle_update(self): + logger.warning('Updating generic resource (Type "%s")' % + self.t['Type']) diff --git a/heat/engine/security_group.py b/heat/engine/security_group.py index 65a01dda..dcebff9d 100644 --- a/heat/engine/security_group.py +++ b/heat/engine/security_group.py @@ -66,6 +66,9 @@ class SecurityGroup(Resource): # unexpected error raise + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): if self.instance_id is not None: try: diff --git a/heat/engine/stack.py b/heat/engine/stack.py index 145c8d3b..4642b94e 100644 --- a/heat/engine/stack.py +++ b/heat/engine/stack.py @@ -79,6 +79,9 @@ class Stack(Resource): self.create_with_template(template) + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): try: stack = self.nested() diff --git a/heat/engine/user.py b/heat/engine/user.py index be09b485..cabda2b3 100644 --- a/heat/engine/user.py +++ b/heat/engine/user.py @@ -56,6 +56,9 @@ class User(Resource): enabled=True) self.instance_id_set(user.id) + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): try: user = self.keystone().users.get(DummyId(self.instance_id)) @@ -137,6 +140,9 @@ class AccessKey(Resource): self.instance_id_set(cred.access) self._secret = cred.secret + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): user = self._user_from_name(self.properties['UserName']) if user and self.instance_id: diff --git a/heat/engine/volume.py b/heat/engine/volume.py index dee0debe..8330fff8 100644 --- a/heat/engine/volume.py +++ b/heat/engine/volume.py @@ -46,6 +46,9 @@ class Volume(Resource): else: raise exception.Error(vol.status) + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): if self.instance_id is not None: vol = self.nova('volume').volumes.get(self.instance_id) @@ -87,6 +90,9 @@ class VolumeAttachment(Resource): else: raise exception.Error(vol.status) + def handle_update(self): + return self.UPDATE_REPLACE + def handle_delete(self): server_id = self.properties['InstanceId'] volume_id = self.properties['VolumeId'] diff --git a/heat/engine/wait_condition.py b/heat/engine/wait_condition.py index fc908a57..85f50c08 100644 --- a/heat/engine/wait_condition.py +++ b/heat/engine/wait_condition.py @@ -43,6 +43,8 @@ class WaitConditionHandle(resources.Resource): self.stack.id, self.name) + def handle_update(self): + return self.UPDATE_REPLACE WAIT_STATUSES = ( WAITING, @@ -111,6 +113,9 @@ class WaitCondition(resources.Resource): if status != SUCCESS: raise exception.Error(reason) + def handle_update(self): + return self.UPDATE_REPLACE + def FnGetAtt(self, key): res = None if key == 'Data': diff --git a/heat/tests/test_parser.py b/heat/tests/test_parser.py index d58ed455..ab25cdb8 100644 --- a/heat/tests/test_parser.py +++ b/heat/tests/test_parser.py @@ -369,7 +369,7 @@ class StackTest(unittest.TestCase): self.assertEqual(stack.updated_time, None) stack.store() stored_time = stack.updated_time - stack.state_set(stack.IN_PROGRESS, 'testing') + stack.state_set(stack.CREATE_IN_PROGRESS, 'testing') self.assertNotEqual(stack.updated_time, None) self.assertNotEqual(stack.updated_time, stored_time)