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
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,6 +137,9 @@ class MachineResources:
class InventoryWriter:
"""
Helper class for generating Ansible inventory files.
"""
def __init__(self, location: str) -> None:
self._file_handle = Path(location)
@ -112,7 +168,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):
@ -137,6 +193,10 @@ def recover_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:
raise Exception("At least one item should be deployed")
@ -250,7 +310,7 @@ def main() -> int:
# CLI definition for positional arg "create"
cenv_parser = sub_parser.add_parser("create",
help="create a new environment")
help="create/update an environment")
cenv_parser.add_argument("customer_name",
type=str,
help="name of the customer")
@ -282,7 +342,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"

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash
sudo apt update --yes && sudo apt upgrade --yes
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
collections:
- 'community.postgresql'
- community.postgresql

View File

@ -1,57 +1,186 @@
#!/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
from enum import Enum
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)
"""
class PortalOptions(Enum):
EXIT = "0"
CREATE = "1"
DESTROY = "2"
RECOVER = "3"
LIST = "4"
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) -> str:
while True:
i = input(prompt)
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)
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:
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:
print(P_OPTIONS)
c = input("Input: ")
if c == "h":
continue
choice = p.take_input("Keuze: ", RE_ANY)
print(end="\n")
if c == "0":
if choice == PortalOptions.EXIT.value:
"""
Used to break out of the loop and exit the portal.
"""
break
if c == "1":
customer_name = _take_input("Customer name (example=opc): ",
RE_TEXT)
env_name = _take_input("Environment name (example=prod): ",
RE_TEXT)
amnt_nginx_web = _take_input(
"Number of nginx webservers (example=1): ", RE_NUM)
amnt_nginx_lb = _take_input(
"Number of nginx loadbalancers (example=1): ", RE_NUM)
amnt_psql = _take_input(
"Number of postgres instances (example=1): ", RE_NUM)
ip_format = _take_input(
"Format of ip (example=192.168.56.{}, `{}` will be replaced with the correct octet at runtime): ",
re.compile(r".+"))
ip_int = _take_input(
"Number to start formatting the IP from (example=10): ",
RE_NUM)
if choice == PortalOptions.CREATE.value:
"""
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)
# Loop until customer has confirmed their desired combination of machines
while True:
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"
# 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([
"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,
"--ip-int", ip_int
])
print(f"Omgeving `{env_name}` successvol gemaakt.")
if c == "2":
customer_name = _take_input("Customer name (example=opc): ",
RE_TEXT)
env_name = _take_input("Environment name (example=prod): ",
RE_TEXT)
if choice == PortalOptions.DESTROY.value:
"""
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=opc): ",
RE_TEXT)
env_name = _take_input("Environment name (example=prod): ",
RE_TEXT)
if choice == PortalOptions.RECOVER.value:
"""
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=opc): ",
RE_TEXT)
sub.call(["python3", "cli.py", "list", customer_name])
if choice == PortalOptions.LIST.value:
"""
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

View File

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