Compare commits

..

10 Commits

5 changed files with 262 additions and 58 deletions

76
cli.py
View File

@ -20,24 +20,61 @@ TEMPLATE_DIR = "./templates"
#region util #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 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 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) ivalue = int(value)
if ivalue <= 0: if ivalue < 0:
raise Exception("Supplied number must be >= 0") raise Exception(f"Supplied number must be >= 0")
return ivalue return ivalue
def encode_member(member: str, mapping: Mapping[str, Any]) -> str: 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()]) return member + " " + " ".join([f"{k}={v}" for k, v in mapping.items()])
def iter_ips(ip_format: str, start_octet: int): 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_int = start_octet
ip_fmt = ip_format ip_fmt = ip_format
while ip_int < 255: 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] = {}): 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() c = Path(src).read_text()
t: Template = Template(c) t: Template = Template(c)
r = t.render(mapping) r = t.render(mapping)
@ -63,6 +107,15 @@ class MachineResources:
@staticmethod @staticmethod
def from_prompt() -> "MachineResources": def from_prompt() -> "MachineResources":
"""Generate `MachineResources` from prompt.
Raises:
Exception
Exception
Returns:
MachineResources
"""
cpus = input( cpus = input(
"How many processors would you like to assign (default=1): ") "How many processors would you like to assign (default=1): ")
if not cpus: if not cpus:
@ -84,6 +137,9 @@ class MachineResources:
class InventoryWriter: class InventoryWriter:
"""
Helper class for generating Ansible inventory files.
"""
def __init__(self, location: str) -> None: def __init__(self, location: str) -> None:
self._file_handle = Path(location) self._file_handle = Path(location)
@ -112,7 +168,7 @@ def list_envs(args: argparse.Namespace):
customer_path = path.join("customers", args.customer_name, "envs") customer_path = path.join("customers", args.customer_name, "envs")
print(" ".join(os.listdir(customer_path))) print(" ".join(os.listdir(customer_path)))
except FileNotFoundError: 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): def delete_env(args: argparse.Namespace):
@ -137,6 +193,10 @@ def recover_env(args: argparse.Namespace):
def create_env(args: argparse.Namespace): def create_env(args: argparse.Namespace):
"""
Managing environments works in a declarative way and thus it can
also be used for updating.
"""
if (args.num_nginx_web + args.num_nginx_lb + args.num_postgres) == 0: if (args.num_nginx_web + args.num_nginx_lb + args.num_postgres) == 0:
raise Exception("At least one item should be deployed") raise Exception("At least one item should be deployed")
@ -250,7 +310,7 @@ def main() -> int:
# CLI definition for positional arg "create" # CLI definition for positional arg "create"
cenv_parser = sub_parser.add_parser("create", cenv_parser = sub_parser.add_parser("create",
help="create a new environment") help="create/update an environment")
cenv_parser.add_argument("customer_name", cenv_parser.add_argument("customer_name",
type=str, type=str,
help="name of the customer") help="name of the customer")
@ -282,7 +342,7 @@ def main() -> int:
cenv_parser.add_argument("--ip-int", cenv_parser.add_argument("--ip-int",
type=check_positive, type=check_positive,
help="4th octet to start at", help="4th octet to start at",
default=10) default="10")
cenv_parser.set_defaults(func=create_env) cenv_parser.set_defaults(func=create_env)
# CLI definition for positional arg "delete" # CLI definition for positional arg "delete"

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
sudo apt update --yes && sudo apt upgrade --yes sudo apt update --yes && sudo apt upgrade --yes
sudo apt install --yes virtualbox vagrant ansible sudo apt install --yes virtualbox vagrant ansible
ansible-galaxy install -r ./requirements.yml ansible-galaxy collection install -r requirements.yml

View File

@ -1,4 +1,4 @@
--- ---
# ansible-galaxy requirements for this repository # ansible-galaxy requirements for this repository
collections: collections:
- 'community.postgresql' - community.postgresql

View File

