diff options
| -rw-r--r-- | .stestr.conf | 3 | ||||
| -rw-r--r-- | README.rst | 111 | ||||
| -rw-r--r-- | debian/changelog | 19 | ||||
| -rw-r--r-- | debian/control | 49 | ||||
| -rw-r--r-- | debian/copyright | 23 | ||||
| -rw-r--r-- | debian/python3-vmmsclient.install | 1 | ||||
| -rw-r--r-- | debian/python3-vmmsclient.postinst | 17 | ||||
| -rwxr-xr-x | debian/rules | 25 | ||||
| -rw-r--r-- | debian/source/format | 1 | ||||
| -rw-r--r-- | debian/source/options | 1 | ||||
| -rw-r--r-- | debian/tests/control | 7 | ||||
| -rw-r--r-- | debian/tests/unittests | 5 | ||||
| -rw-r--r-- | debian/watch | 3 | ||||
| -rw-r--r-- | requirements.txt | 6 | ||||
| -rw-r--r-- | setup.cfg | 37 | ||||
| -rw-r--r-- | setup.py | 8 | ||||
| -rw-r--r-- | test-requirements.txt | 6 | ||||
| -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 |
27 files changed, 1245 insertions, 0 deletions
diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..6f18a58 --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./vmmsclient/tests +top_dir=./ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..864398a --- /dev/null +++ b/README.rst @@ -0,0 +1,111 @@ +===================== +python-vmmsclient +===================== + +Python client library and CLI plugin for the VM Migration Scheduler (VMMS) service. + +Description +----------- + +This package provides a Python client library and OpenStack CLI plugin for +interacting with the VM Migration Scheduler API. It allows users to schedule, +list, and manage virtual machine migrations through command-line interfaces +or programmatically. + +Installation +------------ + +Install using pip:: + + pip install python-vmmsclient + +Prerequisites +------------- + +- OpenStack environment with VMMS service deployed +- Valid OpenStack credentials +- python-openstackclient installed + +CLI Usage +--------- + +Once installed, the vmms commands are available through the openstack CLI: + +Add a VM for immediate migration:: + + openstack vmms vm add <vm-id> <vm-name> + +Add a VM for scheduled migration:: + + openstack vmms vm add <vm-id> <vm-name> --schedule-time <ISO-timestamp> + +List all VM migrations:: + + openstack vmms vm list + +Remove a VM migration:: + + openstack vmms vm remove <migration-id> + +Examples +-------- + +Add a VM for immediate migration:: + + openstack vmms vm add 3d99ca07-92a2-4ff6-a0dc-2012adb57083 "my-test-vm" + +Add a VM for scheduled migration:: + + openstack vmms vm add 3d99ca07-92a2-4ff6-a0dc-2012adb57083 "scheduled-vm" \ + --schedule-time "2025-10-07T22:00:00Z" + +List all migrations:: + + openstack vmms vm list + +Remove a migration:: + + openstack vmms vm remove 123e4567-e89b-12d3-a456-426614174000 + +API Usage +--------- + +The client can also be used programmatically:: + + from keystoneauth1.identity import v3 + from keystoneauth1 import session + from vmmsclient.v2.client import Client + + # Create keystone session + auth = v3.Password(auth_url='http://keystone:5000/v3', + username='admin', + password='password', + project_name='admin', + user_domain_name='Default', + project_domain_name='Default') + sess = session.Session(auth=auth) + + # Create VMMS client + client = Client(session=sess) + + # Add a VM migration + migration = client.add_vm('vm-id', 'vm-name') + + # List migrations + migrations = client.list_vms() + + # Remove a migration + client.remove_vm('migration-id') + +Development +----------- + +Running tests:: + + pip install -r test-requirements.txt + stestr run + +License +------- + +Apache License, Version 2.0 diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..c132b06 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,19 @@ +python-vmmsclient (0.0.3) unstable; urgency=medium + + * Fix copyright holder on all files. + * rm debian/salsa-ci.yml. + + -- Thomas Goirand <zigo@debian.org> Tue, 11 Nov 2025 14:06:17 +0100 + +python-vmmsclient (0.0.2) unstable; urgency=medium + + * Lower pbr version in requirements.txt. + * Add OpenInfra Foundation in d/copyright. + + -- Thomas Goirand <zigo@debian.org> Wed, 29 Oct 2025 11:20:08 +0100 + +python-vmmsclient (0.0.1) unstable; urgency=medium + + * Initial package (Closes: #1119311). + + -- Thomas Goirand <zigo@debian.org> Sun, 28 Sep 2025 23:10:00 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..239efc1 --- /dev/null +++ b/debian/control @@ -0,0 +1,49 @@ +Source: python-vmmsclient +Section: python +Priority: optional +Maintainer: Debian OpenStack <team+openstack@tracker.debian.org> +Uploaders: + Thomas Goirand <zigo@debian.org>, +Build-Depends: + debhelper-compat (= 11), + dh-python, + openstack-pkg-tools, + python3-all, + python3-pbr, + python3-setuptools, +Build-Depends-Indep: + python3-coverage, + python3-fixtures, + python3-keystoneauth1, + python3-mistralclient, + python3-osc-lib, + python3-openstackclient, + python3-requests, + python3-requests-mock, + python3-stestr, + python3-subunit, + python3-testtools, + subunit, +Standards-Version: 4.7.2 +Vcs-Browser: https://salsa.debian.org/openstack-team/clients/python-vmmsclient +Vcs-Git: https://salsa.debian.org/openstack-team/clients/python-vmmsclient.git +Homepage: https://salsa.debian.org/openstack-team/clients/python-vmmsclient + +Package: python3-vmmsclient +Architecture: all +Depends: + python3-keystoneauth1, + python3-mistralclient, + python3-osc-lib, + python3-openstackclient, + python3-pbr, + python3-requests, + ${misc:Depends}, + ${python3:Depends}, +Description: OpenStack VM migration scheduler - API client + VM Migration Scheduler (aka: vmms) is an OpenStack project that makes it + possible to schedule the migraiton of VMs from one cloud to another. Its API + allows one to add, list, delete VMs in the Mistral workflow scheduler, while + the worker is able to schedule VM migrations during a time window. + . + This package provides the API client. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..21e4c21 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,23 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: python-vmmsclient +Source: https://salsa.debian.org/openstack-team/clients/python-vmmsclient + +Files: * +Copyright: (c) 2025, Thomas Goirand <zigo@debian.org> +License: Apache-2 + +License: Apache-2 + 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. + . + On Debian-based systems the full text of the Apache version 2.0 license + can be found in /usr/share/common-licenses/Apache-2.0. diff --git a/debian/python3-vmmsclient.install b/debian/python3-vmmsclient.install new file mode 100644 index 0000000..74e4e23 --- /dev/null +++ b/debian/python3-vmmsclient.install @@ -0,0 +1 @@ +/usr diff --git a/debian/python3-vmmsclient.postinst b/debian/python3-vmmsclient.postinst new file mode 100644 index 0000000..16d884f --- /dev/null +++ b/debian/python3-vmmsclient.postinst @@ -0,0 +1,17 @@ +#!/bin/bash + +set -e + +if [ "$1" = "configure" ] || [ "$1" = "upgrade" ]; then + # Clear Python bytecode cache + find /usr/lib/python3/dist-packages/vmmsclient -name "*.pyc" -delete 2>/dev/null || true + find /usr/lib/python3/dist-packages/vmmsclient -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + + # Force Python to regenerate entry point cache + python3 -c "import importlib; import sys; + [sys.modules.pop(key) for key in list(sys.modules.keys()) if key.startswith('vmmsclient')];" 2>/dev/null || true +fi + +#DEBHELPER# + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..be5d71b --- /dev/null +++ b/debian/rules @@ -0,0 +1,25 @@ +#!/usr/bin/make -f + +include /usr/share/openstack-pkg-tools/pkgos.make + +%: + dh $@ --buildsystem=pybuild --with python3 + +override_dh_auto_clean: + rm -rf build .stestr *.egg-info + find . -iname '*.pyc' -delete + for i in $$(find . -type d -iname __pycache__) ; do rm -rf $$i ; done + +override_dh_auto_build: + echo "Do nothing..." + +override_dh_auto_install: + for i in $(PYTHON3S) ; do \ + python3 setup.py install -f --install-layout=deb --root=$(CURDIR)/debian/tmp ; \ + done +ifeq (,$(findstring nocheck, $(DEB_BUILD_OPTIONS))) + PYTHONPATH=$(CURDIR)/debian/tmp/usr/lib/python3/dist-packages pkgos-dh_auto_test --no-py2 'vmmsclient.tests.*' +endif + +override_dh_auto_test: + echo "Do nothing..." diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/debian/source/options b/debian/source/options new file mode 100644 index 0000000..cb61fa5 --- /dev/null +++ b/debian/source/options @@ -0,0 +1 @@ +extend-diff-ignore = "^[^/]*[.]egg-info/" diff --git a/debian/tests/control b/debian/tests/control new file mode 100644 index 0000000..6211b87 --- /dev/null +++ b/debian/tests/control @@ -0,0 +1,7 @@ +Tests: + unittests, +Depends: + @, + @builddeps@, +Restrictions: + allow-stderr needs-root, diff --git a/debian/tests/unittests b/debian/tests/unittests new file mode 100644 index 0000000..921605c --- /dev/null +++ b/debian/tests/unittests @@ -0,0 +1,5 @@ +#!/bin/sh + +set -e + +pkgos-dh_auto_test --no-py2 'vmmsclient.tests.*' diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..f6139d0 --- /dev/null +++ b/debian/watch @@ -0,0 +1,3 @@ +version=4 +opts="mode=git,uversionmangle=s/\.0rc/~rc/;s/\.0b1/~b1/;s/\.0b2/~b2/;s/\.0b3/~b3/" \ +https://salsa.debian.org/openstack-team/clients/python-vmmsclient refs/tags/(\d[brc\d\.]+) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..412faf4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +pbr>=2.0.0 +osc-lib>=2.2.1 +python-mistralclient>=4.1.1 +python-openstackclient>=5.0.0 +keystoneauth1>=4.5.0 +requests>=2.25.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..53273e9 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,37 @@ +[metadata] +name = python-vmmsclient +summary = Client library for VM Migration Scheduler API +description-file = README.rst +author = OpenStack +author-email = openstack-discuss@lists.openstack.org +home-page = https://www.openstack.org/ +classifier = + Environment :: OpenStack + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + +[files] +packages = + vmmsclient + +[entry_points] +openstack.cli.extension = + vmms = vmmsclient.plugin + +openstack.vmms.v2 = + vmms_vm_add = vmmsclient.v2.client:AddVMCommand + vmms_vm_list = vmmsclient.v2.client:ListVMsCommand + vmms_vm_remove = vmmsclient.v2.client:RemoveVMCommand + vmms_vm_show = vmmsclient.v2.client:ShowVMCommand + vmms_vm_set = vmmsclient.v2.client:SetVMCommand + vmms_vm_unset = vmmsclient.v2.client:UnsetVMCommand + vmms_vm_output = vmmsclient.v2.client:ShowVMOutputCommand diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c32e31b --- /dev/null +++ b/setup.py @@ -0,0 +1,8 @@ +#!/usr/bin/python3 + +from setuptools import setup + +setup( + setup_requires=['pbr>=2.0.0'], + pbr=True, +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..4d73466 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,6 @@ +coverage>=4.5.1 +fixtures>=3.0.0 +python-subunit>=1.0.0 +stestr>=2.0.0 +testtools>=2.2.0 +requests-mock>=1.4.0 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)}') |
