summaryrefslogtreecommitdiff
path: root/vmmsclient
diff options
context:
space:
mode:
Diffstat (limited to 'vmmsclient')
-rw-r--r--vmmsclient/__init__.py14
-rw-r--r--vmmsclient/plugin.py66
-rw-r--r--vmmsclient/tests/__init__.py14
-rw-r--r--vmmsclient/tests/base.py48
-rw-r--r--vmmsclient/tests/v2/__init__.py14
-rw-r--r--vmmsclient/tests/v2/fakes.py40
-rw-r--r--vmmsclient/tests/v2/test_client.py278
-rw-r--r--vmmsclient/tests/v2/test_plugin.py53
-rw-r--r--vmmsclient/v2/__init__.py14
-rw-r--r--vmmsclient/v2/client.py382
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)}')