diff options
-rw-r--r-- | heatclient/common/template_utils.py | 8 | ||||
-rw-r--r-- | heatclient/osc/v1/stack.py | 164 | ||||
-rw-r--r-- | heatclient/tests/unit/osc/v1/test_stack.py | 174 | ||||
-rw-r--r-- | setup.cfg | 1 |
4 files changed, 344 insertions, 3 deletions
diff --git a/heatclient/common/template_utils.py b/heatclient/common/template_utils.py index d6e366f..aa4583c 100644 --- a/heatclient/common/template_utils.py +++ b/heatclient/common/template_utils.py @@ -27,17 +27,19 @@ from heatclient import exc from heatclient.openstack.common._i18n import _ -def process_template_path(template_path, object_request=None): +def process_template_path(template_path, object_request=None, existing=False): """Read template from template path. Attempt to read template first as a file or url. If that is unsuccessful, try again to assuming path is to a template object. """ try: - return get_template_contents(template_file=template_path) + return get_template_contents(template_file=template_path, + existing=existing) except error.URLError: return get_template_contents(template_object=template_path, - object_request=object_request) + object_request=object_request, + existing=existing) def get_template_contents(template_file=None, template_url=None, diff --git a/heatclient/osc/v1/stack.py b/heatclient/osc/v1/stack.py index 34f7ccb..d7f905c 100644 --- a/heatclient/osc/v1/stack.py +++ b/heatclient/osc/v1/stack.py @@ -163,6 +163,170 @@ class CreateStack(show.ShowOne): return _show_stack(client, stack['id'], format='table', short=True) +class UpdateStack(show.ShowOne): + """Update a stack.""" + + log = logging.getLogger(__name__ + '.UpdateStack') + + def get_parser(self, prog_name): + parser = super(UpdateStack, self).get_parser(prog_name) + parser.add_argument( + '-t', '--template', metavar='<FILE or URL>', + help=_('Path to the template') + ) + parser.add_argument( + '-e', '--environment', metavar='<FILE or URL>', + action='append', + help=_('Path to the environment. Can be specified multiple times') + ) + parser.add_argument( + '--pre-update', metavar='<RESOURCE>', action='append', + help=_('Name of a resource to set a pre-update hook to. Resources ' + 'in nested stacks can be set using slash as a separator: ' + 'nested_stack/another/my_resource. You can use wildcards ' + 'to match multiple stacks or resources: ' + 'nested_stack/an*/*_resource. This can be specified ' + 'multiple times') + ) + parser.add_argument( + '--timeout', metavar='<TIMEOUT>', type=int, + help=_('Stack update timeout in minutes') + ) + parser.add_argument( + '--rollback', metavar='<VALUE>', + help=_('Set rollback on update failure. ' + 'Value "enabled" sets rollback to enabled. ' + 'Value "disabled" sets rollback to disabled. ' + 'Value "keep" uses the value of existing stack to be ' + 'updated (default)') + ) + parser.add_argument( + '--dry-run', action="store_true", + help=_('Do not actually perform the stack update, but show what ' + 'would be changed') + ) + parser.add_argument( + '--parameter', metavar='<KEY=VALUE>', + help=_('Parameter values used to create the stack. ' + 'This can be specified multiple times'), + action='append' + ) + parser.add_argument( + '--parameter-file', metavar='<KEY=FILE>', + help=_('Parameter values from file used to create the stack. ' + 'This can be specified multiple times. Parameter value ' + 'would be the content of the file'), + action='append' + ) + parser.add_argument( + '--existing', action="store_true", + help=_('Re-use the template, parameters and environment of the ' + 'current stack. If the template argument is omitted then ' + 'the existing template is used. If no %(env_arg)s is ' + 'specified then the existing environment is used. ' + 'Parameters specified in %(arg)s will patch over the ' + 'existing values in the current stack. Parameters omitted ' + 'will keep the existing values') % { + 'arg': '--parameter', 'env_arg': '--environment'} + ) + parser.add_argument( + '--clear-parameter', metavar='<PARAMETER>', + help=_('Remove the parameters from the set of parameters of ' + 'current stack for the %(cmd)s. The default value in the ' + 'template will be used. This can be specified multiple ' + 'times') % {'cmd': 'stack-update'}, + action='append' + ) + parser.add_argument( + 'stack', metavar='<STACK>', + help=_('Name or ID of stack to update') + ) + parser.add_argument( + '--tags', metavar='<TAG1,TAG2>', + help=_('An updated list of tags to associate with the stack') + ) + parser.add_argument( + '--wait', + action='store_true', + help=_('Wait until stack goes to UPDATE_COMPLETE or ' + 'UPDATE_FAILED') + ) + + return parser + + def take_action(self, parsed_args): + self.log.debug('take_action(%s)', parsed_args) + + client = self.app.client_manager.orchestration + + tpl_files, template = template_utils.process_template_path( + parsed_args.template, + object_request=_authenticated_fetcher(client), + existing=parsed_args.existing) + + env_files, env = ( + template_utils.process_multiple_environments_and_files( + env_paths=parsed_args.environment)) + + parameters = heat_utils.format_all_parameters( + parsed_args.parameter, + parsed_args.parameter_file, + parsed_args.template) + + if parsed_args.pre_update: + template_utils.hooks_to_env(env, parsed_args.pre_update, + 'pre-update') + + fields = { + 'stack_id': parsed_args.stack, + 'parameters': parameters, + 'existing': parsed_args.existing, + 'template': template, + 'files': dict(list(tpl_files.items()) + list(env_files.items())), + 'environment': env + } + + if parsed_args.tags: + fields['tags'] = parsed_args.tags + if parsed_args.timeout: + fields['timeout_mins'] = parsed_args.timeout + if parsed_args.clear_parameter: + fields['clear_parameters'] = list(parsed_args.clear_parameter) + + if parsed_args.rollback: + rollback = parsed_args.rollback.strip().lower() + if rollback not in ('enabled', 'disabled', 'keep'): + msg = _('--rollback invalid value: %s') % parsed_args.rollback + raise exc.CommandError(msg) + if rollback != 'keep': + fields['disable_rollback'] = rollback == 'disabled' + + if parsed_args.dry_run: + changes = client.stacks.preview_update(**fields) + + fields = ['state', 'resource_name', 'resource_type', + 'resource_identity'] + + columns = sorted(changes.get("resource_changes", {}).keys()) + data = [heat_utils.json_formatter(changes["resource_changes"][key]) + for key in columns] + + return columns, data + + client.stacks.update(**fields) + if parsed_args.wait: + if not utils.wait_for_status(client.stacks.get, parsed_args.stack, + status_field='stack_status', + success_status='update_complete', + error_status='update_failed'): + + msg = _('Stack %s failed to update.') % parsed_args.stack + raise exc.CommandError(msg) + + return _show_stack(client, parsed_args.stack, format='table', + short=True) + + class ShowStack(show.ShowOne): """Show stack details""" diff --git a/heatclient/tests/unit/osc/v1/test_stack.py b/heatclient/tests/unit/osc/v1/test_stack.py index bf2fa5f..cdec6cd 100644 --- a/heatclient/tests/unit/osc/v1/test_stack.py +++ b/heatclient/tests/unit/osc/v1/test_stack.py @@ -13,6 +13,7 @@ import copy import mock +import six import testscenarios from openstackclient.common import exceptions as exc @@ -137,6 +138,179 @@ class TestStackCreate(TestStack): self.assertRaises(exc.CommandError, self.cmd.take_action, parsed_args) +class TestStackUpdate(TestStack): + + template_path = 'heatclient/tests/test_templates/empty.yaml' + + defaults = { + 'stack_id': 'my_stack', + 'environment': {}, + 'existing': False, + 'files': {}, + 'template': {'heat_template_version': '2013-05-23'}, + 'parameters': {}, + } + + def setUp(self): + super(TestStackUpdate, self).setUp() + self.cmd = stack.UpdateStack(self.app, None) + self.stack_client.update = mock.MagicMock( + return_value={'stack': {'id': '1234'}}) + self.stack_client.preview_update = mock.MagicMock( + return_value={'resource_changes': {'added': [], + 'deleted': [], + 'replaced': [], + 'unchanged': [], + 'updated': []}}) + self.stack_client.get = mock.MagicMock( + return_value={'stack_status': 'create_complete'}) + stack._authenticated_fetcher = mock.MagicMock() + + def test_stack_update_defaults(self): + arglist = ['my_stack', '-t', self.template_path] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**self.defaults) + + def test_stack_update_rollback_enabled(self): + arglist = ['my_stack', '-t', self.template_path, '--rollback', + 'enabled'] + kwargs = copy.deepcopy(self.defaults) + kwargs['disable_rollback'] = False + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_rollback_disabled(self): + arglist = ['my_stack', '-t', self.template_path, '--rollback', + 'disabled'] + kwargs = copy.deepcopy(self.defaults) + kwargs['disable_rollback'] = True + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_rollback_keep(self): + arglist = ['my_stack', '-t', self.template_path, '--rollback', + 'keep'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.assertNotIn('disable_rollback', self.defaults) + self.stack_client.update.assert_called_with(**self.defaults) + + def test_stack_update_rollback_invalid(self): + arglist = ['my_stack', '-t', self.template_path, '--rollback', 'foo'] + kwargs = copy.deepcopy(self.defaults) + kwargs['disable_rollback'] = False + parsed_args = self.check_parser(self.cmd, arglist, []) + + ex = self.assertRaises(exc.CommandError, self.cmd.take_action, + parsed_args) + self.assertEqual("--rollback invalid value: foo", six.text_type(ex)) + + def test_stack_update_parameters(self): + template_path = ('/'.join(self.template_path.split('/')[:-1]) + + '/parameters.yaml') + arglist = ['my_stack', '-t', template_path, '--parameter', 'p1=a', + '--parameter', 'p2=6'] + kwargs = copy.deepcopy(self.defaults) + kwargs['parameters'] = {'p1': 'a', 'p2': '6'} + kwargs['template']['parameters'] = {'p1': {'type': 'string'}, + 'p2': {'type': 'number'}} + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_clear_parameters(self): + arglist = ['my_stack', '-t', self.template_path, '--clear-parameter', + 'a'] + kwargs = copy.deepcopy(self.defaults) + kwargs['clear_parameters'] = ['a'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_tags(self): + arglist = ['my_stack', '-t', self.template_path, '--tags', 'tag1,tag2'] + kwargs = copy.deepcopy(self.defaults) + kwargs['tags'] = 'tag1,tag2' + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_timeout(self): + arglist = ['my_stack', '-t', self.template_path, '--timeout', '60'] + kwargs = copy.deepcopy(self.defaults) + kwargs['timeout_mins'] = 60 + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_pre_update(self): + arglist = ['my_stack', '-t', self.template_path, '--pre-update', 'a'] + kwargs = copy.deepcopy(self.defaults) + kwargs['environment'] = { + 'resource_registry': {'resources': {'a': {'hooks': 'pre-update'}}} + } + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_existing(self): + arglist = ['my_stack', '-t', self.template_path, '--existing'] + kwargs = copy.deepcopy(self.defaults) + kwargs['existing'] = True + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**kwargs) + + def test_stack_update_dry_run(self): + arglist = ['my_stack', '-t', self.template_path, '--dry-run'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.preview_update.assert_called_with(**self.defaults) + self.stack_client.update.assert_not_called() + + def test_stack_update_wait(self): + arglist = ['my_stack', '-t', self.template_path, '--wait'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.cmd.take_action(parsed_args) + + self.stack_client.update.assert_called_with(**self.defaults) + self.stack_client.get.assert_called_with(**{'stack_id': 'my_stack'}) + + @mock.patch('openstackclient.common.utils.wait_for_status', + return_value=False) + def test_stack_update_wait_fail(self, mock_wait): + arglist = ['my_stack', '-t', self.template_path, '--wait'] + parsed_args = self.check_parser(self.cmd, arglist, []) + + self.assertRaises(exc.CommandError, self.cmd.take_action, parsed_args) + + class TestStackShow(TestStack): scenarios = [ @@ -33,6 +33,7 @@ openstack.orchestration.v1 = stack_show = heatclient.osc.v1.stack:ShowStack stack_list = heatclient.osc.v1.stack:ListStack stack_create = heatclient.osc.v1.stack:CreateStack + stack_update = heatclient.osc.v1.stack:UpdateStack [global] |