diff options
Diffstat (limited to 'vmmsclient/v2/client.py')
| -rw-r--r-- | vmmsclient/v2/client.py | 382 |
1 files changed, 382 insertions, 0 deletions
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)}') |
