From b63e2f1e806715be4ef492ebf50b743857a7f789 Mon Sep 17 00:00:00 2001 From: Gonzalo Rafuls Date: Mon, 30 Sep 2024 16:21:40 +0200 Subject: [PATCH] fix: find-free-cloud and cc_users on mod-cloud Related-to: https://github.com/redhat-performance/quads/issues/500 Change-Id: Ibdcd5a1103d061878341a286f802159cccd2ddce --- src/quads/cli/cli.py | 430 +++++++++++++++------ src/quads/quads_api.py | 38 +- src/quads/server/blueprints/assignments.py | 8 +- src/quads/server/blueprints/clouds.py | 45 +-- src/quads/server/dao/cloud.py | 30 +- src/quads/server/swagger.yaml | 37 +- tests/api/test_clouds.py | 34 +- tests/cli/test_cloud.py | 22 +- tests/cli/test_schedule.py | 98 +++-- tests/cli/test_validate_env.py | 5 +- 10 files changed, 516 insertions(+), 231 deletions(-) diff --git a/src/quads/cli/cli.py b/src/quads/cli/cli.py index 5e97c7275..299b38879 100644 --- a/src/quads/cli/cli.py +++ b/src/quads/cli/cli.py @@ -7,16 +7,17 @@ from datetime import datetime, timedelta from json import JSONDecodeError from tempfile import NamedTemporaryFile -from typing import Tuple, Optional +from typing import Optional, Tuple import yaml from jinja2 import Template from requests import ConnectionError from quads.config import Config as conf -from quads.exceptions import CliException, BaseQuadsException +from quads.exceptions import BaseQuadsException, CliException from quads.helpers.utils import first_day_month, last_day_month -from quads.quads_api import QuadsApi as Quads, APIServerException, APIBadRequest +from quads.quads_api import APIBadRequest, APIServerException +from quads.quads_api import QuadsApi as Quads from quads.server.models import Assignment from quads.tools import reports from quads.tools.external.jira import Jira, JiraException @@ -175,7 +176,9 @@ def _filter_kwargs(self, filter_args): else: if keys[0].strip().lower() == "model": if str(value).upper() not in conf["models"].split(","): - self.logger.warning(f"Accepted model names are: {conf['models']}") + self.logger.warning( + f"Accepted model names are: {conf['models']}" + ) raise CliException("Model type not recognized.") if type(value) == str: @@ -186,9 +189,13 @@ def _filter_kwargs(self, filter_args): if not op_found: self.logger.warning(f"Condition: {condition}") self.logger.warning(f"Accepted operators: {', '.join(ops.keys())}") - raise CliException("A filter was defined but not parsed correctly. Check filter operator.") + raise CliException( + "A filter was defined but not parsed correctly. Check filter operator." + ) if not kwargs: # pragma: no cover - raise CliException("A filter was defined but not parsed correctly. Check filter syntax.") + raise CliException( + "A filter was defined but not parsed correctly. Check filter syntax." + ) return kwargs def _output_json_result(self, request, data): @@ -197,7 +204,9 @@ def _output_json_result(self, request, data): self.logger.info("Successfully removed") else: js = request.json() - self.logger.debug("%s %s: %s" % (request.status_code, request.reason, data)) + self.logger.debug( + "%s %s: %s" % (request.status_code, request.reason, data) + ) if request.request.method == "POST" and request.status_code == 200: self.logger.info("Successful request") if js.get("result"): @@ -262,7 +271,9 @@ def action_ccuser(self): def action_interface(self): hostname = self.cli_args.get("host") if not hostname: - raise CliException("Missing option. --host option is required for --ls-interface.") + raise CliException( + "Missing option. --host option is required for --ls-interface." + ) try: self.quads.get_host(hostname) @@ -288,7 +299,9 @@ def action_interface(self): def action_memory(self): hostname = self.cli_args.get("host") if hostname is None: - raise CliException("Missing option. --host option is required for --ls-memory.") + raise CliException( + "Missing option. --host option is required for --ls-memory." + ) try: host = self.quads.get_host(hostname) @@ -305,7 +318,9 @@ def action_memory(self): def action_disks(self): hostname = self.cli_args.get("host") if hostname is None: - raise CliException("Missing option. --host option is required for --ls-disks.") + raise CliException( + "Missing option. --host option is required for --ls-disks." + ) try: host = self.quads.get_host(hostname) @@ -324,7 +339,9 @@ def action_disks(self): def action_processors(self): hostname = self.cli_args.get("host") if not hostname: - raise CliException("Missing option. --host option is required for --ls-processors.") + raise CliException( + "Missing option. --host option is required for --ls-processors." + ) try: host = self.quads.get_host(hostname) @@ -392,7 +409,9 @@ def action_schedule(self): _cloud_name = schedule.assignment.cloud.name start = ":".join(schedule.start.isoformat().split(":")[:-1]) end = ":".join(schedule.end.isoformat().split(":")[:-1]) - self.logger.info(f"{schedule.id}| start={start}, end={end}, cloud={_cloud_name}") + self.logger.info( + f"{schedule.id}| start={start}, end={end}, cloud={_cloud_name}" + ) else: try: _clouds = self.quads.get_clouds() @@ -403,7 +422,9 @@ def action_schedule(self): _kwargs["cloud"] = cloud.name if cloud.name == conf.get("spare_pool_name"): if self.cli_args.get("datearg"): - _date = datetime.strptime(self.cli_args.get("datearg"), "%Y-%m-%d %H:%M") + _date = datetime.strptime( + self.cli_args.get("datearg"), "%Y-%m-%d %H:%M" + ) _date_iso = ":".join(_date.isoformat().split(":")[:-1]) data = { "start": _date_iso, @@ -463,32 +484,25 @@ def action_ls_clouds(self): def action_free_cloud(self): try: - _clouds = self.quads.get_clouds() + _clouds = self.quads.get_free_clouds() except (APIServerException, APIBadRequest) as ex: raise CliException(str(ex)) - _clouds = [_c for _c in _clouds if _c.name != "cloud01"] for cloud in _clouds: - try: - _future_sched = self.quads.get_future_schedules({"cloud": cloud.name}) - _active_ass = self.quads.get_active_cloud_assignment(cloud.name) - except (APIServerException, APIBadRequest) as ex: - raise CliException(str(ex)) - if len(_future_sched) or _active_ass: - continue - else: - cloud_reservation_lock = int(conf["cloud_reservation_lock"]) - last_redefined = datetime.strptime(str(cloud.last_redefined), "%a, %d %b %Y %H:%M:%S %Z") - lock_release = last_redefined + timedelta(hours=cloud_reservation_lock) - cloud_string = f"{cloud.name}" - if lock_release > datetime.now(): - time_left = lock_release - datetime.now() - hours = time_left.total_seconds() // 3600 - minutes = (time_left.total_seconds() % 3600) // 60 - cloud_string += " (reserved: %dhr %dmin remaining)" % ( - hours, - minutes, - ) - self.logger.info(cloud_string) + cloud_reservation_lock = int(conf["cloud_reservation_lock"]) + last_redefined = datetime.strptime( + str(cloud.last_redefined), "%a, %d %b %Y %H:%M:%S %Z" + ) + lock_release = last_redefined + timedelta(hours=cloud_reservation_lock) + cloud_string = f"{cloud.name}" + if lock_release > datetime.now(): + time_left = lock_release - datetime.now() + hours = time_left.total_seconds() // 3600 + minutes = (time_left.total_seconds() % 3600) // 60 + cloud_string += " (reserved: %dhr %dmin remaining)" % ( + hours, + minutes, + ) + self.logger.info(cloud_string) def action_available(self): kwargs = {} @@ -496,7 +510,9 @@ def action_available(self): _filter = self.cli_args.get("filter") _schedstart = self.cli_args.get("schedstart") _schedend = self.cli_args.get("schedend") - _start = _end = "T".join(":".join(datetime.now().isoformat().split(":")[:-1]).split()) + _start = _end = "T".join( + ":".join(datetime.now().isoformat().split(":")[:-1]).split() + ) if _filter: filter_args = self._filter_kwargs(_filter) @@ -532,11 +548,14 @@ def action_available(self): # TODO: check return on this below try: if self.quads.is_available(host.name, data): - current_schedule = self.quads.get_current_schedules({"host": host.name}) + current_schedule = self.quads.get_current_schedules( + {"host": host.name} + ) if current_schedule: if ( host.default_cloud.name == conf["spare_pool_name"] - and current_schedule[0].assignment.cloud.name != omit_cloud_arg + and current_schedule[0].assignment.cloud.name + != omit_cloud_arg ): current.append(host.name) else: @@ -606,7 +625,9 @@ def action_extend(self): raise CliException(msg) if not cloud_name and not host_name: - msg = "Missing option. At least one of either --host or --cloud is required." + msg = ( + "Missing option. At least one of either --host or --cloud is required." + ) raise CliException(msg) if weeks: @@ -624,13 +645,16 @@ def action_extend(self): schedules = self.quads.get_current_schedules(data_dispatch) if not schedules: - self.logger.warning(f"The selected {dispatch_key} does not have any active schedules") + self.logger.warning( + f"The selected {dispatch_key} does not have any active schedules" + ) future_schedules = self.quads.get_future_schedules(data_dispatch) if not future_schedules: return if not self._confirmation_dialog( - "Would you like to extend a future allocation of " f"{data_dispatch[dispatch_key]}? (y/N): " + "Would you like to extend a future allocation of " + f"{data_dispatch[dispatch_key]}? (y/N): " ): return schedules = future_schedules @@ -639,7 +663,11 @@ def action_extend(self): non_extendable = [] for schedule in schedules: - end_date = schedule.end + timedelta(weeks=weeks) if weeks else datetime.strptime(date_arg, "%Y-%m-%d %H:%M") + end_date = ( + schedule.end + timedelta(weeks=weeks) + if weeks + else datetime.strptime(date_arg, "%Y-%m-%d %H:%M") + ) data = { "start": ":".join(schedule.end.isoformat().split(":")[:-1]), "end": ":".join(end_date.isoformat().split(":")[:-1]), @@ -708,10 +736,14 @@ def action_shrink(self): end_date = None if not weeks and not now and not date_arg: - raise CliException("Missing option. Need --weeks, --date or --now when using --shrink") + raise CliException( + "Missing option. Need --weeks, --date or --now when using --shrink" + ) if not cloud_name and not host_name: - raise CliException("Missing option. At least one of either --host or --cloud is required") + raise CliException( + "Missing option. At least one of either --host or --cloud is required" + ) if weeks: try: @@ -738,13 +770,16 @@ def action_shrink(self): schedules = self.quads.get_current_schedules(data_dispatch) if not schedules: - self.logger.error(f"The selected {dispatch_key} does not have any active schedules") + self.logger.error( + f"The selected {dispatch_key} does not have any active schedules" + ) future_schedules = self.quads.get_future_schedules(data_dispatch) if not future_schedules: return if not self._confirmation_dialog( - "Would you like to shrink a future allocation of" f" {data_dispatch[dispatch_key]}? (y/N): " + "Would you like to shrink a future allocation of" + f" {data_dispatch[dispatch_key]}? (y/N): " ): return schedules = future_schedules @@ -754,7 +789,11 @@ def action_shrink(self): non_shrinkable = [] for schedule in schedules: end_date = schedule.end - timedelta(weeks=weeks) if weeks else _date - if end_date < schedule.start or end_date > schedule.end or (not now and end_date < threshold): + if ( + end_date < schedule.start + or end_date > schedule.end + or (not now and end_date < threshold) + ): non_shrinkable.append(schedule.host) if non_shrinkable: @@ -767,9 +806,14 @@ def action_shrink(self): return if not check: - confirm_msg = f"for {weeks} week[s]? (y/N): " if weeks else f"to {str(_date)[:16]}? (y/N): " + confirm_msg = ( + f"for {weeks} week[s]? (y/N): " + if weeks + else f"to {str(_date)[:16]}? (y/N): " + ) if not self._confirmation_dialog( - f"Are you sure you want to shrink {data_dispatch[dispatch_key]} " + confirm_msg + f"Are you sure you want to shrink {data_dispatch[dispatch_key]} " + + confirm_msg ): return @@ -800,7 +844,9 @@ def action_shrink(self): f"{dispatch_key.capitalize()} {data_dispatch[dispatch_key]} can be shrunk to {str(end_date)[:16]}" ) else: - self.logger.info(f"{dispatch_key.capitalize()} {data_dispatch[dispatch_key]} can be terminated now") + self.logger.info( + f"{dispatch_key.capitalize()} {data_dispatch[dispatch_key]} can be terminated now" + ) def action_cloudresource(self): assignment = None @@ -830,12 +876,16 @@ def action_cloudresource(self): if cloud and cloud.name != conf.get("spare_pool_name"): try: - assignment = self.quads.get_active_cloud_assignment(self.cli_args.get("cloud")) + assignment = self.quads.get_active_cloud_assignment( + self.cli_args.get("cloud") + ) except (APIServerException, APIBadRequest) as ex: # pragma: no cover raise CliException(str(ex)) if assignment: - last_redefined = datetime.strptime(str(cloud.last_redefined), "%a, %d %b %Y %H:%M:%S GMT") + last_redefined = datetime.strptime( + str(cloud.last_redefined), "%a, %d %b %Y %H:%M:%S GMT" + ) lock_release = last_redefined + timedelta(hours=cloud_reservation_lock) cloud_string = f"{cloud.name}" if lock_release > datetime.now(): @@ -874,16 +924,27 @@ def action_cloudresource(self): try: self.quads.update_cloud( cloud.name, - {"last_redefined": ":".join(datetime.now().isoformat().split(":")[:-1])}, + { + "last_redefined": ":".join( + datetime.now().isoformat().split(":")[:-1] + ) + }, ) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) elif assignment: try: response = self.quads.update_assignment(assignment.id, data) self.quads.update_cloud( cloud.name, - {"last_redefined": ":".join(datetime.now().isoformat().split(":")[:-1])}, + { + "last_redefined": ":".join( + datetime.now().isoformat().split(":")[:-1] + ) + }, ) except (APIServerException, APIBadRequest) as ex: # pragma: no cover @@ -897,7 +958,9 @@ def action_cloudresource(self): self.logger.warning("No assignment created or updated.") except ConnectionError: # pragma: no cover - raise CliException("Could not connect to the quads-server, verify service is up and running.") + raise CliException( + "Could not connect to the quads-server, verify service is up and running." + ) def action_modcloud(self): data = { @@ -922,12 +985,16 @@ def action_modcloud(self): clean_data["qinq"] = self.cli_args.get("qinq") try: - assignment = self.quads.get_active_cloud_assignment(self.cli_args.get("cloud")) + assignment = self.quads.get_active_cloud_assignment( + self.cli_args.get("cloud") + ) except (APIServerException, APIBadRequest) as ex: # pragma: no cover raise CliException(str(ex)) if not assignment: - raise CliException(f"No active cloud assignment for {self.cli_args.get('cloud')}") + raise CliException( + f"No active cloud assignment for {self.cli_args.get('cloud')}" + ) try: self.quads.update_assignment(assignment.id, clean_data) @@ -1015,7 +1082,9 @@ def action_define_host_metadata(self): hosts_metadata = yaml.safe_load(md) except IOError as ಠ_ಠ: self.logger.debug(ಠ_ಠ, exc_info=ಠ_ಠ) - raise CliException(f"There was something wrong reading from {self.cli_args['metadata']}") + raise CliException( + f"There was something wrong reading from {self.cli_args['metadata']}" + ) for host_md in hosts_metadata: ready_defined = [] @@ -1031,17 +1100,26 @@ def action_define_host_metadata(self): try: self.quads.create_host(host_data) self.logger.info(f"{host_md.get('name')} created") - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) else: - self.logger.warning(f"Host {host_md.get('name')} not found. Skipping.") + self.logger.warning( + f"Host {host_md.get('name')} not found. Skipping." + ) continue host = self.quads.get_host(host_md.get("name")) data = {} for key, value in host_md.items(): - if key != "name" and key != "default_cloud" and getattr(host, key) is not None: + if ( + key != "name" + and key != "default_cloud" + and getattr(host, key) is not None + ): ready_defined.append(key) if not self.cli_args.get("force"): # pragma: no cover continue @@ -1059,7 +1137,9 @@ def action_define_host_metadata(self): ) as ex: # pragma: no cover raise CliException(str(ex)) else: # pragma: no cover - raise CliException(f"Invalid key '{key}' on metadata for {host.name}") + raise CliException( + f"Invalid key '{key}' on metadata for {host.name}" + ) else: data[key] = value @@ -1161,7 +1241,9 @@ def action_host_metadata_export(self): self.logger.info(f"Metadata successfully exported to {temp.name}.") except Exception as ಠ益ಠ: # pragma: no cover self.logger.debug(ಠ益ಠ, exc_info=ಠ益ಠ) - raise BaseQuadsException("There was something wrong writing to file.") from ಠ益ಠ + raise BaseQuadsException( + "There was something wrong writing to file." + ) from ಠ益ಠ return 0 @@ -1194,7 +1276,9 @@ def action_add_schedule(self): except (APIServerException, APIBadRequest) as ex: # pragma: no cover raise CliException(str(ex)) if host.cloud.name == self.cli_args.get("omitcloud"): - self.logger.info("Host is in part of the cloud specified with --omit-cloud. Nothing has been done.") + self.logger.info( + "Host is in part of the cloud specified with --omit-cloud. Nothing has been done." + ) else: data = { "cloud": self.cli_args.get("schedcloud"), @@ -1213,15 +1297,23 @@ def action_add_schedule(self): with open(self.cli_args.get("host_list")) as _file: host_list_stream = _file.read() except IOError: - raise CliException(f"Could not read file: {self.cli_args['host_list']}.") + raise CliException( + f"Could not read file: {self.cli_args['host_list']}." + ) host_list = host_list_stream.split() non_available = [] - _sched_start = datetime.strptime(self.cli_args.get("schedstart"), "%Y-%m-%d %H:%M") - _sched_end = datetime.strptime(self.cli_args.get("schedend"), "%Y-%m-%d %H:%M") + _sched_start = datetime.strptime( + self.cli_args.get("schedstart"), "%Y-%m-%d %H:%M" + ) + _sched_end = datetime.strptime( + self.cli_args.get("schedend"), "%Y-%m-%d %H:%M" + ) if self.cli_args.get("omitcloud"): - self.logger.info(f"INFO - All hosts from {self.cli_args['omitcloud']} will be omitted.") + self.logger.info( + f"INFO - All hosts from {self.cli_args['omitcloud']} will be omitted." + ) omitted = [] for host in host_list: @@ -1251,7 +1343,9 @@ def action_add_schedule(self): raise CliException(str(ex)) if non_available: - self.logger.error("The following hosts are either broken or unavailable:") + self.logger.error( + "The following hosts are either broken or unavailable:" + ) for host in non_available: self.logger.error(host) @@ -1267,11 +1361,16 @@ def action_add_schedule(self): try: try: self.quads.insert_schedule(data) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) self.logger.info(f"Schedule created for {host}") except ConnectionError: - raise CliException("Could not connect to the quads-server, verify service is up and running.") + raise CliException( + "Could not connect to the quads-server, verify service is up and running." + ) template_file = "jira_ticket_assignment" with open(os.path.join(conf.TEMPLATES_PATH, template_file)) as _file: @@ -1283,7 +1382,9 @@ def action_add_schedule(self): raise CliException(str(ex)) jira_docs_links = conf["jira_docs_links"].split(",") jira_vlans_docs_links = conf["jira_vlans_docs_links"].split(",") - ass = self.quads.get_active_cloud_assignment(self.cli_args.get("schedcloud")) + ass = self.quads.get_active_cloud_assignment( + self.cli_args.get("schedcloud") + ) comment = template.render( schedule_start=self.cli_args.get("schedstart"), schedule_end=self.cli_args.get("schedend"), @@ -1313,7 +1414,9 @@ def action_add_schedule(self): t_name = transition.get("name") if t_name and t_name.lower() == "scheduled": transition_id = transition.get("id") - transition_result = loop.run_until_complete(jira.post_transition(ass.ticket, transition_id)) + transition_result = loop.run_until_complete( + jira.post_transition(ass.ticket, transition_id) + ) break if not transition_result: @@ -1349,7 +1452,11 @@ def action_modschedule(self): value = self.cli_args.get(v) if value: if k in ["start", "end"]: - value = ":".join(datetime.strptime(value, "%Y-%m-%d %H:%M").isoformat().split(":")[:-1]) + value = ":".join( + datetime.strptime(value, "%Y-%m-%d %H:%M") + .isoformat() + .split(":")[:-1] + ) data[k] = value try: schedule = self.quads.get_schedule(self.cli_args.get("schedid")) @@ -1361,7 +1468,9 @@ def action_modschedule(self): "seven_day": False, "pre": False, } - self.quads.update_notification(schedule.assignment["notification"]["id"], not_data) + self.quads.update_notification( + schedule.assignment["notification"]["id"], not_data + ) self.logger.info("Schedule updated successfully.") except (APIServerException, APIBadRequest) as ex: # pragma: no cover raise CliException(str(ex)) @@ -1380,7 +1489,13 @@ def action_addinterface(self): _ifmaintenance = self.cli_args.get("ifmaintenance", False) _force = self.cli_args.get("force", None) _host = self.cli_args.get("host", None) - if _ifmac is None or _ifname is None or _ifip is None or _ifport is None or _ifport is None: + if ( + _ifmac is None + or _ifname is None + or _ifip is None + or _ifport is None + or _ifport is None + ): raise CliException( "Missing option. All these options are required for --add-interface:\n" "\t--host\n" @@ -1418,7 +1533,9 @@ def action_addinterface(self): def action_rminterface(self): if not self.cli_args.get("host") or not self.cli_args.get("ifname"): - raise CliException("Missing option. --host and --interface-name options are required for --rm-interface") + raise CliException( + "Missing option. --host and --interface-name options are required for --rm-interface" + ) data = { "hostname": self.cli_args.get("host"), @@ -1461,7 +1578,9 @@ def action_modinterface(self): _host = self.cli_args.get("host", None) # TODO: fix all if _host is None or _ifname is None: - raise CliException("Missing option. --host and --interface-name options are required for --mod-interface:") + raise CliException( + "Missing option. --host and --interface-name options are required for --mod-interface:" + ) try: host = self.quads.get_host(_host) @@ -1516,11 +1635,15 @@ def action_modinterface(self): def action_movehosts(self): # pragma: no cover if self.cli_args.get("datearg") and not self.cli_args.get("dryrun"): - raise CliException("--move-hosts and --date are mutually exclusive unless using --dry-run.") + raise CliException( + "--move-hosts and --date are mutually exclusive unless using --dry-run." + ) date = "" if self.cli_args.get("datearg"): - date = datetime.strptime(self.cli_args.get("datearg"), "%Y-%m-%d %H:%M").isoformat()[:-3] + date = datetime.strptime( + self.cli_args.get("datearg"), "%Y-%m-%d %H:%M" + ).isoformat()[:-3] try: moves = self.quads.get_moves(date) @@ -1551,14 +1674,19 @@ def action_movehosts(self): # pragma: no cover try: cloud = self.quads.get_cloud(new) assignment = self.quads.get_active_cloud_assignment(cloud.name) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) target_assignment = None if assignment: target_assignment = Assignment().from_dict(data=assignment) wipe = target_assignment.wipe if target_assignment else False - self.logger.info(f"Moving {host} from {current} to {new}, wipe = {wipe}") + self.logger.info( + f"Moving {host} from {current} to {new}, wipe = {wipe}" + ) if not self.cli_args.get("dryrun"): try: self.quads.update_host( @@ -1569,27 +1697,49 @@ def action_movehosts(self): # pragma: no cover "build": False, }, ) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) if new != "cloud01": try: - has_active_schedule = self.quads.get_current_schedules({"cloud": f"{cloud.name}"}) + has_active_schedule = self.quads.get_current_schedules( + {"cloud": f"{cloud.name}"} + ) if has_active_schedule and wipe: - assignment = self.quads.get_active_cloud_assignment(cloud.name) - self.quads.update_assignment(assignment.id, {"validated": False}) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + assignment = self.quads.get_active_cloud_assignment( + cloud.name + ) + self.quads.update_assignment( + assignment.id, {"validated": False} + ) + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) try: if self.cli_args.get("movecommand") == default_move_command: - fn = functools.partial(move_and_rebuild, host, new, semaphore, wipe) + fn = functools.partial( + move_and_rebuild, host, new, semaphore, wipe + ) tasks.append(fn) omits = conf.get("omit_network_move") omit = False if omits: omits = omits.split(",") - omit = [omit for omit in omits if omit in host or omit == new] + omit = [ + omit + for omit in omits + if omit in host or omit == new + ] if not omit: - switch_tasks.append(functools.partial(switch_config, host, current, new)) + switch_tasks.append( + functools.partial( + switch_config, host, current, new + ) + ) else: if wipe: subprocess.check_call( @@ -1612,30 +1762,45 @@ def action_movehosts(self): # pragma: no cover ) except Exception as ex: self.logger.debug(ex) - self.logger.exception("Move command failed for host: %s" % host) + self.logger.exception( + "Move command failed for host: %s" % host + ) provisioned = False if not self.cli_args.get("dryrun"): try: _old_cloud_obj = self.quads.get_cloud(results[0]["current"]) - old_cloud_schedule = self.quads.get_current_schedules({"cloud": _old_cloud_obj.name}) + old_cloud_schedule = self.quads.get_current_schedules( + {"cloud": _old_cloud_obj.name} + ) if not old_cloud_schedule and _old_cloud_obj.name != "cloud01": - _old_ass_cloud_obj = self.quads.get_active_cloud_assignment(_old_cloud_obj.name) + _old_ass_cloud_obj = self.quads.get_active_cloud_assignment( + _old_cloud_obj.name + ) if _old_ass_cloud_obj: payload = {"active": False} - self.quads.update_assignment(_old_ass_cloud_obj.id, payload) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + self.quads.update_assignment( + _old_ass_cloud_obj.id, payload + ) + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) done = None loop = asyncio.get_event_loop() loop.set_exception_handler( - lambda _loop, ctx: self.logger.error(f"Caught exception: {ctx['message']}") + lambda _loop, ctx: self.logger.error( + f"Caught exception: {ctx['message']}" + ) ) try: - done = loop.run_until_complete(asyncio.gather(*[task(loop) for task in tasks])) + done = loop.run_until_complete( + asyncio.gather(*[task(loop) for task in tasks]) + ) except ( asyncio.CancelledError, SystemExit, @@ -1647,12 +1812,17 @@ def action_movehosts(self): # pragma: no cover for task in switch_tasks: try: host_obj = self.quads.get_host(task.args[0]) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover self.logger.exception(str(ex)) continue if not host_obj.switch_config_applied: - self.logger.info(f"Running switch config for {task.args[0]}") + self.logger.info( + f"Running switch config for {task.args[0]}" + ) try: result = task() @@ -1664,12 +1834,19 @@ def action_movehosts(self): # pragma: no cover if result: try: - self.quads.update_host(task.args[0], {"switch_config_applied": True}) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + self.quads.update_host( + task.args[0], {"switch_config_applied": True} + ) + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover self.logger.exception(str(ex)) continue else: - self.logger.exception("There was something wrong configuring the switch.") + self.logger.exception( + "There was something wrong configuring the switch." + ) if done: for future in done: @@ -1681,14 +1858,19 @@ def action_movehosts(self): # pragma: no cover if provisioned: try: _new_cloud_obj = self.quads.get_cloud(_cloud) - assignment = self.quads.get_active_cloud_assignment(_new_cloud_obj.name) + assignment = self.quads.get_active_cloud_assignment( + _new_cloud_obj.name + ) if assignment: validate = not assignment.wipe self.quads.update_assignment( assignment.id, {"provisioned": True, "validated": validate}, ) - except (APIServerException, APIBadRequest) as ex: # pragma: no cover + except ( + APIServerException, + APIBadRequest, + ) as ex: # pragma: no cover raise CliException(str(ex)) return 0 @@ -1703,7 +1885,9 @@ def action_mark_broken(self): raise CliException(str(ex)) if host.broken: - self.logger.warning(f"Host {self.cli_args['host']} has already been marked broken") + self.logger.warning( + f"Host {self.cli_args['host']} has already been marked broken" + ) else: try: self.quads.update_host(self.cli_args.get("host"), {"broken": True}) @@ -1721,7 +1905,9 @@ def action_mark_repaired(self): raise CliException(str(ex)) if not host.broken: - self.logger.warning(f"Host {self.cli_args['host']} has already been marked repaired") + self.logger.warning( + f"Host {self.cli_args['host']} has already been marked repaired" + ) else: try: self.quads.update_host(self.cli_args.get("host"), {"broken": False}) @@ -1739,7 +1925,9 @@ def action_retire(self): raise CliException(str(ex)) if host.retired: - self.logger.warning(f"Host {self.cli_args['host']} has already been marked as retired") + self.logger.warning( + f"Host {self.cli_args['host']} has already been marked as retired" + ) else: try: self.quads.update_host(self.cli_args.get("host"), {"retired": True}) @@ -1757,7 +1945,9 @@ def action_unretire(self): raise CliException(str(ex)) if not host.retired: - self.logger.warning(f"Host {self.cli_args['host']} has already been marked unretired") + self.logger.warning( + f"Host {self.cli_args['host']} has already been marked unretired" + ) else: try: self.quads.update_host(self.cli_args.get("host"), {"retired": False}) @@ -1773,7 +1963,9 @@ def action_host(self): _kwargs = {"host": host.name} if self.cli_args.get("datearg"): - datetime_obj = datetime.strptime(self.cli_args.get("datearg"), "%Y-%m-%d %H:%M") + datetime_obj = datetime.strptime( + self.cli_args.get("datearg"), "%Y-%m-%d %H:%M" + ) datearg_iso = datetime_obj.isoformat() date_str = ":".join(datearg_iso.split(":")[:-1]) _kwargs["date"] = date_str @@ -1795,7 +1987,9 @@ def action_cloudonly(self): _kwargs = {"cloud": _cloud.name} if self.cli_args.get("datearg"): - _kwargs["date"] = datetime.strptime(self.cli_args.get("datearg"), "%Y-%m-%d %H:%M").isoformat()[:-3] + _kwargs["date"] = datetime.strptime( + self.cli_args.get("datearg"), "%Y-%m-%d %H:%M" + ).isoformat()[:-3] schedules = self.quads.get_current_schedules(_kwargs) if schedules: host_kwargs = {"retired": False} @@ -1819,7 +2013,9 @@ def action_cloudonly(self): available_hosts = self.quads.filter_available(data) except (APIServerException, APIBadRequest) as ex: # pragma: no cover self.logger.debug(str(ex)) - raise CliException("Could not connect to the quads-server, verify service is up and running.") + raise CliException( + "Could not connect to the quads-server, verify service is up and running." + ) host_kwargs = {} if self.cli_args.get("filter"): @@ -1846,7 +2042,9 @@ def action_cloudonly(self): def action_summary(self): _kwargs = {} if self.cli_args.get("datearg"): - datearg_obj = datetime.strptime(self.cli_args.get("datearg"), "%Y-%m-%d %H:%M") + datearg_obj = datetime.strptime( + self.cli_args.get("datearg"), "%Y-%m-%d %H:%M" + ) datearg_iso = datearg_obj.isoformat() date_str = ":".join(datearg_iso.split(":")[:-1]) _kwargs["date"] = date_str @@ -1867,7 +2065,9 @@ def action_summary(self): f"{cloud_name} ({cloud_owner}): {cloud_count} ({cloud_description}) - {cloud_ticket}" ) else: - self.logger.info(f"{cloud_name}: {cloud_count} ({cloud_description})") + self.logger.info( + f"{cloud_name}: {cloud_count} ({cloud_description})" + ) def action_regen_instack(self): regen_instack() diff --git a/src/quads/quads_api.py b/src/quads/quads_api.py index 4a6e0b981..7f108d97e 100644 --- a/src/quads/quads_api.py +++ b/src/quads/quads_api.py @@ -1,16 +1,16 @@ import os -import requests - from json import JSONDecodeError -from typing import Optional, List -from requests import Response -from requests.auth import HTTPBasicAuth -from requests.adapters import HTTPAdapter, Retry +from typing import List, Optional from urllib import parse as url_parse from urllib.parse import urlencode +import requests +from requests import Response +from requests.adapters import HTTPAdapter, Retry +from requests.auth import HTTPBasicAuth + from quads.config import Config -from quads.server.models import Host, Cloud, Schedule, Interface, Vlan, Assignment +from quads.server.models import Assignment, Cloud, Host, Interface, Schedule, Vlan class APIServerException(Exception): @@ -40,11 +40,15 @@ def __init__(self, config: Config): self.session = requests.Session() retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504]) self.session.mount("http://", HTTPAdapter(max_retries=retries)) - self.auth = HTTPBasicAuth(self.config.get("quads_api_username"), self.config.get("quads_api_password")) + self.auth = HTTPBasicAuth( + self.config.get("quads_api_username"), self.config.get("quads_api_password") + ) # Base functions def get(self, endpoint: str) -> Response: - _response = self.session.get(os.path.join(self.base_url, endpoint), verify=False, auth=self.auth) + _response = self.session.get( + os.path.join(self.base_url, endpoint), verify=False, auth=self.auth + ) if _response.status_code == 500: raise APIServerException("Check the flask server logs") if _response.status_code == 400: @@ -84,7 +88,9 @@ def patch(self, endpoint, data) -> Response: return _response def delete(self, endpoint) -> Response: - _response = self.session.delete(os.path.join(self.base_url, endpoint), verify=False, auth=self.auth) + _response = self.session.delete( + os.path.join(self.base_url, endpoint), verify=False, auth=self.auth + ) if _response.status_code == 500: raise APIServerException("Check the flask server logs") if _response.status_code == 400: @@ -167,12 +173,20 @@ def get_clouds(self) -> List[Cloud]: clouds.append(Cloud(**cloud)) return [cloud for cloud in sorted(clouds, key=lambda x: x.name)] + def get_free_clouds(self) -> List[Cloud]: + response = self.get("clouds/free/") + clouds = [] + for cloud in response.json(): + clouds.append(Cloud(**cloud)) + return clouds + def get_cloud(self, cloud_name) -> Optional[Cloud]: cloud_obj = None - response = self.get(os.path.join("clouds", cloud_name)) + response = self.get(f"clouds?name={cloud_name}") obj_json = response.json() if obj_json: - cloud_obj = Cloud(**obj_json) + cloud_response = obj_json[0] + cloud_obj = Cloud(**cloud_response) return cloud_obj def insert_cloud(self, data) -> Response: diff --git a/src/quads/server/blueprints/assignments.py b/src/quads/server/blueprints/assignments.py index 76aae79dc..1f9e9ec60 100644 --- a/src/quads/server/blueprints/assignments.py +++ b/src/quads/server/blueprints/assignments.py @@ -1,14 +1,14 @@ import re -from flask import Blueprint, jsonify, request, Response, make_response +from flask import Blueprint, Response, jsonify, make_response, request +from sqlalchemy import inspect from quads.server.blueprints import check_access from quads.server.dao.assignment import AssignmentDao -from quads.server.dao.baseDao import EntryNotFound, InvalidArgument, BaseDao +from quads.server.dao.baseDao import BaseDao, EntryNotFound, InvalidArgument from quads.server.dao.cloud import CloudDao from quads.server.dao.vlan import VlanDao from quads.server.models import Assignment -from sqlalchemy import inspect assignment_bp = Blueprint("assignments", __name__) @@ -225,7 +225,7 @@ def update_assignment(assignment_id: str) -> Response: value = data.get(attr.key) if value is not None: if attr.key == "ccuser": - value = value.split(",") + value = re.split(r"[, ]+", value) value = [user.strip() for user in value] if attr.key == "cloud": _cloud = CloudDao.get_cloud(value) diff --git a/src/quads/server/blueprints/clouds.py b/src/quads/server/blueprints/clouds.py index 6bb4fe5dd..bd0a5d461 100644 --- a/src/quads/server/blueprints/clouds.py +++ b/src/quads/server/blueprints/clouds.py @@ -1,11 +1,11 @@ import json from datetime import datetime -from flask import Blueprint, jsonify, request, Response, make_response -from quads.server.dao.assignment import AssignmentDao +from flask import Blueprint, Response, jsonify, make_response, request from quads.config import Config from quads.server.blueprints import check_access +from quads.server.dao.assignment import AssignmentDao from quads.server.dao.baseDao import EntryNotFound, InvalidArgument from quads.server.dao.cloud import CloudDao from quads.server.dao.host import HostDao @@ -14,28 +14,6 @@ cloud_bp = Blueprint("clouds", __name__) -@cloud_bp.route("//") -def get_cloud(cloud: str) -> Response: - """ - GET request that returns the cloud with the given name. - --- - tags: - - API - - :param cloud: str: Specify the cloud name - :return: A response object that contains the json representation of the cloud - """ - _cloud = CloudDao.get_cloud(cloud) - if not _cloud: - response = { - "status_code": 400, - "error": "Bad Request", - "message": f"Cloud not found: {cloud}", - } - return make_response(jsonify(response), 400) - return jsonify(_cloud.as_dict()) - - @cloud_bp.route("/") def get_clouds() -> Response: """ @@ -62,6 +40,21 @@ def get_clouds() -> Response: return jsonify([_cloud.as_dict() for _cloud in _clouds] if _clouds else {}) +@cloud_bp.route("/free/") +def get_free_clouds() -> Response: + """ + Returns a list of all free clouds in the database. + --- + tags: + - API + + :return: The list of free clouds + """ + _clouds = CloudDao.get_free_clouds() + + return jsonify([_cloud.as_dict() for _cloud in _clouds]) + + @cloud_bp.route("/", methods=["POST"]) @check_access(["admin"]) def create_cloud() -> Response: @@ -192,7 +185,9 @@ def get_summary() -> Response: description = Config["spare_pool_description"] owner = Config["spare_pool_owner"] else: - date = datetime.strptime(_date, "%Y-%m-%dT%H:%M") if _date else datetime.now() + date = ( + datetime.strptime(_date, "%Y-%m-%dT%H:%M") if _date else datetime.now() + ) schedules = ScheduleDao.get_current_schedule(cloud=_cloud, date=date) count = len(schedules) total_count += count diff --git a/src/quads/server/dao/cloud.py b/src/quads/server/dao/cloud.py index 5d417a221..163b0ad2f 100644 --- a/src/quads/server/dao/cloud.py +++ b/src/quads/server/dao/cloud.py @@ -1,15 +1,17 @@ +from datetime import datetime from typing import List, Optional, Type -from sqlalchemy import Boolean +from sqlalchemy import Boolean, or_ +from quads.config import Config from quads.server.dao.baseDao import ( + OPERATORS, BaseDao, EntryExisting, EntryNotFound, - OPERATORS, InvalidArgument, ) -from quads.server.models import db, Cloud +from quads.server.models import Assignment, Cloud, Schedule, db class CloudDao(BaseDao): @@ -68,6 +70,26 @@ def get_clouds() -> List[Cloud]: clouds = db.session.query(Cloud).order_by(Cloud.name.asc()).all() return clouds + @staticmethod + def get_free_clouds() -> List[Cloud]: + free_clouds = ( + db.session.query(Cloud) + .outerjoin(Assignment, Cloud.id == Assignment.cloud_id) + .outerjoin(Schedule, Assignment.id == Schedule.assignment_id) + .filter( + Cloud.name != Config["spare_pool_name"], + or_( + Schedule.end <= datetime.now(), + Assignment.id == None, + Schedule.id == None, + ), + ) + .order_by(Cloud.name.asc()) + .distinct() + .all() + ) + return free_clouds + @staticmethod def filter_clouds_dict(data: dict) -> List[Type[Cloud]]: filter_tuples = [] @@ -103,6 +125,8 @@ def filter_clouds_dict(data: dict) -> List[Type[Cloud]]: ) if filter_tuples: _clouds = CloudDao.create_query_select(Cloud, filters=filter_tuples) + if not _clouds: + raise EntryNotFound("No clouds found with the given filters") else: _clouds = CloudDao.get_clouds() return _clouds diff --git a/src/quads/server/swagger.yaml b/src/quads/server/swagger.yaml index db12291b4..768948732 100644 --- a/src/quads/server/swagger.yaml +++ b/src/quads/server/swagger.yaml @@ -180,8 +180,8 @@ paths: - BearerAuth: [ ] /clouds/{cloudName}/: - get: - summary: Returns a cloud by name + delete: + summary: Delete cloud by cloud name tags: - Clouds parameters: @@ -193,7 +193,18 @@ paths: type: string responses: '200': - description: Cloud name + description: Deleted Cloud + security: + - BearerAuth: [ ] + + /clouds/free/: + get: + summary: Returns all free clouds that are available for new assignments + tags: + - Clouds + responses: + '200': + description: Free clouds headers: x-next: description: A link to the next page of responses @@ -202,29 +213,15 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Cloud' + type: array + items: + $ref: '#/components/schemas/Cloud' default: description: Unexpected error content: application/json: schema: $ref: '#/components/schemas/Error' - delete: - summary: Delete cloud by cloud name - tags: - - Clouds - parameters: - - name: cloudName - in: path - description: Cloud name - required: true - schema: - type: string - responses: - '200': - description: Deleted Cloud - security: - - BearerAuth: [ ] /clouds/summary/: get: diff --git a/tests/api/test_clouds.py b/tests/api/test_clouds.py index c5161df76..c217cfd43 100644 --- a/tests/api/test_clouds.py +++ b/tests/api/test_clouds.py @@ -78,14 +78,15 @@ def test_valid_single(self, test_client, auth): cloud_name = f"cloud{str(cloud_id).zfill(2)}" response = unwrap_json( test_client.get( - f"/api/v3/clouds/{cloud_name}", + f"/api/v3/clouds?name={cloud_name}", headers=auth_header, ) ) + cloud = response.json[0] assert response.status_code == 200 - assert response.json["id"] == cloud_id - assert response.json["name"] == cloud_name - assert response.json["last_redefined"] is not None + assert cloud["id"] == cloud_id + assert cloud["name"] == cloud_name + assert cloud["last_redefined"] is not None def test_valid_multiple(self, test_client, auth): """ @@ -108,6 +109,27 @@ def test_valid_multiple(self, test_client, auth): assert response.json[cloud_id - 1]["name"] == cloud_name assert response.json[cloud_id - 1]["last_redefined"] is not None + def test_free_cloud(self, test_client, auth): + """ + | GIVEN: Clouds from test_valid in database and user logged in + | WHEN: User tries to read all free clouds + | THEN: User should be able to read all free clouds + """ + auth_header = auth.get_auth_header() + response = unwrap_json( + test_client.get( + "/api/v3/clouds/free/", + headers=auth_header, + ) + ) + assert response.status_code == 200 + assert len(response.json) == 9 + for cloud_id in range(2, 11): + cloud_name = f"cloud{str(cloud_id).zfill(2)}" + assert response.json[cloud_id - 2]["id"] == cloud_id + assert response.json[cloud_id - 2]["name"] == cloud_name + assert response.json[cloud_id - 2]["last_redefined"] is not None + def test_invalid_not_found_single(self, test_client, auth): """ | GIVEN: Clouds from test_valid in database and user logged in @@ -117,11 +139,11 @@ def test_invalid_not_found_single(self, test_client, auth): auth_header = auth.get_auth_header() cloud_name = "cloud11" response = unwrap_json( - test_client.get(f"/api/v3/clouds/{cloud_name}", headers=auth_header) + test_client.get(f"/api/v3/clouds?name={cloud_name}", headers=auth_header) ) assert response.status_code == 400 assert response.json["error"] == "Bad Request" - assert response.json["message"] == "Cloud not found: cloud11" + assert response.json["message"] == "No clouds found with the given filters" def test_invalid_filter(self, test_client, auth): """ diff --git a/tests/cli/test_cloud.py b/tests/cli/test_cloud.py index a06b3b1f1..f657d85eb 100644 --- a/tests/cli/test_cloud.py +++ b/tests/cli/test_cloud.py @@ -1,21 +1,22 @@ from datetime import datetime from unittest.mock import patch +import pytest + from quads.exceptions import CliException from quads.quads_api import APIServerException from quads.server.dao.assignment import AssignmentDao from quads.server.dao.cloud import CloudDao from tests.cli.config import ( CLOUD, - HOST1, - DEFINE_CLOUD, - REMOVE_CLOUD, - MOD_CLOUD, DEFAULT_CLOUD, + DEFINE_CLOUD, FREE_CLOUD, + HOST1, + MOD_CLOUD, + REMOVE_CLOUD, ) from tests.cli.test_base import TestBase -import pytest def finalizer(): @@ -322,8 +323,8 @@ def test_ls_vlan_no_vlans(self, mock_get): def test_free_cloud(self, define_free_cloud): self.quads_cli_call("free_cloud") - assert self._caplog.messages[0].startswith(f"{FREE_CLOUD} (reserved: ") - assert self._caplog.messages[0].endswith("min remaining)") + assert self._caplog.messages[1].startswith(f"{FREE_CLOUD} (reserved: ") + assert self._caplog.messages[1].endswith("min remaining)") @patch("quads.quads_api.requests.Session.get") def test_free_cloud_exception(self, mock_get, define_free_cloud): @@ -332,13 +333,6 @@ def test_free_cloud_exception(self, mock_get, define_free_cloud): self.quads_cli_call("free_cloud") assert str(ex.value) == "Check the flask server logs" - @patch("quads.quads_api.QuadsApi.get_future_schedules") - def test_free_cloud_future_exception(self, mock_future, define_free_cloud): - mock_future.side_effect = APIServerException("Connection Error") - with pytest.raises(CliException) as ex: - self.quads_cli_call("free_cloud") - assert str(ex.value) == "Connection Error" - class TestCloudOnly(TestBase): def test_cloud_only(self): diff --git a/tests/cli/test_schedule.py b/tests/cli/test_schedule.py index 2e759f1c7..535d28fa6 100644 --- a/tests/cli/test_schedule.py +++ b/tests/cli/test_schedule.py @@ -13,14 +13,7 @@ from quads.server.dao.schedule import ScheduleDao from quads.server.dao.vlan import VlanDao from quads.server.models import db -from tests.cli.config import ( - CLOUD, - HOST2, - DEFAULT_CLOUD, - MOD_CLOUD, - MODEL2, - HOST1, -) +from tests.cli.config import CLOUD, DEFAULT_CLOUD, HOST1, HOST2, MOD_CLOUD, MODEL2 from tests.cli.test_base import TestBase @@ -39,8 +32,12 @@ def define_fixture(request): request.addfinalizer(finalizer) cloud = CloudDao.get_cloud(CLOUD) - vlan = VlanDao.create_vlan("192.168.1.1", 122, "192.168.1.1/22", "255.255.255.255", 1) - AssignmentDao.create_assignment("test", "test", "1234", 0, False, [""], cloud.name, vlan.vlan_id) + vlan = VlanDao.create_vlan( + "192.168.1.1", 122, "192.168.1.1/22", "255.255.255.255", 1 + ) + AssignmentDao.create_assignment( + "test", "test", "1234", 0, False, [""], cloud.name, vlan.vlan_id + ) @pytest.fixture @@ -52,8 +49,12 @@ def remove_fixture(request): cloud = CloudDao.get_cloud(CLOUD) host = HostDao.get_host(HOST2) - vlan = VlanDao.create_vlan("192.168.1.1", 122, "192.168.1.1/22", "255.255.255.255", 1) - assignment = AssignmentDao.create_assignment("test", "test", "1234", 0, False, [""], cloud.name, vlan.vlan_id) + vlan = VlanDao.create_vlan( + "192.168.1.1", 122, "192.168.1.1/22", "255.255.255.255", 1 + ) + assignment = AssignmentDao.create_assignment( + "test", "test", "1234", 0, False, [""], cloud.name, vlan.vlan_id + ) schedule = ScheduleDao.create_schedule( today.strftime("%Y-%m-%d %H:%M"), tomorrow.strftime("%Y-%m-%d %H:%M"), @@ -87,13 +88,18 @@ def test_add_schedule_host_list_not_avail(self): self.cli_args["schedend"] = tomorrow.strftime("%Y-%m-%d %H:%M") self.cli_args["schedcloud"] = CLOUD self.cli_args["host"] = None - self.cli_args["host_list"] = os.path.join(os.path.dirname(__file__), "fixtures/hostlist") + self.cli_args["host_list"] = os.path.join( + os.path.dirname(__file__), "fixtures/hostlist" + ) self.cli_args["omitcloud"] = None with pytest.raises(CliException) as ex: self.quads_cli_call("add_schedule") assert str(ex.value) == "Remove these from your host list and try again." - assert self._caplog.messages[0] == "The following hosts are either broken or unavailable:" + assert ( + self._caplog.messages[0] + == "The following hosts are either broken or unavailable:" + ) assert self._caplog.messages[1] == f"{HOST1}" assert self._caplog.messages[2] == f"{HOST2}" @@ -104,7 +110,9 @@ def test_add_schedule_host_list_file_not_found(self): self.cli_args["schedend"] = tomorrow.strftime("%Y-%m-%d %H:%M") self.cli_args["schedcloud"] = CLOUD self.cli_args["host"] = None - self.cli_args["host_list"] = os.path.join(os.path.dirname(__file__), "nonexistent.file") + self.cli_args["host_list"] = os.path.join( + os.path.dirname(__file__), "nonexistent.file" + ) self.cli_args["omitcloud"] = None with pytest.raises(CliException) as ex: @@ -210,7 +218,9 @@ def test_mod_schedule(self, remove_fixture): schedule_obj = ScheduleDao.get_schedule(_schedule[0].id) db.session.refresh(schedule_obj) - assert schedule_obj.end.strftime("%Y-%m-%dT%H:%M") == atomorrow.strftime("%Y-%m-%dT%H:%M") + assert schedule_obj.end.strftime("%Y-%m-%dT%H:%M") == atomorrow.strftime( + "%Y-%m-%dT%H:%M" + ) def test_mod_schedule_no_args(self, remove_fixture): self.cli_args["schedstart"] = None @@ -306,7 +316,9 @@ def test_extend_schedule(self, remove_fixture): schedule_obj = ScheduleDao.get_schedule(_schedule[0].id) db.session.refresh(schedule_obj) - assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") == atomorrow.strftime("%Y-%m-%d %H:%M") + assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") == atomorrow.strftime( + "%Y-%m-%d %H:%M" + ) def test_extend_schedule_no_schedule(self, define_fixture): self.cli_args["weeks"] = 2 @@ -315,7 +327,10 @@ def test_extend_schedule_no_schedule(self, define_fixture): self.quads_cli_call("extend") - assert self._caplog.messages[0] == "The selected cloud does not have any active schedules" + assert ( + self._caplog.messages[0] + == "The selected cloud does not have any active schedules" + ) def test_extend_no_dates(self): self.cli_args["weeks"] = None @@ -325,7 +340,10 @@ def test_extend_no_dates(self): with pytest.raises(CliException) as ex: self.quads_cli_call("extend") - assert str(ex.value) == "Missing option. Need --weeks or --date when using --extend" + assert ( + str(ex.value) + == "Missing option. Need --weeks or --date when using --extend" + ) def test_extend_no_target(self): self.cli_args["weeks"] = 2 @@ -335,7 +353,10 @@ def test_extend_no_target(self): with pytest.raises(CliException) as ex: self.quads_cli_call("extend") - assert str(ex.value) == "Missing option. At least one of either --host or --cloud is required." + assert ( + str(ex.value) + == "Missing option. At least one of either --host or --cloud is required." + ) def test_extend_bad_weeks(self): self.cli_args["weeks"] = "BADWEEKS" @@ -376,7 +397,9 @@ def test_shrink_schedule(self, mock_input, remove_fixture): schedule_obj = ScheduleDao.get_schedule(_schedule[0].id) db.session.refresh(schedule_obj) - assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") == atomorrow.strftime("%Y-%m-%d %H:%M") + assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") == atomorrow.strftime( + "%Y-%m-%d %H:%M" + ) @patch("quads.cli.cli.input") def test_shrink_schedule_check(self, mock_input, remove_fixture): @@ -393,11 +416,15 @@ def test_shrink_schedule_check(self, mock_input, remove_fixture): self.cli_args["check"] = True self.quads_cli_call("shrink") - assert self._caplog.messages[0].startswith(f"Host {HOST2} can be shrunk for 1 week[s] to") + assert self._caplog.messages[0].startswith( + f"Host {HOST2} can be shrunk for 1 week[s] to" + ) schedule_obj = ScheduleDao.get_schedule(_schedule[0].id) db.session.refresh(schedule_obj) - assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") != atomorrow.strftime("%Y-%m-%d %H:%M") + assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") != atomorrow.strftime( + "%Y-%m-%d %H:%M" + ) @patch("quads.cli.cli.input") def test_shrink_date(self, mock_input, remove_fixture): @@ -418,7 +445,9 @@ def test_shrink_date(self, mock_input, remove_fixture): schedule_obj = ScheduleDao.get_schedule(_schedule[0].id) db.session.refresh(schedule_obj) - assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") == atomorrow.strftime("%Y-%m-%d %H:%M") + assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") == atomorrow.strftime( + "%Y-%m-%d %H:%M" + ) def test_shrink_date_check(self, remove_fixture): host = HostDao.get_host(HOST2) @@ -438,7 +467,9 @@ def test_shrink_date_check(self, remove_fixture): schedule_obj = ScheduleDao.get_schedule(_schedule[0].id) db.session.refresh(schedule_obj) - assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") != atomorrow.strftime("%Y-%m-%d %H:%M") + assert schedule_obj.end.strftime("%Y-%m-%d %H:%M") != atomorrow.strftime( + "%Y-%m-%d %H:%M" + ) @patch("quads.cli.cli.input") def test_shrink_now(self, mock_input, remove_fixture): @@ -530,7 +561,10 @@ def test_shrink_no_dates(self): with pytest.raises(CliException) as ex: self.quads_cli_call("shrink") - assert str(ex.value) == "Missing option. Need --weeks, --date or --now when using --shrink" + assert ( + str(ex.value) + == "Missing option. Need --weeks, --date or --now when using --shrink" + ) def test_shrink_no_target(self): self.cli_args["weeks"] = 2 @@ -541,7 +575,10 @@ def test_shrink_no_target(self): with pytest.raises(CliException) as ex: self.quads_cli_call("shrink") - assert str(ex.value) == "Missing option. At least one of either --host or --cloud is required" + assert ( + str(ex.value) + == "Missing option. At least one of either --host or --cloud is required" + ) def test_shrink_bad_weeks(self): self.cli_args["weeks"] = "BADWEEKS" @@ -573,7 +610,10 @@ def test_shrink_no_schedules(self): self.cli_args["check"] = False self.quads_cli_call("shrink") - assert self._caplog.messages[0] == f"The selected host does not have any active schedules" + assert ( + self._caplog.messages[0] + == f"The selected host does not have any active schedules" + ) assert len(self._caplog.messages) == 1 @@ -677,4 +717,4 @@ def test_available_omit_bad_cloud(self, mock_filter, define_fixture): with pytest.raises(CliException) as ex: self.quads_cli_call("available") - assert str(ex.value) == "Cloud not found: BADCLOUD" + assert str(ex.value) == "No clouds found with the given filters" diff --git a/tests/cli/test_validate_env.py b/tests/cli/test_validate_env.py index 739cd0a8f..b1d91f605 100644 --- a/tests/cli/test_validate_env.py +++ b/tests/cli/test_validate_env.py @@ -1,10 +1,9 @@ import logging from datetime import datetime, timedelta +from unittest.mock import patch import pytest -from unittest.mock import patch - from quads.config import Config from quads.exceptions import CliException from quads.server.dao.assignment import AssignmentDao @@ -82,4 +81,4 @@ def test_validate_env_no_cloud(self, mocked_smtp): with pytest.raises(CliException) as ex: self.quads_cli_call("validate_env") - assert str(ex.value) == "Cloud not found: cloud02" + assert str(ex.value) == "No clouds found with the given filters"