@ -1,57 +1,186 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from os import path
import subprocess as sub import subprocess as sub
import re import re
from typing import Any, Callable
from enum import Enum
P_BANNER = """
██╗ ██╗███████╗██╗ ██╗ ██╗ ██████╗ ███╗ ███╗
██║ ██║██╔════╝██║ ██║ ██╔╝██╔═══██╗████╗ ████║
██║ █╗ ██║█████╗ ██║ █████╔╝ ██║ ██║██╔████╔██║
██║███╗██║██╔══╝ ██║ ██╔═██╗ ██║ ██║██║╚██╔╝██║
╚███╔███╔╝███████╗███████╗██║ ██╗╚██████╔╝██║ ╚═╝ ██║
╚══╝╚══╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
"""
P_OPTIONS = """ P_OPTIONS = """
Options: Opties:
0) Exit 0) Afsluiten
1) Maak/Update klantomgeving(en) 1) Maak/Wijzig omgeving(en)
2) Vernietig klantomgeving(en) 2) Vernietig omgeving(en)
3) Recover klantomgeving(en) 3) Recover omgeving(en)
4) List klantomgeving(en) 4) List omgeving(en)
h) Help
""" """
class PortalOptions(Enum):
EXIT = "0"
CREATE = "1"
DESTROY = "2"
RECOVER = "3"
LIST = "4"
RE_TEXT = re.compile(r"^\w+$") RE_TEXT = re.compile(r"^\w+$")
RE_NUM = re.compile(r"^\d+$") RE_NUM = re.compile(r"^\d+$")
RE_ANY = re.compile(r".+")
def _take_input(prompt: str, pattern: re.Pattern) -> str: class Prompter:
while True:
i = input(prompt) def __init__(self, ps1: str = "") -> None:
if pattern.match(i): self.ps1 = ps1
return i
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)
def take_confirmation(self, prompt: str, default: str = "y") -> bool:
prompt = self._build_prompt(prompt)
i = Prompter.input(prompt, lambda x: x.lower() in ["y", "n"], default=default)
return i == "y"
@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: 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.
NOTE: The customer and the `envs` directory inside of it never get removed.
"""
envs_dir = path.join("customers", customer_name, "envs")
Path(envs_dir).mkdir(parents=True, exist_ok=True)
p = Prompter(ps1=f"{customer_name} > ")
while True: while True:
print(P_OPTIONS) print(P_OPTIONS)
c = input("Input: ") choice = p.take_input("Keuze: ", RE_ANY)
if c == "h": print(end="\n")
continue
if c == "0": if choice == PortalOptions.EXIT.value:
"""
Used to break out of the loop and exit the portal.
"""
break break
if c == "1": if choice == PortalOptions.CREATE.value:
customer_name = _take_input("Customer name (example=opc): ", """
RE_TEXT) Used to either update or create an environment.
env_name = _take_input("Environment name (example=prod): ", """
RE_TEXT) envs = get_env_list(customer_name)
amnt_nginx_web = _take_input( print(
"Number of nginx webservers (example=1): ", RE_NUM) f"NOTE: Kies een albestaande omgevingen {envs} of iets nieuws..."
amnt_nginx_lb = _take_input( )
"Number of nginx loadbalancers (example=1): ", RE_NUM) fmt = "Omgevingsnaam: "
amnt_psql = _take_input( env_name = p.take_input(fmt, RE_TEXT)
"Number of postgres instances (example=1): ", RE_NUM)
ip_format = _take_input( # Loop until customer has confirmed their desired combination of machines
"Format of ip (example=192.168.56.{}, `{}` will be replaced with the correct octet at runtime): ", while True:
re.compile(r".+")) templates = ["production", "acceptance", "test", "custom"]
ip_int = _take_input( fmt = f"Het type omgeving {templates}: "
"Number to start formatting the IP from (example=10): ", template_name = p.take_choice(fmt, templates)
RE_NUM) 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"
# TODO: migrate different machine types to a dict model
print(end="\n")
print("Deze omgeving bevat:")
if amnt_nginx_web > "0":
print(f" - {amnt_nginx_web} Nginx webserver(s)")
if amnt_nginx_lb > "0":
print(f" - {amnt_nginx_lb} Nginx loadbalancer(s)")
if amnt_psql > "0":
print(f" - {amnt_psql} Postgres instance(s)")
print(end="\n")
if p.take_confirmation("Bevestig (Y/n): "):
break
"""
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([ sub.call([
"python3", "cli.py", "create", customer_name, env_name, "python3", "cli.py", "create", customer_name, env_name,
@ -59,25 +188,37 @@ def main() -> int:
"--num-nginx-lb", amnt_nginx_lb, "--ip-format", ip_format, "--num-nginx-lb", amnt_nginx_lb, "--ip-format", ip_format,
"--ip-int", ip_int "--ip-int", ip_int
]) ])
print(f"Omgeving `{env_name}` successvol gemaakt.")
if c == "2": if choice == PortalOptions.DESTROY.value:
customer_name = _take_input("Customer name (example=opc): ", """
RE_TEXT) Deletes all traces of the chosen environment.
env_name = _take_input("Environment name (example=prod): ", """
RE_TEXT) 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]) sub.call(["python3", "cli.py", "delete", customer_name, env_name])
if c == "3": if choice == PortalOptions.RECOVER.value:
customer_name = _take_input("Customer name (example=opc): ", """
RE_TEXT) Allows the customer to "force" their environment into the desired `up-state`.
env_name = _take_input("Environment name (example=prod): ", This `up-state` is a representation of the Vagrantfile, Ansible inventory and
RE_TEXT) .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]) sub.call(["python3", "cli.py", "recover", customer_name, env_name])
if c == "4": if choice == PortalOptions.LIST.value:
customer_name = _take_input("Customer name (example=opc): ", """
RE_TEXT) This branch displays the customer's existing environments
sub.call(["python3", "cli.py", "list", customer_name]) """
print("De volgende omgeving(en) zijn aanwezig: ")
for env in get_env_list(customer_name):
print(f" - {env}")
return 0 return 0

View File

@ -3,3 +3,6 @@ private_key_file = "./.ssh/id_rsa"
remote_user = "vagrant" remote_user = "vagrant"
inventory = ./inventory inventory = ./inventory
host_key_checking = False host_key_checking = False
[ssh_connection]
retries=2