diff --git a/blazarclient/command.py b/blazarclient/command.py index 951a7d1..0de9c3e 100644 --- a/blazarclient/command.py +++ b/blazarclient/command.py @@ -81,7 +81,12 @@ class BlazarCommand(OpenStackCommand): # self.formatters['table'] = TableFormatter() def get_client(self): - return self.app.client + # client_manager.reservation is used for osc_lib, and should be used + # if it exists + if hasattr(self.app, 'client_manager'): + return self.app.client_manager.reservation + else: + return self.app.client def get_parser(self, prog_name): parser = super(BlazarCommand, self).get_parser(prog_name) diff --git a/blazarclient/osc/__init__.py b/blazarclient/osc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blazarclient/osc/plugin.py b/blazarclient/osc/plugin.py new file mode 100644 index 0000000..7433849 --- /dev/null +++ b/blazarclient/osc/plugin.py @@ -0,0 +1,66 @@ +# 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 logging + +from osc_lib import utils + + +LOG = logging.getLogger(__name__) + +DEFAULT_API_VERSION = '1' + +# Required by the OSC plugin interface +API_NAME = 'reservation' +API_VERSION_OPTION = 'os_reservations_api_version' +API_VERSIONS = { + '1': 'blazarclient.v1.client.Client', +} + + +# Required by the OSC plugin interface +def make_client(instance): + reservation_client = utils.get_client_class( + API_NAME, + instance._api_version[API_NAME], + API_VERSIONS) + + LOG.debug("Instantiating reservation client: %s", reservation_client) + + client = reservation_client( + instance._api_version[API_NAME], + session=instance.session, + endpoint_override=instance.get_endpoint_for_service_type( + API_NAME, + interface=instance.interface, + region_name=instance._region_name) + ) + return client + + +# Required by the OSC plugin interface +def build_option_parser(parser): + """Hook to add global options. + Called from openstackclient.shell.OpenStackShell.__init__() + after the builtin parser has been initialized. This is + where a plugin can add global options such as an API version setting. + :param argparse.ArgumentParser parser: The parser object that has been + initialized by OpenStackShell. + """ + parser.add_argument( + "--os-reservation-api-version", + metavar="", + help="Reservation API version, default=" + "{} (Env: OS_RESERVATION_API_VERSION)".format( + DEFAULT_API_VERSION) + ) + return parser diff --git a/blazarclient/tests/test_command.py b/blazarclient/tests/test_command.py index f8091aa..0b79371 100644 --- a/blazarclient/tests/test_command.py +++ b/blazarclient/tests/test_command.py @@ -60,9 +60,19 @@ class BlazarCommandTestCase(tests.TestCase): self.command = command.BlazarCommand(self.app, []) def test_get_client(self): + # Test that either client_manager.reservation or client is used, + # whichever exists + + client_manager = self.app.client_manager + del self.app.client_manager client = self.command.get_client() self.assertEqual(self.app.client, client) + self.app.client_manager = client_manager + del self.app.client + client = self.command.get_client() + self.assertEqual(self.app.client_manager.reservation, client) + def test_get_parser(self): self.command.get_parser('TestCase') self.parser.assert_called_once_with('TestCase') diff --git a/blazarclient/tests/test_plugin.py b/blazarclient/tests/test_plugin.py new file mode 100644 index 0000000..f7e5551 --- /dev/null +++ b/blazarclient/tests/test_plugin.py @@ -0,0 +1,36 @@ +# 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. + +from unittest import mock + +from blazarclient.osc import plugin +from blazarclient import tests + + +class ReservationPluginTests(tests.TestCase): + + @mock.patch("blazarclient.v1.client.Client") + def test_make_client(self, mock_client): + instance = mock.Mock() + instance._api_version = {"reservation": "1"} + endpoint = "blazar_endpoint" + instance.get_endpoint_for_service_type = mock.Mock( + return_value=endpoint + ) + + plugin.make_client(instance) + + mock_client.assert_called_with( + "1", + session=instance.session, + endpoint_override=endpoint + ) diff --git a/blazarclient/v1/shell_commands/leases.py b/blazarclient/v1/shell_commands/leases.py index 9403586..9fe3e36 100644 --- a/blazarclient/v1/shell_commands/leases.py +++ b/blazarclient/v1/shell_commands/leases.py @@ -91,7 +91,7 @@ class ShowLease(command.ShowCommand): log = logging.getLogger(__name__ + '.ShowLease') -class CreateLease(command.CreateCommand): +class CreateLeaseBase(command.CreateCommand): """Create a lease.""" resource = 'lease' json_indent = 4 @@ -100,7 +100,7 @@ class CreateLease(command.CreateCommand): default_end = _utc_now() + datetime.timedelta(days=1) def get_parser(self, prog_name): - parser = super(CreateLease, self).get_parser(prog_name) + parser = super(CreateLeaseBase, self).get_parser(prog_name) parser.add_argument( 'name', metavar=self.resource.upper(), help='Name for the %s' % self.resource @@ -126,22 +126,6 @@ class CreateLease(command.CreateCommand): 'the end of the lease (default: depends on system default)', default=None ) - parser.add_argument( - '--physical-reservation', - metavar="", - action='append', - dest='physical_reservations', - help='Create a reservation for physical compute hosts. ' - 'Specify option multiple times to create multiple ' - 'reservations. ' - 'min: minimum number of hosts to reserve. ' - 'max: maximum number of hosts to reserve. ' - 'hypervisor_properties: JSON string, see doc. ' - 'resource_properties: JSON string, see doc. ' - 'before_end: JSON string, see doc. ', - default=[] - ) parser.add_argument( '--reservation', metavar="", @@ -165,36 +149,12 @@ class CreateLease(command.CreateCommand): return parser def args2body(self, parsed_args): - def parse_params(str_params, default): - request_params = {} - prog = re.compile('^(?:(.*),)?(%s)=(.*)$' - % "|".join(default.keys())) - - while str_params != "": - match = prog.search(str_params) - - if match is None: - raise exception.IncorrectLease(err_msg) - - self.log.info("Matches: %s", match.groups()) - k, v = match.group(2, 3) - if k in request_params.keys(): - raise exception.DuplicatedLeaseParameters(err_msg) - else: - if strutils.is_int_like(v): - request_params[k] = int(v) - elif isinstance(defaults[k], list): - request_params[k] = jsonutils.loads(v) - else: - request_params[k] = v - - str_params = match.group(1) if match.group(1) else "" - - request_params.update({k: v for k, v in default.items() - if k not in request_params.keys() and - v is not None}) - return request_params + params = self._generate_params(parsed_args) + if not params['reservations']: + raise exception.IncorrectLease + return params + def _generate_params(self, parsed_args): params = {} if parsed_args.name: params['name'] = parsed_args.name @@ -243,6 +203,115 @@ class CreateLease(command.CreateCommand): params['reservations'] = [] params['events'] = [] + reservations = [] + for res_str in parsed_args.reservations: + err_msg = ("Invalid reservation argument '%s'. " + "Reservation arguments must be of the " + "form --reservation " + % res_str) + + if "physical:host" in res_str: + defaults = CREATE_RESERVATION_KEYS['physical:host'] + elif "virtual:instance" in res_str: + defaults = CREATE_RESERVATION_KEYS['virtual:instance'] + elif "virtual:floatingip" in res_str: + defaults = CREATE_RESERVATION_KEYS['virtual:floatingip'] + else: + defaults = CREATE_RESERVATION_KEYS['others'] + + res_info = self._parse_params(res_str, defaults, err_msg) + reservations.append(res_info) + + if reservations: + params['reservations'] += reservations + + events = [] + for event_str in parsed_args.events: + err_msg = ("Invalid event argument '%s'. " + "Event arguments must be of the " + "form --event " + % event_str) + event_info = {"event_type": "", "event_date": ""} + for kv_str in event_str.split(","): + try: + k, v = kv_str.split("=", 1) + except ValueError: + raise exception.IncorrectLease(err_msg) + if k in event_info: + event_info[k] = v + else: + raise exception.IncorrectLease(err_msg) + if not event_info['event_type'] and not event_info['event_date']: + raise exception.IncorrectLease(err_msg) + event_date = event_info['event_date'] + try: + date = datetime.datetime.strptime(event_date, '%Y-%m-%d %H:%M') + event_date = datetime.datetime.strftime(date, '%Y-%m-%d %H:%M') + event_info['event_date'] = event_date + except ValueError: + raise exception.IncorrectLease + events.append(event_info) + if events: + params['events'] = events + + return params + + def _parse_params(self, str_params, default, err_msg): + request_params = {} + prog = re.compile('^(?:(.*),)?(%s)=(.*)$' + % "|".join(default.keys())) + + while str_params != "": + match = prog.search(str_params) + + if match is None: + raise exception.IncorrectLease(err_msg) + + self.log.info("Matches: %s", match.groups()) + k, v = match.group(2, 3) + if k in request_params.keys(): + raise exception.DuplicatedLeaseParameters(err_msg) + else: + if strutils.is_int_like(v): + request_params[k] = int(v) + elif isinstance(default[k], list): + request_params[k] = jsonutils.loads(v) + else: + request_params[k] = v + + str_params = match.group(1) if match.group(1) else "" + + request_params.update({k: v for k, v in default.items() + if k not in request_params.keys() and + v is not None}) + return request_params + + +class CreateLease(CreateLeaseBase): + + def get_parser(self, prog_name): + parser = super(CreateLease, self).get_parser(prog_name) + parser.add_argument( + '--physical-reservation', + metavar="", + action='append', + dest='physical_reservations', + help='Create a reservation for physical compute hosts. ' + 'Specify option multiple times to create multiple ' + 'reservations. ' + 'min: minimum number of hosts to reserve. ' + 'max: maximum number of hosts to reserve. ' + 'hypervisor_properties: JSON string, see doc. ' + 'resource_properties: JSON string, see doc. ' + 'before_end: JSON string, see doc. ', + default=[] + ) + return parser + + def args2body(self, parsed_args): + params = self._generate_params(parsed_args) + physical_reservations = [] for phys_res_str in parsed_args.physical_reservations: err_msg = ("Invalid physical-reservation argument '%s'. " @@ -252,7 +321,7 @@ class CreateLease(command.CreateCommand): "before_end=str>" % phys_res_str) defaults = CREATE_RESERVATION_KEYS["physical:host"] - phys_res_info = parse_params(phys_res_str, defaults) + phys_res_info = self._parse_params(phys_res_str, defaults, err_msg) if not (phys_res_info['min'] and phys_res_info['max']): raise exception.IncorrectLease(err_msg) @@ -284,61 +353,10 @@ class CreateLease(command.CreateCommand): phys_res_info['resource_type'] = 'physical:host' physical_reservations.append(phys_res_info) if physical_reservations: - params['reservations'] += physical_reservations - - reservations = [] - for res_str in parsed_args.reservations: - err_msg = ("Invalid reservation argument '%s'. " - "Reservation arguments must be of the " - "form --reservation " - % res_str) - - if "physical:host" in res_str: - defaults = CREATE_RESERVATION_KEYS['physical:host'] - elif "virtual:instance" in res_str: - defaults = CREATE_RESERVATION_KEYS['virtual:instance'] - elif "virtual:floatingip" in res_str: - defaults = CREATE_RESERVATION_KEYS['virtual:floatingip'] - else: - defaults = CREATE_RESERVATION_KEYS['others'] - - res_info = parse_params(res_str, defaults) - reservations.append(res_info) - - if reservations: - params['reservations'] += reservations - - if not params['reservations']: - raise exception.IncorrectLease - - events = [] - for event_str in parsed_args.events: - err_msg = ("Invalid event argument '%s'. " - "Event arguments must be of the " - "form --event " - % event_str) - event_info = {"event_type": "", "event_date": ""} - for kv_str in event_str.split(","): - try: - k, v = kv_str.split("=", 1) - except ValueError: - raise exception.IncorrectLease(err_msg) - if k in event_info: - event_info[k] = v - else: - raise exception.IncorrectLease(err_msg) - if not event_info['event_type'] and not event_info['event_date']: - raise exception.IncorrectLease(err_msg) - event_date = event_info['event_date'] - try: - date = datetime.datetime.strptime(event_date, '%Y-%m-%d %H:%M') - event_date = datetime.datetime.strftime(date, '%Y-%m-%d %H:%M') - event_info['event_date'] = event_date - except ValueError: - raise exception.IncorrectLease - events.append(event_info) - if events: - params['events'] = events + # We prepend the physical_reservations to preserve legacy order + # of reservations + params['reservations'] = physical_reservations \ + + params['reservations'] return params diff --git a/lower-constraints.txt b/lower-constraints.txt index 0da54d6..f33db55 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -15,6 +15,7 @@ msgpack-python==0.4.0 netaddr==0.7.18 netifaces==0.10.4 os-client-config==1.28.0 +osc-lib==1.3.0 oslo.config==5.2.0 oslo.context==2.19.2 oslo.i18n==3.15.3 diff --git a/releasenotes/notes/openstackclient-support-f591eef2dc3c1a8b.yaml b/releasenotes/notes/openstackclient-support-f591eef2dc3c1a8b.yaml new file mode 100644 index 0000000..93fa946 --- /dev/null +++ b/releasenotes/notes/openstackclient-support-f591eef2dc3c1a8b.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Add openstackclient plugin support, enabling blazar commands to be used + within the openstack CLI. diff --git a/requirements.txt b/requirements.txt index 9205671..a091625 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ oslo.i18n>=3.15.3 # Apache-2.0 oslo.log>=3.36.0 # Apache-2.0 oslo.utils>=3.33.0 # Apache-2.0 keystoneauth1>=3.4.0 # Apache-2.0 +osc-lib>=1.3.0 # Apache-2.0 diff --git a/setup.cfg b/setup.cfg index a7e9a1a..c9e6364 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,3 +30,22 @@ packages = [entry_points] console_scripts = blazar = blazarclient.shell:main + +openstack.cli.extension = + reservation = blazarclient.osc.plugin + +openstack.reservation.v1 = + reservation_floatingip_create = blazarclient.v1.shell_commands.floatingips:CreateFloatingIP + reservation_floatingip_delete = blazarclient.v1.shell_commands.floatingips:DeleteFloatingIP + reservation_floatingip_list = blazarclient.v1.shell_commands.floatingips:ListFloatingIPs + reservation_floatingip_show = blazarclient.v1.shell_commands.floatingips:ShowFloatingIP + reservation_host_create = blazarclient.v1.shell_commands.hosts:CreateHost + reservation_host_delete = blazarclient.v1.shell_commands.hosts:DeleteHost + reservation_host_list = blazarclient.v1.shell_commands.hosts:ListHosts + reservation_host_set = blazarclient.v1.shell_commands.hosts:UpdateHost + reservation_host_show = blazarclient.v1.shell_commands.hosts:ShowHost + reservation_lease_create = blazarclient.v1.shell_commands.leases:CreateLeaseBase + reservation_lease_delete = blazarclient.v1.shell_commands.leases:DeleteLease + reservation_lease_list = blazarclient.v1.shell_commands.leases:ListLeases + reservation_lease_set = blazarclient.v1.shell_commands.leases:UpdateLease + reservation_lease_show = blazarclient.v1.shell_commands.leases:ShowLease