diff --git a/cli.py b/cli.py index a7259d4..68591fc 100755 --- a/cli.py +++ b/cli.py @@ -20,24 +20,61 @@ TEMPLATE_DIR = "./templates" #region util -def gen_pass() -> str: +def gen_pass(pass_length: int = 20) -> str: + """Generates a simple password with the provided length + + Args: + pass_length (int, optional): Length of password. Defaults to 20. + + Returns: + str: The generated password. + """ alphabet = string.ascii_letters + string.digits - password = "".join(secrets.choice(alphabet) for _ in range(20)) + password = "".join(secrets.choice(alphabet) for _ in range(pass_length)) return password -def check_positive(value: str): +def check_positive(value: str) -> int: + """`argparse` type helper to check whether a given input is a positive integer. + + Args: + value (str): Input to validate + + Raises: + Exception: Input is neither a decimal or positive + + Returns: + int: The parsed input if valid + """ ivalue = int(value) - if ivalue <= 0: - raise Exception("Supplied number must be >= 0") + if ivalue < 0: + raise Exception(f"Supplied number must be >= 0") return ivalue def encode_member(member: str, mapping: Mapping[str, Any]) -> str: + """Encodes the member-entry of an inventory file with additional mappings. + + Args: + member (str) + mapping (Mapping[str, Any]) + + Returns: + str: member with mapping encoded + """ return member + " " + " ".join([f"{k}={v}" for k, v in mapping.items()]) def iter_ips(ip_format: str, start_octet: int): + """Simple iterator for generating ip's + + Args: + ip_format (str) + start_octet (int) + + Yields: + Yeah idk too lazy too look up what the type annotation for a generator is + """ ip_int = start_octet ip_fmt = ip_format while ip_int < 255: @@ -46,6 +83,13 @@ def iter_ips(ip_format: str, start_octet: int): def copy_template(src: str, dest: str, mapping: Mapping[str, Any] = {}): + """Templates and writes a template file using Jinja as templating engine. + + Args: + src (str): content of the file + dest (str): place to write templated file to + mapping (Mapping[str, Any], optional): datamapping. Defaults to {}. + """ c = Path(src).read_text() t: Template = Template(c) r = t.render(mapping) @@ -63,6 +107,15 @@ class MachineResources: @staticmethod def from_prompt() -> "MachineResources": + """Generate `MachineResources` from prompt. + + Raises: + Exception + Exception + + Returns: + MachineResources + """ cpus = input( "How many processors would you like to assign (default=1): ") if not cpus: @@ -84,7 +137,9 @@ class MachineResources: class InventoryWriter: - + """ + Helper class for generating Ansible inventory files. + """ def __init__(self, location: str) -> None: self._file_handle = Path(location) self._groups: dict[str, set[str]] = DefaultDict(set) @@ -112,7 +167,7 @@ def list_envs(args: argparse.Namespace): customer_path = path.join("customers", args.customer_name, "envs") print(" ".join(os.listdir(customer_path))) except FileNotFoundError: - raise Exception(f"Customer `{args.customer_name}` does not exist.") + print(f"Customer `{args.customer_name}` does not exist.") def delete_env(args: argparse.Namespace): @@ -282,7 +337,7 @@ def main() -> int: cenv_parser.add_argument("--ip-int", type=check_positive, help="4th octet to start at", - default=10) + default="10") cenv_parser.set_defaults(func=create_env) # CLI definition for positional arg "delete" diff --git a/service.py b/service.py index 472a359..8f86d1c 100644 --- a/service.py +++ b/service.py @@ -1,59 +1,160 @@ #!/usr/bin/env python3 from __future__ import annotations +from pathlib import Path +from os import path import subprocess as sub import re +from typing import Any, Callable + +P_BANNER = """ +██╗ ██╗███████╗██╗ ██╗ ██╗ ██████╗ ███╗ ███╗ +██║ ██║██╔════╝██║ ██║ ██╔╝██╔═══██╗████╗ ████║ +██║ █╗ ██║█████╗ ██║ █████╔╝ ██║ ██║██╔████╔██║ +██║███╗██║██╔══╝ ██║ ██╔═██╗ ██║ ██║██║╚██╔╝██║ +╚███╔███╔╝███████╗███████╗██║ ██╗╚██████╔╝██║ ╚═╝ ██║ + ╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ +""" P_OPTIONS = """ -Options: -0) Exit -1) Maak/Update klantomgeving(en) -2) Vernietig klantomgeving(en) -3) Recover klantomgeving(en) -4) List klantomgeving(en) - -h) Help +Opties: +0) Afsluiten +1) Maak/Wijzig omgeving(en) +2) Vernietig omgeving(en) +3) Recover omgeving(en) +4) List omgeving(en) """ RE_TEXT = re.compile(r"^\w+$") RE_NUM = re.compile(r"^\d+$") +RE_ANY = re.compile(r".+") -def _take_input(prompt: str, pattern: re.Pattern, default: str="") -> str: - while True: - i = input(prompt) - if i == "": - i = default - if pattern.match(i): - return i +class Prompter: + + def __init__(self, ps1: str = "") -> None: + self.ps1 = ps1 + + def _build_prompt(self, prompt: str) -> str: + return self.ps1 + prompt + + def take_input(self, + prompt: str, + pattern: re.Pattern[Any], + default: str = "") -> str: + prompt = self._build_prompt(prompt) + return Prompter.input(prompt, lambda x: bool(pattern.match(x)), + default) + + def take_choice(self, prompt: str, options: list[str]) -> str: + prompt = self._build_prompt(prompt) + return Prompter.input(prompt, lambda x: x in options) + + @staticmethod + def input(prompt: str, + validate: Callable[[str], bool], + default: str = "") -> str: + while True: + i = input(prompt) + if i == "": + i = default + if validate(i): + return i + + +def get_env_list(customer: str) -> list[str]: + """Fetches and parses a list of a customer's environments + + Args: + customer (str): The customer to fetch environments from + + Returns: + list[str]: The parsed environments + """ + return sub.check_output(["python3", "cli.py", "list", + customer]).decode().strip("\n").split(" ") def main() -> int: + print(P_BANNER) + customer_name = Prompter.input("Wat is uw naam: ", + lambda x: bool(RE_TEXT.match(x))) + + """ + Ensures the necessary customer setup. + """ + envs_dir = path.join("customers", customer_name, "envs") + Path(envs_dir).mkdir(parents=True, exist_ok=True) + + """ + Initializes models for the event loop + """ + p = Prompter(ps1=f"{customer_name} > ") + + """ + The main "event" loop + """ while True: print(P_OPTIONS) - c = input("Input: ") - if c == "h": - continue + c = p.take_input("Keuze: ", RE_ANY) + print(end="\n") if c == "0": + """ + Used to break out of the loop and exit the portal. + """ break if c == "1": - customer_name = _take_input("Customer name (example=opg): ", - RE_TEXT) - env_name = _take_input("Environment name (example=prod): ", - RE_TEXT) - amnt_nginx_web = _take_input( - "Number of nginx webservers (default=1): ", RE_NUM, default="1") - amnt_nginx_lb = _take_input( - "Number of nginx loadbalancers (default=1): ", RE_NUM, default="1") - amnt_psql = _take_input( - "Number of postgres instances (default=1): ", RE_NUM, default="1") - ip_format = _take_input( - "Format of ip (default=192.168.56.{}, `{}` will be replaced with the correct octet at runtime): ", - re.compile(r".+"), default="192.168.56.{}") - ip_int = _take_input( - "Number to start formatting the IP from (default=10): ", - RE_NUM, default="10") + """ + Used to either update or create an environment. + """ + envs = get_env_list(customer_name) + print(f"NOTE: Kies een albestaande omgevingen {envs} of iets nieuws...") + fmt = "Omgevingsnaam:" + env_name = p.take_input(fmt, RE_TEXT) + + templates = ["production", "acceptance", "test", "custom"] + fmt = f"Het type omgeving {templates}: " + template_name = p.take_choice(fmt, templates) + + if template_name == "custom": + """ + Asks for all machine's individually + """ + fmt = "Aantal nginx webservers (default=1): " + amnt_nginx_web = p.take_input(fmt, RE_NUM, default="1") + + fmt = "Aantal nginx loadbalancers (default=1): " + amnt_nginx_lb = p.take_input(fmt, RE_NUM, default="1") + + fmt = "Aantal postgres instances (default=1): " + amnt_psql = p.take_input(fmt, RE_NUM, default="1") + elif template_name == "production": + amnt_nginx_web = "2" + amnt_nginx_lb = "1" + amnt_psql = "1" + elif template_name == "acceptance": + amnt_nginx_web = "1" + amnt_nginx_lb = "0" + amnt_psql = "1" + # elif template_name == "test": + else: + amnt_nginx_web = "1" + amnt_nginx_lb = "0" + amnt_psql = "0" + + """ + Define the format for templating ip-addresses.`{}` is required + and will be subbed at runtime with the correct octet. + """ + print(end="\n") + fmt = "NOTE: `{}` will be replaced with the correct octet at runtime" + print(fmt) + fmt = "Format of ip (default=192.168.56.{}): " + ip_format = p.take_input(fmt, RE_ANY, default="192.168.56.{}") + + fmt = "Number to start formatting the IP from (default=10): " + ip_int = p.take_input(fmt, RE_NUM, default="10") sub.call([ "python3", "cli.py", "create", customer_name, env_name, @@ -61,25 +162,37 @@ def main() -> int: "--num-nginx-lb", amnt_nginx_lb, "--ip-format", ip_format, "--ip-int", ip_int ]) + print(f"Omgeving `{env_name}` successvol gemaakt.") if c == "2": - customer_name = _take_input("Customer name (example=opg): ", - RE_TEXT) - env_name = _take_input("Environment name (example=prod): ", - RE_TEXT) + """ + Deletes all traces of the chosen environment. + """ + envs = get_env_list(customer_name) + fmt = f"Omgevingsnaam {envs}: " + env_name = p.take_choice(fmt, envs) sub.call(["python3", "cli.py", "delete", customer_name, env_name]) if c == "3": - customer_name = _take_input("Customer name (example=opg): ", - RE_TEXT) - env_name = _take_input("Environment name (example=prod): ", - RE_TEXT) + """ + Allows the customer to "force" their environment into the desired `up-state`. + This `up-state` is a representation of the Vagrantfile, Ansible inventory and + .ssh directory of that specific environment. The .ssh directory is only + necessary in case of an existing storage-volume. Otherwise a new one + keypair can be manually created or created using option 1. + """ + envs = get_env_list(customer_name) + fmt = f"Omgevingsnaam {envs}: " + env_name = p.take_choice(fmt, envs) sub.call(["python3", "cli.py", "recover", customer_name, env_name]) if c == "4": - customer_name = _take_input("Customer name (example=opg): ", - RE_TEXT) - sub.call(["python3", "cli.py", "list", customer_name]) + """ + This branch displays the customer's existing environments + """ + print("De volgende omgeving(en) zijn aanwezig: ") + for env in get_env_list(customer_name): + print(f" - {env}") return 0