diff options
Diffstat (limited to 'vmmsclient')
| -rw-r--r-- | vmmsclient/__init__.py | 14 | ||||
| -rw-r--r-- | vmmsclient/plugin.py | 66 | ||||
| -rw-r--r-- | vmmsclient/tests/__init__.py | 14 | ||||
| -rw-r--r-- | vmmsclient/tests/base.py | 48 | ||||
| -rw-r--r-- | vmmsclient/tests/v2/__init__.py | 14 | ||||
| -rw-r--r-- | vmmsclient/tests/v2/fakes.py | 40 | ||||
| -rw-r--r-- | vmmsclient/tests/v2/test_client.py | 278 | ||||
| -rw-r--r-- | vmmsclient/tests/v2/test_plugin.py | 53 | ||||
| -rw-r--r-- | vmmsclient/v2/__init__.py | 14 | ||||
| -rw-r--r-- | vmmsclient/v2/client.py | 382 |
10 files changed, 923 insertions, 0 deletions
diff --git a/vmmsclient/__init__.py b/vmmsclient/__init__.py new file mode 100644 index 0000000..a25a7f2 --- /dev/null +++ b/vmmsclient/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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. diff --git a/vmmsclient/plugin.py b/vmmsclient/plugin.py new file mode 100644 index 0000000..b9c2f9b --- /dev/null +++ b/vmmsclient/plugin.py @@ -0,0 +1,66 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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. + +try: + from unittest import mock +except ImportError: + import mock +from osc_lib.cli import parseractions +from osc_lib import utils + +from vmmsclient.v2.client import AddVMCommand, ListVMsCommand, RemoveVMCommand, ShowVMCommand, SetVMCommand, UnsetVMCommand + +DEFAULT_API_VERSION = '2' +API_VERSION_OPTION = 'os_vmms_api_version' +API_NAME = 'vmms' +API_VERSIONS = { + '2': 'vmmsclient.v2.client.Client', +} + +__all__ = [ + 'AddVMCommand', + 'ListVMsCommand', + 'RemoveVMCommand', + 'ShowVMCommand', + 'SetVMCommand', + 'UnsetVMCommand' +] + +def make_client(instance): + """Returns a client to the ClientManager.""" + # Safely get the configuration value + config = instance.get_configuration() + requested_api_version = config.get(API_VERSION_OPTION, DEFAULT_API_VERSION) + + client_class = utils.get_client_class(API_NAME, requested_api_version, API_VERSIONS) + + kwargs = { + 'session': instance.session, + 'service_type': 'vmms', + 'region_name': instance.region_name, + 'interface': instance.interface, + } + + client = client_class(**kwargs) + return client + +def build_option_parser(parser): + """Hook to add global options.""" + parser.add_argument( + '--os-vmms-api-version', + metavar='<vmms-api-version>', + default=utils.env('OS_VMMS_API_VERSION') or DEFAULT_API_VERSION, + help='VM Migration Scheduler API version (default: %(default)s)') + return parser diff --git a/vmmsclient/tests/__init__.py b/vmmsclient/tests/__init__.py new file mode 100644 index 0000000..a25a7f2 --- /dev/null +++ b/vmmsclient/tests/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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. diff --git a/vmmsclient/tests/base.py b/vmmsclient/tests/base.py new file mode 100644 index 0000000..e2e8da6 --- /dev/null +++ b/vmmsclient/tests/base.py @@ -0,0 +1,48 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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. + +try: + from unittest import mock +except ImportError: + import mock +import testtools + +from keystoneauth1 import fixture as ksa_fixture +from osc_lib import shell +from osc_lib.tests import utils + + +class FakeApp(object): + def __init__(self): + self.client_manager = None + + +class TestCase(testtools.TestCase): + """Test case base class for all unit tests.""" + + def setUp(self): + super(TestCase, self).setUp() + self.app = FakeApp() + self.shell = shell.OpenStackShell() + + +class TestCommand(TestCase): + """Test case base class for command tests.""" + + def setUp(self): + super(TestCommand, self).setUp() + self.app = mock.Mock() + self.app.client_manager = mock.Mock() + self.app.client_manager.vmms = mock.Mock() diff --git a/vmmsclient/tests/v2/__init__.py b/vmmsclient/tests/v2/__init__.py new file mode 100644 index 0000000..a25a7f2 --- /dev/null +++ b/vmmsclient/tests/v2/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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. diff --git a/vmmsclient/tests/v2/fakes.py b/vmmsclient/tests/v2/fakes.py new file mode 100644 index 0000000..9db59a0 --- /dev/null +++ b/vmmsclient/tests/v2/fakes.py @@ -0,0 +1,40 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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 datetime +import json +import uuid + + +def make_fake_migration(**kwargs): + """Create a fake migration object.""" + fake_migration = { + 'id': kwargs.get('id', str(uuid.uuid4())), + 'vm_id': kwargs.get('vm_id', str(uuid.uuid4())), + 'vm_name': kwargs.get('vm_name', 'test-vm'), + 'scheduled_time': kwargs.get('scheduled_time', None), + 'state': kwargs.get('state', 'SCHEDULED'), + 'created_at': kwargs.get('created_at', datetime.datetime.now().isoformat()), + 'updated_at': kwargs.get('updated_at', datetime.datetime.now().isoformat()), + } + return fake_migration + + +FAKE_MIGRATIONS = [ + make_fake_migration(vm_name='test-vm-1', state='SCHEDULED'), + make_fake_migration(vm_name='test-vm-2', state='MIGRATED'), + make_fake_migration(vm_name='scheduled-vm', state='SCHEDULED', + scheduled_time='2025-10-07T22:00:00'), +] diff --git a/vmmsclient/tests/v2/test_client.py b/vmmsclient/tests/v2/test_client.py new file mode 100644 index 0000000..ac2b972 --- /dev/null +++ b/vmmsclient/tests/v2/test_client.py @@ -0,0 +1,278 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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 datetime +try: + from unittest import mock +except ImportError: + import mock +import testtools +import uuid + +from osc_lib import exceptions as osc_lib_exceptions + +from vmmsclient.tests import base +from vmmsclient.tests.v2.fakes import FAKE_MIGRATIONS, make_fake_migration +from vmmsclient.v2 import client + + +class TestClient(base.TestCase): + """Test cases for the Client class.""" + + def setUp(self): + super(TestClient, self).setUp() + self.client = client.Client( + session=mock.Mock(), + service_type='vmms', + interface='public' + ) + + def test_add_vm(self): + """Test adding a VM migration.""" + fake_migration = make_fake_migration() + + # Mock the post method to return our fake data + with mock.patch.object(self.client, 'post') as mock_post: + mock_response = mock.Mock() + mock_response.json.return_value = fake_migration + mock_post.return_value = mock_response + + result = self.client.add_vm( + vm_identifier='test-vm-id-or-name' + ) + + self.assertEqual(fake_migration, result) + # Check that post was called with correct arguments + expected_data = { + 'vm_identifier': 'test-vm-id-or-name' + } + mock_post.assert_called_once_with('/vms', json=expected_data) + + def test_add_vm_with_schedule_time(self): + """Test adding a VM migration with scheduled time.""" + fake_migration = make_fake_migration( + scheduled_time='2025-10-07T22:00:00' + ) + + # Mock the post method to return our fake data + with mock.patch.object(self.client, 'post') as mock_post: + mock_response = mock.Mock() + mock_response.json.return_value = fake_migration + mock_post.return_value = mock_response + + result = self.client.add_vm( + vm_identifier='test-vm-id-or-name', + scheduled_time=fake_migration['scheduled_time'] + ) + + self.assertEqual(fake_migration, result) + # Check that post was called with correct arguments + expected_data = { + 'vm_identifier': 'test-vm-id-or-name', + 'scheduled_time': fake_migration['scheduled_time'] + } + mock_post.assert_called_once_with('/vms', json=expected_data) + + def test_list_vms(self): + """Test listing VM migrations.""" + # Mock the get method to return our fake data + with mock.patch.object(self.client, 'get') as mock_get: + mock_response = mock.Mock() + mock_response.json.return_value = FAKE_MIGRATIONS + mock_get.return_value = mock_response + + result = self.client.list_vms() + + self.assertEqual(FAKE_MIGRATIONS, result) + mock_get.assert_called_once_with('/vms') + + def test_remove_vm(self): + """Test removing a VM migration.""" + migration_id = 'test-migration-id' + + # Mock the delete method to return success + with mock.patch.object(self.client, 'delete') as mock_delete: + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_delete.return_value = mock_response + + result = self.client.remove_vm(migration_id) + + self.assertTrue(result) + mock_delete.assert_called_once_with('/vms/{0}'.format(migration_id)) + + def test_remove_vm_not_found(self): + """Test removing a VM migration that doesn't exist.""" + migration_id = 'non-existent-id' + + # Mock the delete method to return 404 + with mock.patch.object(self.client, 'delete') as mock_delete: + mock_delete.side_effect = osc_lib_exceptions.NotFound('Migration not found (404).') + + # Expect NotFound to be raised for 404 + self.assertRaises( + osc_lib_exceptions.NotFound, + self.client.remove_vm, + migration_id + ) + + +class TestAddVMCommand(base.TestCommand): + """Test cases for AddVMCommand.""" + + def setUp(self): + super(TestAddVMCommand, self).setUp() + self.cmd = client.AddVMCommand(self.app, None) + + def test_get_parser(self): + """Test parser creation.""" + parser = self.cmd.get_parser('test') + parsed_args = parser.parse_args([ + 'test-vm-id-or-name' + ]) + self.assertEqual('test-vm-id-or-name', parsed_args.vm_identifier) + + def test_get_parser_with_schedule_time(self): + """Test parser creation with schedule time.""" + parser = self.cmd.get_parser('test') + parsed_args = parser.parse_args([ + 'test-vm-id-or-name', + '--schedule-time', '2025-10-07T22:00:00' + ]) + self.assertEqual('test-vm-id-or-name', parsed_args.vm_identifier) + self.assertEqual('2025-10-07T22:00:00', parsed_args.schedule_time) + + def test_take_action(self): + """Test taking action.""" + fake_migration = make_fake_migration() + self.app.client_manager.vmms.add_vm.return_value = fake_migration + + parsed_args = mock.Mock() + parsed_args.vm_identifier = 'test-vm-id-or-name' + parsed_args.schedule_time = None + + columns, data = self.cmd.take_action(parsed_args) + + self.app.client_manager.vmms.add_vm.assert_called_once_with( + vm_identifier='test-vm-id-or-name', + scheduled_time=None + ) + self.assertEqual(('id', 'vm_id', 'vm_name', 'scheduled_time', 'state', + 'workflow_exec', 'created_at', 'updated_at'), columns) + + +class TestListVMsCommand(base.TestCommand): + """Test cases for ListVMsCommand.""" + + def setUp(self): + super(TestListVMsCommand, self).setUp() + self.cmd = client.ListVMsCommand(self.app, None) + + def test_take_action(self): + """Test taking action.""" + self.app.client_manager.vmms.list_vms.return_value = FAKE_MIGRATIONS + + # Test without --state parameter + parsed_args = mock.Mock() + parsed_args.state = None + parsed_args.long = False + + columns, data = self.cmd.take_action(parsed_args) + + self.app.client_manager.vmms.list_vms.assert_called_once_with(state=None) + self.assertEqual(('id', 'vm_id', 'vm_name', 'scheduled_time', 'state', + 'workflow_exec'), columns) + self.assertEqual(len(FAKE_MIGRATIONS), len(list(data))) + + def test_take_action_with_state_filter(self): + """Test taking action with state filter.""" + self.app.client_manager.vmms.list_vms.return_value = FAKE_MIGRATIONS + + parsed_args = mock.Mock() + parsed_args.state = 'ERROR' + parsed_args.long = False + + columns, data = self.cmd.take_action(parsed_args) + + self.app.client_manager.vmms.list_vms.assert_called_once_with(state='ERROR') + self.assertEqual(('id', 'vm_id', 'vm_name', 'scheduled_time', 'state', + 'workflow_exec'), columns) + + def test_take_action_long_format(self): + """Test taking action with long format.""" + self.app.client_manager.vmms.list_vms.return_value = FAKE_MIGRATIONS + + parsed_args = mock.Mock() + parsed_args.state = None + parsed_args.long = True + + columns, data = self.cmd.take_action(parsed_args) + + self.app.client_manager.vmms.list_vms.assert_called_once_with(state=None) + self.assertEqual(('id', 'vm_id', 'vm_name', 'scheduled_time', 'state', + 'workflow_exec', 'created_at', 'updated_at'), columns) + self.assertEqual(len(FAKE_MIGRATIONS), len(list(data))) + + def test_get_parser(self): + """Test parser creation.""" + parser = self.cmd.get_parser('test') + # Test that parser accepts --state option + parsed_args = parser.parse_args(['--state', 'ERROR']) + self.assertEqual('ERROR', parsed_args.state) + + # Test that parser accepts --long option + parsed_args = parser.parse_args(['--long']) + self.assertTrue(parsed_args.long) + + +class TestRemoveVMCommand(base.TestCommand): + """Test cases for RemoveVMCommand.""" + + def setUp(self): + super(TestRemoveVMCommand, self).setUp() + self.cmd = client.RemoveVMCommand(self.app, None) + + def test_get_parser(self): + """Test parser creation.""" + parser = self.cmd.get_parser('test') + parsed_args = parser.parse_args(['test-migration-id']) + self.assertEqual('test-migration-id', parsed_args.migration_id) + + def test_take_action_success(self): + """Test successful removal.""" + self.app.client_manager.vmms.remove_vm.return_value = True + + parsed_args = mock.Mock() + parsed_args.migration_id = 'test-migration-id' + + self.cmd.take_action(parsed_args) + + self.app.client_manager.vmms.remove_vm.assert_called_once_with( + 'test-migration-id' + ) + + def test_take_action_failure(self): + """Test failed removal.""" + # Mock remove_vm to raise NotFound + self.app.client_manager.vmms.remove_vm.side_effect = osc_lib_exceptions.NotFound("Migration not found") + + parsed_args = mock.Mock() + parsed_args.migration_id = 'test-migration-id' + + self.assertRaises( + osc_lib_exceptions.CommandError, + self.cmd.take_action, + parsed_args + ) diff --git a/vmmsclient/tests/v2/test_plugin.py b/vmmsclient/tests/v2/test_plugin.py new file mode 100644 index 0000000..53443d7 --- /dev/null +++ b/vmmsclient/tests/v2/test_plugin.py @@ -0,0 +1,53 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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 argparse +try: + from unittest import mock +except ImportError: + import mock +import testtools + +from vmmsclient import plugin +from vmmsclient.tests import base + + +class TestPlugin(base.TestCase): + """Test cases for the plugin module.""" + + def test_make_client(self): + """Test client creation.""" + instance = mock.Mock() + instance.get_configuration.return_value = { + plugin.API_VERSION_OPTION: '2' + } + instance.session = mock.Mock() + instance.region_name = 'RegionOne' + instance.interface = 'public' + + client = plugin.make_client(instance) + + self.assertIsNotNone(client) + + def test_build_option_parser(self): + """Test option parser creation.""" + parser = argparse.ArgumentParser() + result_parser = plugin.build_option_parser(parser) + + self.assertEqual(parser, result_parser) + + # Test that the option is added + args = parser.parse_args([]) + self.assertTrue(hasattr(args, 'os_vmms_api_version')) diff --git a/vmmsclient/v2/__init__.py b/vmmsclient/v2/__init__.py new file mode 100644 index 0000000..a25a7f2 --- /dev/null +++ b/vmmsclient/v2/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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. diff --git a/vmmsclient/v2/client.py b/vmmsclient/v2/client.py new file mode 100644 index 0000000..9fce6bb --- /dev/null +++ b/vmmsclient/v2/client.py @@ -0,0 +1,382 @@ +# Copyright (c) 2025 Thomas Goirand +# +# 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 json +from urllib.parse import urljoin + +from keystoneauth1 import adapter +from osc_lib.cli import parseractions +from osc_lib.command import command +from osc_lib import exceptions +from osc_lib import utils + +# Common column definitions +COMMON_COLUMNS = ( + 'id', + 'vm_id', + 'vm_name', + 'scheduled_time', + 'state', + 'workflow_exec' +) + +LONG_COLUMNS = ( + 'id', + 'vm_id', + 'vm_name', + 'scheduled_time', + 'state', + 'workflow_exec', + 'created_at', + 'updated_at' +) + + +class Client(adapter.Adapter): + """Client for the VM Migration Scheduler API.""" + + def __init__(self, *args, **kwargs): + kwargs.setdefault('user_agent', 'python-vmmsclient') + kwargs.setdefault('version', '2') + super(Client, self).__init__(*args, **kwargs) + self.api_version = '2' + + def _get_url(self, path): + """Build URL for API requests.""" + # Ensure path starts with / + if not path.startswith('/'): + path = '/' + path + return path + + def add_vm(self, vm_identifier, scheduled_time=None): + """Add a VM to migration scheduler.""" + url = self._get_url('vms') + data = { + 'vm_identifier': vm_identifier + } + if scheduled_time: + data['scheduled_time'] = scheduled_time + + try: + resp = self.post(url, json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise self._handle_exception(e) + + def list_vms(self, state=None): + """List all VM migrations.""" + url = self._get_url('vms') + if state: + url += f'?state={state}' + try: + resp = self.get(url) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise self._handle_exception(e) + + def remove_vm(self, migration_id): + """Remove a VM from migration scheduler.""" + url = self._get_url('vms/{0}'.format(migration_id)) + try: + resp = self.delete(url) + resp.raise_for_status() + return True + except Exception as e: + raise self._handle_exception(e) + + def show_vm(self, migration_id): + """Show details of a specific VM migration.""" + url = self._get_url('vms/{0}'.format(migration_id)) + try: + resp = self.get(url) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise self._handle_exception(e) + + def update_vm(self, migration_id, scheduled_time=None, state=None): + """Update a VM migration.""" + url = self._get_url('vms/{0}'.format(migration_id)) + data = {} + if scheduled_time is not None: + data['scheduled_time'] = scheduled_time + if state is not None: + data['state'] = state + try: + resp = self.put(url, json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise self._handle_exception(e) + + def unset_vm(self, migration_id, scheduled_time=False): + """Unset VM migration properties.""" + url = self._get_url('vms/{0}'.format(migration_id)) + data = {} + if scheduled_time: + data['scheduled_time'] = None + try: + resp = self.put(url, json=data) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise self._handle_exception(e) + + def _is_404_error(self, e): + """Check if exception represents a 404 Not Found error.""" + # Check if it's already a NotFound exception + if isinstance(e, exceptions.NotFound): + return True + # Check if it's an HTTP error with 404 status code + if hasattr(e, 'response') and e.response is not None: + return getattr(e.response, 'status_code', None) == 404 + return False + + def _handle_exception(self, e): + """Handle exceptions and raise appropriate OSC exceptions.""" + # Handle 404 errors by raising NotFound exception + if self._is_404_error(e): + raise exceptions.NotFound('Migration not found (404).') + # For other errors, raise CommandError + raise exceptions.CommandError(self._format_exception_message(e)) + + def _format_exception_message(self, e): + """Format exception message for better user experience.""" + if hasattr(e, 'response') and e.response is not None: + try: + error_data = e.response.json() + if 'error' in error_data: + return error_data['error'] + except: + pass + return f"HTTP {e.response.status_code}: {e.response.reason}" + return str(e) + + def get_vm_output(self, migration_id): + """Get output from a VM migration. + + :param migration_id: ID of the migration + :returns: Dictionary containing stdout, stderr, and exit_code + """ + formatted_path = 'vms/{0}/output'.format(migration_id) + url = self._get_url(formatted_path) + try: + resp = self.get(url) + resp.raise_for_status() + return resp.json() + except Exception as e: + raise self._handle_exception(e) + + +class ShowVMCommand(command.ShowOne): + """Show details of a VM migration.""" + + def get_parser(self, prog_name): + parser = super(ShowVMCommand, self).get_parser(prog_name) + parser.add_argument( + 'migration_id', + metavar='<migration-id>', + help='Migration ID') + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + data = client.show_vm(parsed_args.migration_id) + + return COMMON_COLUMNS + ('created_at', 'updated_at'), utils.get_dict_properties(data, COMMON_COLUMNS + ('created_at', 'updated_at')) + + +class AddVMCommand(command.ShowOne): + """Add a VM to migration scheduler.""" + + def get_parser(self, prog_name): + parser = super(AddVMCommand, self).get_parser(prog_name) + parser.add_argument( + 'vm_identifier', + metavar='<vm-uuid-or-name>', + help='VM UUID or name') + parser.add_argument( + '--schedule-time', + metavar='<schedule-time>', + help='Scheduled migration time (ISO format or "now")') + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + + # Handle "now" keyword for schedule time + schedule_time = parsed_args.schedule_time + if schedule_time and schedule_time.lower() == 'now': + # Use current UTC time + import datetime + schedule_time = datetime.datetime.utcnow().isoformat() + 'Z' + + data = client.add_vm( + vm_identifier=parsed_args.vm_identifier, + scheduled_time=schedule_time) + + return COMMON_COLUMNS + ('created_at', 'updated_at'), utils.get_dict_properties(data, COMMON_COLUMNS + ('created_at', 'updated_at')) + + +class ListVMsCommand(command.Lister): + """List VM migrations.""" + + def get_parser(self, prog_name): + parser = super(ListVMsCommand, self).get_parser(prog_name) + parser.add_argument( + '--long', + action='store_true', + help='Show detailed information including created_at and updated_at' + ) + parser.add_argument( + '--state', + metavar='<state>', + choices=['SCHEDULED', 'MIGRATING', 'MIGRATED', 'ERROR'], + help='Filter by migration state' + ) + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + data = client.list_vms(state=parsed_args.state) + + columns = LONG_COLUMNS if parsed_args.long else COMMON_COLUMNS + return ( + columns, + (utils.get_dict_properties(s, columns) for s in data) + ) + + +class RemoveVMCommand(command.Command): + """Remove a VM from migration scheduler.""" + + def get_parser(self, prog_name): + parser = super(RemoveVMCommand, self).get_parser(prog_name) + parser.add_argument( + 'migration_id', + metavar='<migration-id>', + help='Migration ID') + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + try: + client.remove_vm(parsed_args.migration_id) + self.app.stdout.write( + 'Migration {0} removed successfully\n'.format( + parsed_args.migration_id)) + except exceptions.NotFound: + raise exceptions.CommandError( + 'Failed to remove migration {0}: not found'.format( + parsed_args.migration_id)) + + +class SetVMCommand(command.ShowOne): + """Set properties of a VM migration.""" + + def get_parser(self, prog_name): + parser = super(SetVMCommand, self).get_parser(prog_name) + parser.add_argument( + 'migration_id', + metavar='<migration-id>', + help='Migration ID') + parser.add_argument( + '--schedule-time', + metavar='<schedule-time>', + help='New scheduled migration time (ISO format or "now")') + parser.add_argument( + '--state', + metavar='<state>', + choices=['SCHEDULED', 'MIGRATING', 'MIGRATED', 'ERROR'], + help='New state (SCHEDULED, MIGRATING, MIGRATED, ERROR)') + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + + # Prepare data based on provided arguments + if not parsed_args.schedule_time and not parsed_args.state: + raise exceptions.CommandError('Either --schedule-time or --state must be specified') + + # Handle "now" keyword for schedule time + schedule_time = parsed_args.schedule_time + if schedule_time and schedule_time.lower() == 'now': + # Use current UTC time + import datetime + schedule_time = datetime.datetime.utcnow().isoformat() + 'Z' + + data = client.update_vm( + migration_id=parsed_args.migration_id, + scheduled_time=schedule_time, + state=parsed_args.state) + + return COMMON_COLUMNS + ('created_at', 'updated_at'), utils.get_dict_properties(data, COMMON_COLUMNS + ('created_at', 'updated_at')) + + +class UnsetVMCommand(command.ShowOne): + """Unset properties of a VM migration.""" + + def get_parser(self, prog_name): + parser = super(UnsetVMCommand, self).get_parser(prog_name) + parser.add_argument( + 'migration_id', + metavar='<migration-id>', + help='Migration ID') + parser.add_argument( + '--schedule-time', + action='store_true', + help='Unset scheduled migration time') + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + data = client.unset_vm( + migration_id=parsed_args.migration_id, + scheduled_time=parsed_args.schedule_time) + + return COMMON_COLUMNS + ('created_at', 'updated_at'), utils.get_dict_properties(data, COMMON_COLUMNS + ('created_at', 'updated_at')) + +class ShowVMOutputCommand(command.Command): + """Show output from VM migration.""" + + def get_parser(self, prog_name): + parser = super(ShowVMOutputCommand, self).get_parser(prog_name) + parser.add_argument( + 'migration_id', + metavar='<migration-id>', + help='Migration ID') + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.vmms + try: + data = client.get_vm_output(parsed_args.migration_id) + + # Show only stdout, equivalent to jq .stdout -r + if 'stdout' in data: + self.app.stdout.write(data['stdout']) + if not data['stdout'].endswith('\n'): + self.app.stdout.write('\n') + else: + self.app.stdout.write('\n') + + except exceptions.NotFound: + raise exceptions.CommandError( + f'Failed to get output for migration {parsed_args.migration_id}: not found') + except Exception as e: + raise exceptions.CommandError( + f'Failed to get output for migration {parsed_args.migration_id}: {str(e)}') |
