diff --git a/README.md b/README.md index 0ab88e94..5eef9ea2 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,14 @@ When a tester is logged in using the proper account, namespace commands can be u Use `bonfire namespace -h` to see a list of all available namespace commands. + +### NamespaceReservation CRD + +`bonfire` can also be used to perform create, extend, list, and delete operations on a new NamespaceReservation CRD. This is available in the ephemeral cluster and is currently +in an 'alpha' state while we test out the new ephemeral namespace operator. + +Use `bonfire reservation -h` to test it out. + ### Namespace reconciler A separate cron job runs the `bonfire namespace reconcile` command every 2 minutes. This command does the following: diff --git a/bonfire/bonfire.py b/bonfire/bonfire.py index dc64bbc6..24904ebd 100755 --- a/bonfire/bonfire.py +++ b/bonfire/bonfire.py @@ -19,10 +19,20 @@ find_clowd_env_for_ns, wait_for_clowd_env_target_ns, wait_on_cji, + wait_on_reservation, + get_reservation, + check_for_existing_reservation, + oc, + whoami, ) -from bonfire.utils import FatalError, split_equals, find_what_depends_on +from bonfire.utils import FatalError, split_equals, find_what_depends_on, validate_time_string from bonfire.local import get_local_apps -from bonfire.processor import TemplateProcessor, process_clowd_env, process_iqe_cji +from bonfire.processor import ( + TemplateProcessor, + process_clowd_env, + process_iqe_cji, + process_reservation, +) from bonfire.namespaces import ( Namespace, get_namespaces, @@ -85,6 +95,12 @@ def apps(): pass +@main.group() +def reservation(): + """ALPHA: Perform operations related to the NamespaceReservation CRD""" + pass + + def _warn_if_unsafe(namespace): ns = Namespace(name=namespace) if not ns.owned_by_me and not ns.available: @@ -95,6 +111,23 @@ def _warn_if_unsafe(namespace): sys.exit(0) +def _warn_before_delete(): + if not click.confirm( + "Deleting your reservation will also delete the associated namespace. Proceed?" + ): + click.echo("Aborting") + sys.exit(0) + + +def _warn_of_existing(requester): + if not click.confirm( + f"Existing reservation(s) detected for requester '{requester}'. " + "Do you need to reserve an additional namespace?" + ): + click.echo("Aborting") + sys.exit(0) + + def _reserve_namespace(duration, retries, namespace): log.info( "reserving ephemeral namespace%s...", @@ -252,6 +285,13 @@ def _validate_resource_arguments(ctx, param, value): return value +def _validate_reservation_duration(ctx, param, value): + try: + return validate_time_string(value) + except ValueError: + raise click.BadParameter("expecting h/m/s string. Ex: '1h30m'") + + _app_source_options = [ click.option( "--source", @@ -463,6 +503,53 @@ def _validate_resource_arguments(ctx, param, value): ), ] +_reservation_process_options = [ + click.option( + "--name", + type=str, + default=None, + help="Identifier for the reservation", + ), + click.option( + "--requester", + "-r", + type=str, + default=None, + help="Name of the user requesting a reservation", + ), + click.option( + "--duration", + "-d", + type=str, + default="1h", + help="Duration of the reservation", + callback=_validate_reservation_duration, + ), +] + +_reservation_lookup_options = [ + click.option( + "--name", + type=str, + default=None, + help="Identifier for the reservation", + ), + click.option( + "--requester", + "-r", + type=str, + default=None, + help="Name of the user requesting a reservation" + ), + click.option( + "--namespace", + "-n", + type=str, + default=None, + help="Namespace for the reservation", + ), +] + def options(options_list): """Click decorator used to set a list of click options on a command.""" @@ -1016,6 +1103,195 @@ def _cmd_apps_what_depends_on( print("\n".join(found) or f"no apps depending on {component} found") +@reservation.command("create") +@click.option( + '--bot', + '-b', + is_flag=True, + help="Use this flag to skip the duplicate reservation check (for automation)", +) +@options(_reservation_process_options) +@options(_timeout_option) +def _create_new_reservation(bot, name, requester, duration, timeout): + def _err_handler(err): + msg = f"reservation failed: {str(err)}" + _error(msg) + + try: + res = get_reservation(name) + # Name should be unique on reservation creation. + if res: + raise FatalError( + f"Reservation with name {name} already exists" + ) + + res_config = process_reservation(name, requester, duration) + + log.debug("processed reservation:\n%s", res_config) + + if not bot: + if check_for_existing_reservation(res_config["items"][0]["spec"]["requester"]): + _warn_of_existing(res_config["items"][0]["spec"]["requester"]) + + try: + res_name = res_config["items"][0]["metadata"]["name"] + except (KeyError, IndexError): + raise Exception( + "error parsing name of Reservation from processed template, " + "check Reservation template" + ) + + apply_config(None, list_resource=res_config) + + ns_name = wait_on_reservation(res_name, timeout) + except KeyboardInterrupt as err: + log.error("aborted by keyboard interrupt!") + _err_handler(err) + except TimedOutError as err: + log.error("hit timeout error: %s", err) + _err_handler(err) + except FatalError as err: + log.error("hit fatal error: %s", err) + _err_handler(err) + except Exception as err: + log.exception("hit unexpected error!") + _err_handler(err) + else: + log.info( + "namespace '%s' is reserved by '%s' for '%s'", + ns_name, + res_config["items"][0]["spec"]["requester"], + duration, + ) + click.echo(ns_name) + + +@reservation.command("extend") +@click.option( + '--duration', + '-d', + type=str, + default='1h', + help='Amount of time to extend the reservation', + callback=_validate_reservation_duration, +) +@options(_reservation_lookup_options) +def _extend_reservation(name, namespace, requester, duration): + def _err_handler(err): + msg = f"reservation extension failed: {str(err)}" + _error(msg) + + try: + res = get_reservation(name, namespace, requester) + if res: + res_config = process_reservation( + res["metadata"]["name"], + res["spec"]["requester"], + duration, + ) + + log.debug("processed reservation:\n%s", res_config) + + apply_config(None, list_resource=res_config) + else: + raise FatalError("Reservation lookup failed") + except KeyboardInterrupt as err: + log.error("aborted by keyboard interrupt!") + _err_handler(err) + except TimedOutError as err: + log.error("hit timeout error: %s", err) + _err_handler(err) + except FatalError as err: + log.error("hit fatal error: %s", err) + _err_handler(err) + except Exception as err: + log.exception("hit unexpected error!") + _err_handler(err) + else: + log.info( + "reservation '%s' extended by '%s'", res["metadata"]["name"], duration + ) + + +@reservation.command("delete") +@options(_reservation_lookup_options) +def _delete_reservation(name, namespace, requester): + def _err_handler(err): + msg = f"reservation deletion failed: {str(err)}" + _error(msg) + + try: + res = get_reservation(name, namespace, requester) + if res: + _warn_before_delete() + res_name = res["metadata"]["name"] + log.info("deleting reservation '%s'", res_name) + oc("delete", "reservation", res_name) + log.info("reservation '%s' deleted", res_name) + else: + raise FatalError("Reservation lookup failed") + except KeyboardInterrupt as err: + log.error("aborted by keyboard interrupt!") + _err_handler(err) + except TimedOutError as err: + log.error("hit timeout error: %s", err) + _err_handler(err) + except FatalError as err: + log.error("hit fatal error: %s", err) + _err_handler(err) + except Exception as err: + log.exception("hit unexpected error!") + _err_handler(err) + + +@reservation.command("list") +@click.option( + '--mine', + '-m', + is_flag=True, + help='Return reservations belonging to the result of oc whoami', +) +@click.option( + '--requester', + '-r', + type=str, + default=None, + help='Return reservations belonging to the provided requester', +) +def _list_reservations(mine, requester): + def _err_handler(err): + msg = f"reservation listing failed: {str(err)}" + _error(msg) + + try: + if mine: + try: + requester = whoami() + except Exception: + log.info( + "whoami returned an error - getting reservations for 'bonfire'" + ) # minikube + requester = "bonfire" + oc("get", "reservation", "--selector", f"requester={requester}") + else: + if requester: + oc("get", "reservation", "--selector", f"requester={requester}") + else: + oc("get", "reservation") + except KeyboardInterrupt as err: + log.error("aborted by keyboard interrupt!") + _err_handler(err) + except TimedOutError as err: + log.error("hit timeout error: %s", err) + _err_handler(err) + except FatalError as err: + log.error("hit fatal error: %s", err) + _err_handler(err) + except Exception as err: + log.exception("hit unexpected error!") + _err_handler(err) + + def main_with_handler(): try: main() diff --git a/bonfire/config.py b/bonfire/config.py index e2b7d9c5..d9379667 100644 --- a/bonfire/config.py +++ b/bonfire/config.py @@ -34,6 +34,7 @@ def _get_config_path(): ) DEFAULT_IQE_CJI_TEMPLATE = resource_filename("bonfire", "resources/default-iqe-cji.yaml") DEFAULT_CONFIG_DATA = resource_filename("bonfire", "resources/default_config.yaml") +DEFAULT_RESERVATION_TEMPLATE = resource_filename("bonfire", "resources/reservation-template.yaml") LOCAL_GRAPHQL_URL = "http://localhost:4000/graphql" diff --git a/bonfire/openshift.py b/bonfire/openshift.py index 8f5ccf6d..ed4f2519 100644 --- a/bonfire/openshift.py +++ b/bonfire/openshift.py @@ -731,3 +731,56 @@ def _pod_found(): waiter.wait_for_ready(remaining_time) return pod_name + + +def wait_on_reservation(res_name, timeout): + log.info("waiting for reservation '%s' to get picked up by operator", res_name) + + def _find_reservation(): + res = get_json("reservation", name=res_name) + try: + return res["status"]["namespace"] + except (KeyError, IndexError): + return False + + ns_name, elapsed = wait_for( + _find_reservation, + num_sec=timeout, + message=f"waiting for namespace to be allocated to reservation '{res_name}'", + ) + return ns_name + + +def check_for_existing_reservation(requester): + log.info("Checking for existing reservations for '%s'", requester) + + all_res = get_json("reservation") + + for res in all_res["items"]: + if res["spec"]["requester"] == requester: + return True + + return False + + +def get_reservation(name=None, namespace=None, requester=None): + if name: + res = get_json("reservation", name=name) + return res if res else False + elif namespace: + all_res = get_json("reservation") + for res in all_res["items"]: + if res["status"]["namespace"] == namespace: + return res + elif requester: + all_res = get_json("reservation", label=f"requester={requester}") + numRes = len(all_res["items"]) + if numRes == 0: + return False + elif numRes == 1: + return all_res["items"][0] + else: + log.info("Multiple reservations found for requester '%s'. Aborting.", requester) + return False + + return False diff --git a/bonfire/processor.py b/bonfire/processor.py index 1120bc72..619728ff 100644 --- a/bonfire/processor.py +++ b/bonfire/processor.py @@ -8,7 +8,7 @@ from pathlib import Path import bonfire.config as conf -from bonfire.openshift import process_template +from bonfire.openshift import process_template, whoami from bonfire.utils import RepoFile, FatalError from bonfire.utils import get_dependencies as utils_get_dependencies @@ -117,6 +117,44 @@ def process_iqe_cji( return processed_template +def process_reservation( + name, + requester, + duration, + template_path=None, +): + log.info("processing namespace reservation") + + template_path = Path(template_path if template_path else conf.DEFAULT_RESERVATION_TEMPLATE) + + if not template_path.exists(): + raise FatalError("Reservation template file does not exist: %s", template_path) + + with template_path.open() as fp: + template_data = yaml.safe_load(fp) + + params = dict() + + params["NAME"] = name if name else f"bonfire-reservation-{str(uuid.uuid4()).split('-')[0]}" + params["DURATION"] = duration + + if requester is None: + try: + requester = whoami() + except Exception: + log.info("whoami returned an error - setting requester to 'bonfire'") # minikube + requester = "bonfire" + + params["REQUESTER"] = requester + + processed_template = process_template(template_data, params=params) + + if not processed_template.get("items"): + raise FatalError("Processed Reservation template has no items") + + return processed_template + + class TemplateProcessor: @staticmethod def _parse_app_names(app_names): diff --git a/bonfire/resources/reservation-template.yaml b/bonfire/resources/reservation-template.yaml new file mode 100644 index 00000000..b99847c6 --- /dev/null +++ b/bonfire/resources/reservation-template.yaml @@ -0,0 +1,28 @@ +apiVersion: v1 +kind: Template +metadata: + name: default-reservation + +objects: +- apiVersion: cloud.redhat.com/v1alpha1 + kind: NamespaceReservation + metadata: + name: ${NAME} + extensionId: ${EXTENSIONID} + labels: + requester: ${REQUESTER} + spec: + duration: ${DURATION} + requester: ${REQUESTER} + +parameters: +- name: DURATION + required: true +- name: REQUESTER + required: true +- name: NAME + required: true +- name: EXTENSIONID + description: ID used to indicate a new extension of an existing reservation + generate: expression + from: "[a-zA-Z0-9]{10}" diff --git a/bonfire/utils.py b/bonfire/utils.py index d311f46f..da6ee453 100644 --- a/bonfire/utils.py +++ b/bonfire/utils.py @@ -106,6 +106,15 @@ def split_equals(list_of_str, allow_null=False): return output +def validate_time_string(time): + valid_time = re.compile(r"^((\d+)h)?((\d+)m)?((\d+)s)?$") + if not valid_time.match(time): + raise ValueError( + f"invalid format for duration '{time}', must match: r'{valid_time.pattern}'" + ) + return time + + class RepoFile: def __init__(self, host, org, repo, path, ref="master"): if host not in ["local", "github", "gitlab"]: