summaryrefslogtreecommitdiff
diff options
-rw-r--r--.stestr.conf3
-rw-r--r--README.rst111
-rw-r--r--debian/changelog19
-rw-r--r--debian/control49
-rw-r--r--debian/copyright23
-rw-r--r--debian/python3-vmmsclient.install1
-rw-r--r--debian/python3-vmmsclient.postinst17
-rwxr-xr-xdebian/rules25
-rw-r--r--debian/source/format1
-rw-r--r--debian/source/options1
-rw-r--r--debian/tests/control7
-rw-r--r--debian/tests/unittests5
-rw-r--r--debian/watch3
-rw-r--r--requirements.txt6
-rw-r--r--setup.cfg37
-rw-r--r--setup.py8
-rw-r--r--test-requirements.txt6
-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
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)}')