From 268e6a10d6c3d718406bcebf5f86936c8961d853 Mon Sep 17 00:00:00 2001 From: Jason Dunsmore Date: Fri, 29 Jan 2016 11:43:19 -0600 Subject: Add support for launch_stack launchConfiguration type Rackspace AutoScale Groups now support a "launch_stack" launchConfiguration type. This adds support to the Rackspace::AutoScale::Group resource. Change-Id: I20d2d9d4f317d726ccbb21275984f096ba300767 --- .../rackspace/rackspace/resources/auto_scale.py | 135 ++++++- .../rackspace/rackspace/tests/test_auto_scale.py | 447 ++++++++++++++++++++- 2 files changed, 567 insertions(+), 15 deletions(-) (limited to 'contrib') diff --git a/contrib/rackspace/rackspace/resources/auto_scale.py b/contrib/rackspace/rackspace/resources/auto_scale.py index 3230f01be..86cafed14 100644 --- a/contrib/rackspace/rackspace/resources/auto_scale.py +++ b/contrib/rackspace/rackspace/resources/auto_scale.py @@ -14,6 +14,7 @@ """Resources for Rackspace Auto Scale.""" import copy +import six from heat.common import exception from heat.common.i18n import _ @@ -22,6 +23,7 @@ from heat.engine import constraints from heat.engine import properties from heat.engine import resource from heat.engine import support +from heat.engine import template as templatem try: from pyrax.exceptions import Forbidden @@ -74,9 +76,11 @@ class Group(resource.Resource): _LAUNCH_CONFIG_ARGS_KEYS = ( LAUNCH_CONFIG_ARGS_LOAD_BALANCERS, LAUNCH_CONFIG_ARGS_SERVER, + LAUNCH_CONFIG_ARGS_STACK, ) = ( 'loadBalancers', 'server', + 'stack', ) _LAUNCH_CONFIG_ARGS_LOAD_BALANCER_KEYS = ( @@ -115,6 +119,24 @@ class Group(resource.Resource): 'uuid', ) + _LAUNCH_CONFIG_ARGS_STACK_KEYS = ( + LAUNCH_CONFIG_ARGS_STACK_TEMPLATE, + LAUNCH_CONFIG_ARGS_STACK_TEMPLATE_URL, + LAUNCH_CONFIG_ARGS_STACK_DISABLE_ROLLBACK, + LAUNCH_CONFIG_ARGS_STACK_ENVIRONMENT, + LAUNCH_CONFIG_ARGS_STACK_FILES, + LAUNCH_CONFIG_ARGS_STACK_PARAMETERS, + LAUNCH_CONFIG_ARGS_STACK_TIMEOUT_MINS + ) = ( + 'template', + 'template_url', + 'disable_rollback', + 'environment', + 'files', + 'parameters', + 'timeout_mins' + ) + _launch_configuration_args_schema = { LAUNCH_CONFIG_ARGS_LOAD_BALANCERS: properties.Schema( properties.Schema.LIST, @@ -141,6 +163,7 @@ class Group(resource.Resource): properties.Schema.MAP, _('Server creation arguments, as accepted by the Cloud Servers ' 'server creation API.'), + required=False, schema={ LAUNCH_CONFIG_ARGS_SERVER_NAME: properties.Schema( properties.Schema.STRING, @@ -211,8 +234,49 @@ class Group(resource.Resource): 'key-based authentication to the server.') ), }, - required=True ), + LAUNCH_CONFIG_ARGS_STACK: properties.Schema( + properties.Schema.MAP, + _('The attributes that Auto Scale uses to create a new stack. The ' + 'attributes that you specify for the stack entity apply to all ' + 'new stacks in the scaling group. Note the stack arguments are ' + 'directly passed to Heat when creating a stack.'), + schema={ + LAUNCH_CONFIG_ARGS_STACK_TEMPLATE: properties.Schema( + properties.Schema.MAP, + _('The template that describes the stack. Either the ' + 'template or template_url property must be specified.'), + ), + LAUNCH_CONFIG_ARGS_STACK_TEMPLATE_URL: properties.Schema( + properties.Schema.STRING, + _('A URI to a template. Either the template or ' + 'template_url property must be specified.') + ), + LAUNCH_CONFIG_ARGS_STACK_DISABLE_ROLLBACK: properties.Schema( + properties.Schema.BOOLEAN, + _('Keep the resources that have been created if the stack ' + 'fails to create. Defaults to True.'), + default=True + ), + LAUNCH_CONFIG_ARGS_STACK_ENVIRONMENT: properties.Schema( + properties.Schema.MAP, + _('The environment for the stack.'), + ), + LAUNCH_CONFIG_ARGS_STACK_FILES: properties.Schema( + properties.Schema.MAP, + _('The contents of files that the template references.') + ), + LAUNCH_CONFIG_ARGS_STACK_PARAMETERS: properties.Schema( + properties.Schema.MAP, + _('Key/value pairs of the parameters and their values to ' + 'pass to the parameters in the template.') + ), + LAUNCH_CONFIG_ARGS_STACK_TIMEOUT_MINS: properties.Schema( + properties.Schema.INTEGER, + _('The stack creation timeout in minutes.') + ) + } + ) } properties_schema = { @@ -256,17 +320,18 @@ class Group(resource.Resource): schema={ LAUNCH_CONFIG_ARGS: properties.Schema( properties.Schema.MAP, - _('Type-specific server launching arguments.'), + _('Type-specific launch arguments.'), schema=_launch_configuration_args_schema, required=True ), LAUNCH_CONFIG_TYPE: properties.Schema( properties.Schema.STRING, - _('Launch configuration method. Only launch_server ' - 'is currently supported.'), + _('Launch configuration method. Only launch_server and ' + 'launch_stack are currently supported.'), required=True, constraints=[ - constraints.AllowedValues(['launch_server']), + constraints.AllowedValues(['launch_server', + 'launch_stack']), ] ), }, @@ -287,8 +352,7 @@ class Group(resource.Resource): max_entities=groupconf[self.GROUP_CONFIGURATION_MAX_ENTITIES], metadata=groupconf.get(self.GROUP_CONFIGURATION_METADATA, None)) - def _get_launch_config_args(self, launchconf): - """Get the launchConfiguration-related pyrax arguments.""" + def _get_launch_config_server_args(self, launchconf): lcargs = launchconf[self.LAUNCH_CONFIG_ARGS] server_args = lcargs[self.LAUNCH_CONFIG_ARGS_SERVER] lb_args = lcargs.get(self.LAUNCH_CONFIG_ARGS_LOAD_BALANCERS) @@ -330,6 +394,30 @@ class Group(resource.Resource): key_name=server_args.get(self.LAUNCH_CONFIG_ARGS_SERVER_KEY_NAME), ) + def _get_launch_config_stack_args(self, launchconf): + lcargs = launchconf[self.LAUNCH_CONFIG_ARGS] + stack_args = lcargs[self.LAUNCH_CONFIG_ARGS_STACK] + return dict( + launch_config_type=launchconf[self.LAUNCH_CONFIG_TYPE], + template=stack_args[self.LAUNCH_CONFIG_ARGS_STACK_TEMPLATE], + template_url=stack_args[ + self.LAUNCH_CONFIG_ARGS_STACK_TEMPLATE_URL], + disable_rollback=stack_args[ + self.LAUNCH_CONFIG_ARGS_STACK_DISABLE_ROLLBACK], + environment=stack_args[self.LAUNCH_CONFIG_ARGS_STACK_ENVIRONMENT], + files=stack_args[self.LAUNCH_CONFIG_ARGS_STACK_FILES], + parameters=stack_args[self.LAUNCH_CONFIG_ARGS_STACK_PARAMETERS], + timeout_mins=stack_args[self.LAUNCH_CONFIG_ARGS_STACK_TIMEOUT_MINS] + ) + + def _get_launch_config_args(self, launchconf): + """Get the launchConfiguration-related pyrax arguments.""" + if launchconf[self.LAUNCH_CONFIG_ARGS].get( + self.LAUNCH_CONFIG_ARGS_SERVER): + return self._get_launch_config_server_args(launchconf) + else: + return self._get_launch_config_stack_args(launchconf) + def _get_create_args(self): """Get pyrax-style arguments for creating a scaling group.""" args = self._get_group_config_args( @@ -406,6 +494,19 @@ class Group(resource.Resource): super(Group, self).validate() launchconf = self.properties[self.LAUNCH_CONFIGURATION] lcargs = launchconf[self.LAUNCH_CONFIG_ARGS] + + server_args = lcargs.get(self.LAUNCH_CONFIG_ARGS_SERVER) + st_args = lcargs.get(self.LAUNCH_CONFIG_ARGS_STACK) + + # launch_server and launch_stack are required and mutually exclusive. + if ((not server_args and not st_args) or + (server_args and st_args)): + msg = (_('Must provide one of %(server)s or %(stack)s in %(conf)s') + % {'server': self.LAUNCH_CONFIG_ARGS_SERVER, + 'stack': self.LAUNCH_CONFIG_ARGS_STACK, + 'conf': self.LAUNCH_CONFIGURATION}) + raise exception.StackValidationFailed(msg) + lb_args = lcargs.get(self.LAUNCH_CONFIG_ARGS_LOAD_BALANCERS) lbs = copy.deepcopy(lb_args) for lb in lbs: @@ -418,6 +519,26 @@ class Group(resource.Resource): 'with id %s') % (lb_id) raise exception.StackValidationFailed(msg) + if st_args: + st_tmpl = st_args.get(self.LAUNCH_CONFIG_ARGS_STACK_TEMPLATE) + st_tmpl_url = st_args.get( + self.LAUNCH_CONFIG_ARGS_STACK_TEMPLATE_URL) + st_env = st_args.get(self.LAUNCH_CONFIG_ARGS_STACK_ENVIRONMENT) + # template and template_url are required and mutually exclusive. + if ((not st_tmpl and not st_tmpl_url) or + (st_tmpl and st_tmpl_url)): + msg = _('Must provide one of template or template_url.') + raise exception.StackValidationFailed(msg) + + if st_tmpl: + st_files = st_args.get(self.LAUNCH_CONFIG_ARGS_STACK_FILES) + try: + templatem.Template(st_tmpl, files=st_files, env=st_env) + except Exception as exc: + msg = (_('Encountered error while loading template: %s') % + six.text_type(exc)) + raise exception.StackValidationFailed(msg) + def auto_scale(self): return self.client('auto_scale') diff --git a/contrib/rackspace/rackspace/tests/test_auto_scale.py b/contrib/rackspace/rackspace/tests/test_auto_scale.py index bd8343c7b..f86cc28c4 100644 --- a/contrib/rackspace/rackspace/tests/test_auto_scale.py +++ b/contrib/rackspace/rackspace/tests/test_auto_scale.py @@ -102,9 +102,15 @@ class FakeAutoScale(object): def replace_launch_config(self, group_id, **kwargs): """Update the launch configuration on a scaling group.""" - allowed = ['launch_config_type', 'server_name', 'image', 'flavor', - 'disk_config', 'metadata', 'personality', 'networks', - 'load_balancers', 'key_name', 'user_data', 'config_drive'] + if kwargs.get('launch_config_type') == 'launch_server': + allowed = ['launch_config_type', 'server_name', 'image', 'flavor', + 'disk_config', 'metadata', 'personality', 'networks', + 'load_balancers', 'key_name', 'user_data', + 'config_drive'] + elif kwargs.get('launch_config_type') == 'launch_stack': + allowed = ['launch_config_type', 'template', 'template_url', + 'disable_rollback', 'environment', 'files', + 'parameters', 'timeout_mins'] self._check_args(kwargs, allowed) self._get_group(group_id).kwargs = kwargs @@ -171,7 +177,7 @@ class FakeAutoScale(object): class ScalingGroupTest(common.HeatTestCase): - group_template = template_format.parse(''' + server_template = template_format.parse(''' HeatTemplateFormatVersion: "2012-12-12" Description: "Rackspace Auto Scale" Parameters: {} @@ -207,6 +213,53 @@ class ScalingGroupTest(common.HeatTestCase): ''') + stack_template = template_format.parse(''' + HeatTemplateFormatVersion: "2012-12-12" + Description: "Rackspace Auto Scale" + Parameters: {} + Resources: + my_group: + Type: Rackspace::AutoScale::Group + Properties: + groupConfiguration: + name: "My Group" + cooldown: 60 + minEntities: 1 + maxEntities: 25 + metadata: + group: metadata + launchConfiguration: + type: launch_stack + args: + stack: + template: + heat_template_version: 2015-10-15 + description: This is a Heat template + parameters: + image: + default: cirros-0.3.4-x86_64-uec + type: string + flavor: + default: m1.tiny + type: string + resources: + rand: + type: OS::Heat::RandomString + disable_rollback: False + environment: + parameters: + image: Ubuntu 14.04 LTS (Trusty Tahr) (PVHVM) + resource_registry: + Heat::InstallConfigAgent: + https://myhost.com/bootconfig.yaml + files: + fileA.yaml: Contents of the file + file:///usr/fileB.template: Contents of the file + parameters: + flavor: 4 GB Performance + timeout_mins: 30 + ''') + def setUp(self): super(ScalingGroupTest, self).setUp() for res_name, res_class in auto_scale.resource_mapping().items(): @@ -222,14 +275,16 @@ class ScalingGroupTest(common.HeatTestCase): 'find_flavor_by_name_or_id') mock_fl.return_value = 'flavor-ref' - def _setup_test_stack(self): - self.stack = utils.parse_stack(self.group_template) + def _setup_test_stack(self, template=None): + if template is None: + template = self.server_template + self.stack = utils.parse_stack(template) self.stack.create() self.assertEqual( ('CREATE', 'COMPLETE'), self.stack.state, self.stack.status_reason) - def test_group_create(self): + def test_group_create_server(self): """Creating a group passes all the correct arguments to pyrax. Also saves the group ID as the resource ID. @@ -266,6 +321,62 @@ class ScalingGroupTest(common.HeatTestCase): resource = self.stack['my_group'] self.assertEqual('0', resource.FnGetRefId()) + def test_group_create_stack(self): + """Creating a group passes all the correct arguments to pyrax. + + Also saves the group ID as the resource ID. + """ + self._setup_test_stack(self.stack_template) + self.assertEqual(1, len(self.fake_auto_scale.groups)) + self.assertEqual( + { + 'cooldown': 60, + 'min_entities': 1, + 'max_entities': 25, + 'group_metadata': {'group': 'metadata'}, + 'name': 'My Group', + 'launch_config_type': u'launch_stack', + 'template': { + 'heat_template_version': '2015-10-15', + 'description': 'This is a Heat template', + 'parameters': { + 'flavor': { + 'default': 'm1.tiny', + 'type': 'string'}, + 'image': { + 'default': 'cirros-0.3.4-x86_64-uec', + 'type': 'string'}}, + 'resources': { + 'rand': {'type': u'OS::Heat::RandomString'} + } + }, + 'template_url': None, + 'disable_rollback': False, + 'environment': { + 'parameters': { + 'image': + 'Ubuntu 14.04 LTS (Trusty Tahr) (PVHVM)', + }, + 'resource_registry': { + 'Heat::InstallConfigAgent': ('https://myhost.com/' + 'bootconfig.yaml') + } + }, + 'files': { + 'fileA.yaml': 'Contents of the file', + 'file:///usr/fileB.template': 'Contents of the file' + }, + 'parameters': { + 'flavor': '4 GB Performance', + }, + 'timeout_mins': 30, + }, + self.fake_auto_scale.groups['0'].kwargs + ) + + resource = self.stack['my_group'] + self.assertEqual('0', resource.FnGetRefId()) + def test_group_create_no_personality(self): template = template_format.parse(''' @@ -366,7 +477,7 @@ Resources: self.assertEqual( 5, self.fake_auto_scale.groups['0'].kwargs['min_entities']) - def test_update_launch_config(self): + def test_update_launch_config_server(self): """Updates the launchConfigresults section. Updates the launchConfigresults section in a template results in a @@ -389,6 +500,24 @@ Resources: [{'loadBalancerId': 1, 'port': 80}], self.fake_auto_scale.groups['0'].kwargs['load_balancers']) + def test_update_launch_config_stack(self): + self._setup_test_stack(self.stack_template) + + resource = self.stack['my_group'] + uprops = copy.deepcopy(dict(resource.properties.data)) + lcargs = uprops['launchConfiguration']['args'] + lcargs['stack']['timeout_mins'] = 60 + new_template = rsrc_defn.ResourceDefinition(resource.name, + resource.type(), + uprops) + + scheduler.TaskRunner(resource.update, new_template)() + + self.assertEqual(1, len(self.fake_auto_scale.groups)) + self.assertEqual( + 60, + self.fake_auto_scale.groups['0'].kwargs['timeout_mins']) + def test_delete(self): """Deleting a ScalingGroup resource invokes pyrax API to delete it.""" self._setup_test_stack() @@ -786,3 +915,305 @@ class AutoScaleGroupValidationTests(common.HeatTestCase): asg = auto_scale.Group("test", rsrcdef, self.mockstack) self.assertIsNone(asg.validate()) + + def test_validate_launch_stack(self, mock_client, mock_plugin): + asg_properties = { + "groupConfiguration": { + "name": "My Group", + "cooldown": 60, + "minEntities": 1, + "maxEntities": 25, + "metadata": { + "group": "metadata", + }, + }, + "launchConfiguration": { + "type": "launch_stack", + "args": { + "stack": { + 'template': { + 'heat_template_version': '2015-10-15', + 'description': 'This is a Heat template', + 'parameters': { + 'flavor': { + 'default': 'm1.tiny', + 'type': 'string'}, + 'image': { + 'default': 'cirros-0.3.4-x86_64-uec', + 'type': 'string'}}, + 'resources': { + 'rand': {'type': u'OS::Heat::RandomString'} + } + }, + 'template_url': None, + 'disable_rollback': False, + 'environment': { + 'parameters': { + 'image': + 'Ubuntu 14.04 LTS (Trusty Tahr) (PVHVM)', + }, + 'resource_registry': { + 'Heat::InstallConfigAgent': ( + 'https://myhost.com/bootconfig.yaml') + } + }, + 'files': { + 'fileA.yaml': 'Contents of the file', + 'file:///usr/fileB.yaml': 'Contents of the file' + }, + 'parameters': { + 'flavor': '4 GB Performance', + }, + 'timeout_mins': 30, + } + } + } + } + rsrcdef = rsrc_defn.ResourceDefinition( + "test", auto_scale.Group, properties=asg_properties) + asg = auto_scale.Group("test", rsrcdef, self.mockstack) + + self.assertIsNone(asg.validate()) + + def test_validate_launch_server_and_stack(self, mock_client, mock_plugin): + asg_properties = { + "groupConfiguration": { + "name": "My Group", + "cooldown": 60, + "minEntities": 1, + "maxEntities": 25, + "metadata": { + "group": "metadata", + }, + }, + "launchConfiguration": { + "type": "launch_server", + "args": { + "server": { + "name": "sdfsdf", + "flavorRef": "ffdgdf", + "imageRef": "image-ref", + }, + "stack": { + 'template': { + 'heat_template_version': '2015-10-15', + 'description': 'This is a Heat template', + 'parameters': { + 'flavor': { + 'default': 'm1.tiny', + 'type': 'string'}, + 'image': { + 'default': 'cirros-0.3.4-x86_64-uec', + 'type': 'string'}}, + 'resources': { + 'rand': {'type': u'OS::Heat::RandomString'} + } + }, + 'template_url': None, + 'disable_rollback': False, + 'environment': { + 'parameters': { + 'image': + 'Ubuntu 14.04 LTS (Trusty Tahr) (PVHVM)', + }, + 'resource_registry': { + 'Heat::InstallConfigAgent': ( + 'https://myhost.com/bootconfig.yaml') + } + }, + 'files': { + 'fileA.yaml': 'Contents of the file', + 'file:///usr/fileB.yaml': 'Contents of the file' + }, + 'parameters': { + 'flavor': '4 GB Performance', + }, + 'timeout_mins': 30, + } + } + } + } + rsrcdef = rsrc_defn.ResourceDefinition( + "test", auto_scale.Group, properties=asg_properties) + asg = auto_scale.Group("test", rsrcdef, self.mockstack) + + error = self.assertRaises( + exception.StackValidationFailed, asg.validate) + self.assertIn( + 'Must provide one of server or stack in launchConfiguration', + six.text_type(error)) + + def test_validate_no_launch_server_or_stack(self, mock_client, + mock_plugin): + asg_properties = { + "groupConfiguration": { + "name": "My Group", + "cooldown": 60, + "minEntities": 1, + "maxEntities": 25, + "metadata": { + "group": "metadata", + }, + }, + "launchConfiguration": { + "type": "launch_server", + "args": {} + } + } + rsrcdef = rsrc_defn.ResourceDefinition( + "test", auto_scale.Group, properties=asg_properties) + asg = auto_scale.Group("test", rsrcdef, self.mockstack) + + error = self.assertRaises( + exception.StackValidationFailed, asg.validate) + self.assertIn( + 'Must provide one of server or stack in launchConfiguration', + six.text_type(error)) + + def test_validate_stack_template_and_template_url(self, mock_client, + mock_plugin): + asg_properties = { + "groupConfiguration": { + "name": "My Group", + "cooldown": 60, + "minEntities": 1, + "maxEntities": 25, + "metadata": { + "group": "metadata", + }, + }, + "launchConfiguration": { + "type": "launch_server", + "args": { + "stack": { + 'template': { + 'heat_template_version': '2015-10-15', + 'description': 'This is a Heat template', + 'parameters': { + 'flavor': { + 'default': 'm1.tiny', + 'type': 'string'}, + 'image': { + 'default': 'cirros-0.3.4-x86_64-uec', + 'type': 'string'}}, + 'resources': { + 'rand': {'type': 'OS::Heat::RandomString'} + } + }, + 'template_url': 'https://myhost.com/template.yaml', + } + } + } + } + rsrcdef = rsrc_defn.ResourceDefinition( + "test", auto_scale.Group, properties=asg_properties) + asg = auto_scale.Group("test", rsrcdef, self.mockstack) + + error = self.assertRaises( + exception.StackValidationFailed, asg.validate) + self.assertIn( + 'Must provide one of template or template_url', + six.text_type(error)) + + def test_validate_stack_no_template_or_template_url(self, mock_client, + mock_plugin): + asg_properties = { + "groupConfiguration": { + "name": "My Group", + "cooldown": 60, + "minEntities": 1, + "maxEntities": 25, + "metadata": { + "group": "metadata", + }, + }, + "launchConfiguration": { + "type": "launch_server", + "args": { + "stack": { + 'disable_rollback': False, + 'environment': { + 'parameters': { + 'image': + 'Ubuntu 14.04 LTS (Trusty Tahr) (PVHVM)', + }, + 'resource_registry': { + 'Heat::InstallConfigAgent': ( + 'https://myhost.com/bootconfig.yaml') + } + }, + 'files': { + 'fileA.yaml': 'Contents of the file', + 'file:///usr/fileB.yaml': 'Contents of the file' + }, + 'parameters': { + 'flavor': '4 GB Performance', + }, + 'timeout_mins': 30, + } + } + } + } + rsrcdef = rsrc_defn.ResourceDefinition( + "test", auto_scale.Group, properties=asg_properties) + asg = auto_scale.Group("test", rsrcdef, self.mockstack) + + error = self.assertRaises( + exception.StackValidationFailed, asg.validate) + self.assertIn( + 'Must provide one of template or template_url', + six.text_type(error)) + + def test_validate_invalid_template(self, mock_client, mock_plugin): + asg_properties = { + "groupConfiguration": { + "name": "My Group", + "cooldown": 60, + "minEntities": 1, + "maxEntities": 25, + "metadata": { + "group": "metadata", + }, + }, + "launchConfiguration": { + "type": "launch_stack", + "args": { + "stack": { + 'template': { + 'SJDADKJAJKLSheat_template_version': '2015-10-15', + 'description': 'This is a Heat template', + 'parameters': { + 'flavor': { + 'default': 'm1.tiny', + 'type': 'string'}, + 'image': { + 'default': 'cirros-0.3.4-x86_64-uec', + 'type': 'string'}}, + 'resources': { + 'rand': {'type': u'OS::Heat::RandomString'} + } + }, + 'template_url': None, + 'disable_rollback': False, + 'environment': {'Foo': 'Bar'}, + 'files': { + 'fileA.yaml': 'Contents of the file', + 'file:///usr/fileB.yaml': 'Contents of the file' + }, + 'parameters': { + 'flavor': '4 GB Performance', + }, + 'timeout_mins': 30, + } + } + } + } + rsrcdef = rsrc_defn.ResourceDefinition( + "test", auto_scale.Group, properties=asg_properties) + asg = auto_scale.Group("test", rsrcdef, self.mockstack) + + error = self.assertRaises( + exception.StackValidationFailed, asg.validate) + self.assertIn( + 'Encountered error while loading template:', + six.text_type(error)) -- cgit v1.2.1