diff --git a/fuelclient/__init__.py b/fuelclient/__init__.py index 763f9ca7..b73ac005 100644 --- a/fuelclient/__init__.py +++ b/fuelclient/__init__.py @@ -73,6 +73,7 @@ def get_client(resource, version='v1', connection=None): 'plugins': v1.plugins, 'release': v1.release, 'role': v1.role, + 'sequence': v1.sequence, 'snapshot': v1.snapshot, 'task': v1.task, 'vip': v1.vip diff --git a/fuelclient/commands/base.py b/fuelclient/commands/base.py index e5058b5f..8f4351c8 100644 --- a/fuelclient/commands/base.py +++ b/fuelclient/commands/base.py @@ -76,6 +76,8 @@ class BaseCommand(command.Command): class BaseListCommand(lister.Lister, BaseCommand): """Lists all entities showing some information.""" + filters = {} + @abc.abstractproperty def columns(self): """Names of columns in the resulting table.""" @@ -106,7 +108,13 @@ class BaseListCommand(lister.Lister, BaseCommand): return parser def take_action(self, parsed_args): - data = self.client.get_all() + filters = {} + for name, prop in self.filters.items(): + value = getattr(parsed_args, prop, None) + if value is not None: + filters[name] = value + + data = self.client.get_all(**filters) data = data_utils.get_display_data_multi(self.columns, data) scolumn_ids = [self.columns.index(col) diff --git a/fuelclient/commands/sequence.py b/fuelclient/commands/sequence.py new file mode 100644 index 00000000..51f3eace --- /dev/null +++ b/fuelclient/commands/sequence.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from fuelclient.cli import serializers +from fuelclient.commands import base +from fuelclient.common import data_utils + + +class SequenceMixIn(object): + entity_name = 'sequence' + + +class SequenceCreate(SequenceMixIn, base.show.ShowOne, base.BaseCommand): + """Create a new deployment sequence.""" + + columns = ("id", "release_id", "name") + + def get_parser(self, prog_name): + parser = super(SequenceCreate, self).get_parser(prog_name) + + parser.add_argument( + "-r", "--release", + type=int, + required=True, + help="Release object id, sequence will be linked to." + ) + parser.add_argument( + '-n', '--name', + required=True, + help='The unique name for sequence' + ) + parser.add_argument( + '-t', '--graph-type', + dest='graph_types', + nargs='+', + required=True, + help='Graph types, which will be included to sequence.\n' + 'Note: Order is important' + ) + return parser + + def take_action(self, args): + new_sequence = self.client.create( + args.release, args.name, args.graph_types + ) + self.app.stdout.write("Sequence was successfully created:\n") + data = data_utils.get_display_data_single(self.columns, new_sequence) + + return self.columns, data + + +class SequenceUpload(SequenceMixIn, base.show.ShowOne, base.BaseCommand): + """Upload a new deployment sequence.""" + + columns = ("id", "release_id", "name") + + def get_parser(self, prog_name): + parser = super(SequenceUpload, self).get_parser(prog_name) + + parser.add_argument( + "-r", "--release", + type=int, + required=True, + help="Release object id, sequence will be linked to." + ) + parser.add_argument( + '--file', + required=True, + help='YAML file which contains deployment sequence properties.' + ) + return parser + + def take_action(self, args): + serializer = serializers.FileFormatBasedSerializer() + new_sequence = self.client.upload( + args.release, serializer.read_from_file(args.file) + ) + self.app.stdout.write("Sequence was successfully created:\n") + data = data_utils.get_display_data_single(self.columns, new_sequence) + return self.columns, data + + +class SequenceDownload(SequenceMixIn, base.BaseCommand): + """Download deployment sequence data.""" + + def get_parser(self, prog_name): + parser = super(SequenceDownload, self).get_parser(prog_name) + + parser.add_argument( + "id", + type=int, + help="Sequence ID." + ) + parser.add_argument( + '--file', + help='The file path where data will be saved.' + ) + return parser + + def take_action(self, args): + data = self.client.download(args.id) + if args.file: + serializer = serializers.FileFormatBasedSerializer() + serializer.write_to_file(args.file, data) + else: + serializer = serializers.Serializer("yaml") + serializer.write_to_file(self.app.stdout, data) + + +class SequenceUpdate(SequenceMixIn, base.BaseShowCommand): + """Update existing sequence""" + + columns = ("id", "name") + + def get_parser(self, prog_name): + parser = super(SequenceUpdate, self).get_parser(prog_name) + parser.add_argument( + '-n', '--name', + required=False, + help='The unique name for sequence' + ) + parser.add_argument( + '-t', '--graph-type', + dest='graph_types', + nargs='+', + required=False, + help='Graph types, which will be included to sequence.\n' + 'Note: Order is important' + ) + return parser + + def take_action(self, args): + sequence = self.client.update( + args.id, name=args.name, graph_types=args.graph_types + ) + + if sequence: + self.app.stdout.write("Sequence was successfully updated:\n") + data = data_utils.get_display_data_single(self.columns, sequence) + return self.columns, data + else: + self.app.stdout.write("Nothing to update.\n") + + +class SequenceDelete(SequenceMixIn, base.BaseDeleteCommand): + """Delete existing sequence""" + + +class SequenceShow(SequenceMixIn, base.BaseShowCommand): + """Display information about sequence""" + columns = ("id", "release_id", "name", "graphs") + + +class SequenceList(SequenceMixIn, base.BaseListCommand): + """Delete existing sequence""" + columns = ("id", "release_id", "name") + filters = {'release': 'release', 'cluster': 'env'} + + def get_parser(self, prog_name): + parser = super(SequenceList, self).get_parser(prog_name) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + '-r', '--release', + type=int, + help='The Release object ID' + ) + group.add_argument( + '-e', '--env', + type=int, + help='The environment object id.\n' + ) + return parser + + +class SequenceExecute(SequenceMixIn, base.BaseCommand): + """Executes sequence on specified environment.""" + + def get_parser(self, prog_name): + parser = super(SequenceExecute, self).get_parser(prog_name) + parser.add_argument( + 'id', + type=int, + help='Id of the Sequence.' + ) + parser.add_argument( + '-e', '--env', + type=int, + required=True, + help='Id of the environment' + ) + parser.add_argument( + '--dry-run', + action="store_true", + default=False, + help='Specifies to dry-run a deployment by configuring ' + 'task executor to dump the deployment graph to a dot file.') + parser.add_argument( + '--force', + action="store_true", + default=False, + help='Force run all deployment tasks ' + 'without evaluating conditions.' + ) + parser.add_argument( + '--noop', + action="store_true", + default=False, + help='Specifies noop-run deployment configuring ' + 'tasks executor to run puppet and shell tasks in ' + 'noop mode and skip all other. Stores noop-run ' + 'result summary in nailgun database.' + ) + return parser + + def take_action(self, args): + result = self.client.execute( + sequence_id=args.id, + env_id=args.env, + dry_run=args.dry_run, + noop_run=args.noop, + force=args.force + ) + msg = 'Deployment task with id {t} for the environment {e} ' \ + 'has been started.\n'.format(t=result.data['id'], + e=result.data['cluster']) + self.app.stdout.write(msg) diff --git a/fuelclient/objects/__init__.py b/fuelclient/objects/__init__.py index 7d71645b..96780d9e 100644 --- a/fuelclient/objects/__init__.py +++ b/fuelclient/objects/__init__.py @@ -29,3 +29,4 @@ from fuelclient.objects.task import Task from fuelclient.objects.fuelversion import FuelVersion from fuelclient.objects.network_group import NetworkGroup from fuelclient.objects.plugins import Plugins +from fuelclient.objects.sequence import Sequence diff --git a/fuelclient/objects/base.py b/fuelclient/objects/base.py index 1c1593f5..30a2980e 100644 --- a/fuelclient/objects/base.py +++ b/fuelclient/objects/base.py @@ -60,9 +60,9 @@ class BaseObject(object): return self._data @classmethod - def get_all_data(cls): - return cls.connection.get_request(cls.class_api_path) + def get_all_data(cls, **kwargs): + return cls.connection.get_request(cls.class_api_path, params=kwargs) @classmethod - def get_all(cls): - return map(cls.init_with_data, cls.get_all_data()) + def get_all(cls, **kwargs): + return map(cls.init_with_data, cls.get_all_data(**kwargs)) diff --git a/fuelclient/objects/sequence.py b/fuelclient/objects/sequence.py new file mode 100644 index 00000000..29ec9926 --- /dev/null +++ b/fuelclient/objects/sequence.py @@ -0,0 +1,21 @@ +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from fuelclient.objects.base import BaseObject + + +class Sequence(BaseObject): + + class_api_path = "sequences/" + instance_api_path = "sequences/{0}/" diff --git a/fuelclient/tests/unit/v2/cli/test_sequences.py b/fuelclient/tests/unit/v2/cli/test_sequences.py new file mode 100644 index 00000000..d74637f9 --- /dev/null +++ b/fuelclient/tests/unit/v2/cli/test_sequences.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock + +from fuelclient.tests.unit.v2.cli import test_engine + + +class TestSequenceActions(test_engine.BaseCLITest): + def test_create(self): + self.exec_command( + 'sequence create -r 1 -n test -t test_graph' + ) + self.m_client.create.assert_called_once_with( + 1, 'test', ['test_graph'] + ) + + def test_upload(self): + m_open = mock.mock_open(read_data='name: test\ngraphs: [test]') + module_path = 'fuelclient.cli.serializers.open' + with mock.patch(module_path, m_open, create=True): + self.exec_command( + 'sequence upload -r 1 --file sequence.yaml' + ) + + self.m_client.upload.assert_called_once_with( + 1, {'name': 'test', 'graphs': ['test']} + ) + + def test_download(self): + self.m_client.download.return_value = {"name": "test"} + m_open = mock.mock_open() + module_path = 'fuelclient.cli.serializers.open' + with mock.patch(module_path, m_open, create=True): + self.exec_command( + 'sequence download 1 --file sequence.json' + ) + self.m_client.download.assert_called_once_with(1) + with mock.patch('sys.stdout') as stdout_mock: + self.exec_command('sequence download 1') + stdout_mock.write.assert_called_with("name: test\n") + + def test_update(self): + self.exec_command( + 'sequence update 1 -n test -t test_graph' + ) + self.m_client.update.assert_called_once_with( + 1, name='test', graph_types=['test_graph'] + ) + + def test_show(self): + self.exec_command('sequence show 1') + self.m_client.get_by_id.assert_called_once_with(1) + + def test_list(self): + self.exec_command('sequence list -r 1') + self.m_client.get_all.assert_called_once_with(release=1) + + def test_delete(self): + self.exec_command('sequence delete 1') + self.m_client.delete_by_id.assert_called_once_with(1) + + def test_execute(self): + self.exec_command( + 'sequence execute 1 -e 2 --dry-run --force' + ) + self.m_client.execute.assert_called_once_with( + sequence_id=1, env_id=2, dry_run=True, noop_run=False, force=True + ) diff --git a/fuelclient/tests/unit/v2/lib/test_sequence.py b/fuelclient/tests/unit/v2/lib/test_sequence.py new file mode 100644 index 00000000..8cc40abb --- /dev/null +++ b/fuelclient/tests/unit/v2/lib/test_sequence.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import fuelclient +from fuelclient.tests.unit.v2.lib import test_api + + +class TestDeploymentSequence(test_api.BaseLibTest): + + def setUp(self): + super(TestDeploymentSequence, self).setUp() + self.version = 'v1' + self.client = fuelclient.get_client('sequence', self.version) + self.env_id = 1 + self.sequence_body = { + 'id': 1, 'release_id': 1, 'name': 'test', + 'graphs': [{'type': 'graph1'}] + } + + def _check_sequence_object(self, sequence): + self.assertEqual(self.sequence_body, sequence) + + def test_create(self): + matcher_post = self.m_request.post( + '/api/v1/sequences/', json=self.sequence_body + ) + sequence = self.client.create(1, name='test', graph_types=['graph1']) + self.assertTrue(matcher_post.called) + self._check_sequence_object(sequence) + + def test_update(self): + matcher_put = self.m_request.put( + '/api/v1/sequences/1/', json=self.sequence_body + ) + sequence = self.client.update(1, name='test') + self.assertTrue(matcher_put.called) + self.assertEqual('{"name": "test"}', matcher_put.last_request.body) + self._check_sequence_object(sequence) + + def test_delete(self): + mathcher_delete = self.m_request.delete( + '/api/v1/sequences/1/', status_code=204 + ) + self.client.delete_by_id(1) + self.assertTrue(mathcher_delete.called) + + def test_get_one(self): + matcher_get = self.m_request.get( + '/api/v1/sequences/1/', json=self.sequence_body + ) + sequence = self.client.get_by_id(1) + self.assertTrue(matcher_get.called) + self.assertEqual('test', sequence['name']) + self.assertEqual('graph1', sequence['graphs']) + + def test_get_all(self): + matcher_get = self.m_request.get( + '/api/v1/sequences/?release=1', json=[self.sequence_body] + ) + sequences = self.client.get_all(release=1) + self.assertTrue(matcher_get.called) + self.assertEqual(1, len(sequences)) + self._check_sequence_object(sequences[0]) + + def test_execute(self): + self.m_request.get('/api/v1/nodes/?cluster_id=2', json=[]) + self.m_request.get('/api/v1/cluster/2', json={'id': 2}) + + matcher_post = self.m_request.post( + '/api/v1/sequences/1/execute/', json={'id': 10, 'cluster': 2} + ) + self.client.execute(1, 2, dry_run=True, noop_run=False) + self.assertTrue(matcher_post.called) + self.assertIn('"cluster": 2', matcher_post.last_request.body) + self.assertIn('"noop_run": false', matcher_post.last_request.body) + self.assertIn('"dry_run": true', matcher_post.last_request.body) + + def test_upload(self): + matcher_post = self.m_request.post( + '/api/v1/sequences/', json=self.sequence_body + ) + sequence = self.client.upload(1, self.sequence_body) + self.assertTrue(matcher_post.called) + self._check_sequence_object(sequence) + + def test_download(self): + matcher_get = self.m_request.get( + '/api/v1/sequences/1/', json=self.sequence_body + ) + sequence = self.client.download(1) + self.assertTrue(matcher_get.called) + self._check_sequence_object(sequence) diff --git a/fuelclient/v1/__init__.py b/fuelclient/v1/__init__.py index 4d96abee..6b49bff6 100644 --- a/fuelclient/v1/__init__.py +++ b/fuelclient/v1/__init__.py @@ -25,6 +25,7 @@ from fuelclient.v1 import openstack_config from fuelclient.v1 import release from fuelclient.v1 import role from fuelclient.v1 import plugins +from fuelclient.v1 import sequence from fuelclient.v1 import snapshot from fuelclient.v1 import task from fuelclient.v1 import vip @@ -43,6 +44,7 @@ __all__ = ('cluster_settings', 'plugins', 'release', 'role', + 'sequence', 'snapshot', 'task', 'vip') diff --git a/fuelclient/v1/base_v1.py b/fuelclient/v1/base_v1.py index c5af618e..8a725ee8 100644 --- a/fuelclient/v1/base_v1.py +++ b/fuelclient/v1/base_v1.py @@ -38,8 +38,8 @@ class BaseV1Client(object): {'connection': self.connection} ) - def get_all(self): - result = self._entity_wrapper.get_all_data() + def get_all(self, **kwargs): + result = self._entity_wrapper.get_all_data(**kwargs) return result diff --git a/fuelclient/v1/sequence.py b/fuelclient/v1/sequence.py new file mode 100644 index 00000000..7d5fc35d --- /dev/null +++ b/fuelclient/v1/sequence.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2016 Mirantis, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from fuelclient import objects +from fuelclient.v1 import base_v1 + + +class SequenceClient(base_v1.BaseV1Client): + _entity_wrapper = objects.Sequence + + executor_path = _entity_wrapper.instance_api_path + 'execute/' + + def create(self, release_id, name, graph_types): + """Creates new sequence object. + + :param release_id: the release object id + :param name: the sequence name + :param graph_types: the types of graphs + :returns: created object + """ + data = {'name': name} + graphs = data['graphs'] = [] + for graph_type in graph_types: + graphs.append({'type': graph_type}) + + return self.upload(release_id, data) + + def upload(self, release_id, data): + """Creates new sequence object from data. + + :param release_id: release object id + :param data: the sequence properties + :returns: created object + """ + url = self._entity_wrapper.class_api_path + data['release'] = release_id + return self.connection.post_request(url, data) + + def download(self, sequence_id): + """Get raw content of sequence.""" + return super(SequenceClient, self).get_by_id(sequence_id) + + def update(self, sequence_id, name=None, graph_types=None): + """Updates existing object. + + :param sequence_id: the sequence object id + :param name: new name + :param graph_types: new graph types + :returns: updated object or False if nothing to update + """ + data = {} + if name: + data['name'] = name + if graph_types: + graphs = data['graphs'] = [] + for graph_type in graph_types: + graphs.append({'type': graph_type}) + + if not data: + return False + + url = self._entity_wrapper.instance_api_path.format(sequence_id) + return self.connection.put_request(url, data) + + def get_by_id(self, sequence_id): + """Gets formatted sequence data by id.""" + data = super(SequenceClient, self).get_by_id(sequence_id) + data['graphs'] = ', '.join(g['type'] for g in data['graphs']) + return data + + def delete_by_id(self, sequence_id): + """Deletes existed object. + + :param sequence_id: the sequence object id + """ + url = self._entity_wrapper.instance_api_path.format(sequence_id) + self.connection.delete_request(url) + + def execute(self, sequence_id, env_id, **kwargs): + """Executes sequence on cluster. + + :param sequence_id: the sequence object id + :param env_id: the cluster id + :param kwargs: options - force, dry_run and noop. + """ + data = {'cluster': env_id} + data.update(kwargs) + url = self.executor_path.format(sequence_id) + deploy_data = self.connection.post_request(url, data) + return objects.DeployTask.init_with_data(deploy_data) + + +def get_client(connection): + return SequenceClient(connection) diff --git a/setup.cfg b/setup.cfg index 1266d06d..10fcf288 100644 --- a/setup.cfg +++ b/setup.cfg @@ -110,6 +110,14 @@ fuelclient = task_network-configuration_download=fuelclient.commands.task:TaskNetworkConfigurationDownload task_settings_download=fuelclient.commands.task:TaskClusterSettingsDownload task_show=fuelclient.commands.task:TaskShow + sequence_create=fuelclient.commands.sequence:SequenceCreate + sequence_delete=fuelclient.commands.sequence:SequenceDelete + sequence_download=fuelclient.commands.sequence:SequenceDownload + sequence_execute=fuelclient.commands.sequence:SequenceExecute + sequence_list=fuelclient.commands.sequence:SequenceList + sequence_show=fuelclient.commands.sequence:SequenceShow + sequence_update=fuelclient.commands.sequence:SequenceUpdate + sequence_upload=fuelclient.commands.sequence:SequenceUpload snapshot_create=fuelclient.commands.snapshot:SnapshotGenerate snapshot_get-default-config=fuelclient.commands.snapshot:SnapshotConfigGetDefault snapshot_get-link=fuelclient.commands.snapshot:SnapshotGetLink