From 6abdc25bc08a1af43f51572183c5380b4fc44e63 Mon Sep 17 00:00:00 2001 From: HenrithicusGreenson <89527529+HenrithicusGreenson@users.noreply.github.com> Date: Wed, 21 Feb 2024 11:49:13 -0500 Subject: [PATCH] Genisys temp inventory generation (#59) * Create genisysinventory.py * Method prototyping * Method prototyping II * Update Initialization Process.drawio * Include hostname assignment plan in Drawio diagram * First draft of genisys inventory system - Marked lines in the server script that reference the old inventory that will need to be changed - Currently uses the JSON format * Added get_host_name * Fixed string slicing index * Added changing of hostnames to genisys format * Made hostname_prefix a constant var * Added return types to GenisysInventory * Updated http.py to use GenisysInventory instead of Inventory * Added w+ for open method with inventory file to create it if it does not already exist * Added genisys inventory file location to example.yml * Updated __init__.py to use the GenisysInventory instead of Inventory * Removed Inventory.py * Removed legacy code * Changed file mode to r+ to prevent overwriting * Added description to "inventory-file" yaml config * JSON error handling * Updated error handling for opening JSON file * Changed logic to if - then instead of try - except for get_next_hostname * Removed unnecessary if condition * Added way to actually get the hostname from adding the host to the inventory file * Fixed bug with "genisys1" being assigned as a hostname twice * Removed redundant check on file * Removed unused import --------- Co-authored-by: Robert --- documentation/Initialization Process.drawio | 144 ++++++++++++++------ documentation/example.yml | 4 + genisys/server/__init__.py | 8 +- genisys/server/genisysinventory.py | 98 +++++++++++++ genisys/server/http.py | 33 ++--- genisys/server/inventory.py | 39 ------ 6 files changed, 220 insertions(+), 106 deletions(-) create mode 100644 genisys/server/genisysinventory.py delete mode 100644 genisys/server/inventory.py diff --git a/documentation/Initialization Process.drawio b/documentation/Initialization Process.drawio index 63727ce8..83816b08 100644 --- a/documentation/Initialization Process.drawio +++ b/documentation/Initialization Process.drawio @@ -1,130 +1,194 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - + + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + - - + + + + + - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + diff --git a/documentation/example.yml b/documentation/example.yml index 40689f11..778a4475 100644 --- a/documentation/example.yml +++ b/documentation/example.yml @@ -46,6 +46,10 @@ Network: # nessecary if the privkey is encrypted, the first line of the file will be used as the passphrase # when decrytping the key, after stripping any newline password-file: /path/to/key/password + # inventory file defines the path to the file that contains the inventory file managed by genisys + # which contains arbitrary information about the clients booted through genisys which is sent to + # the server via the firstboot script + inventory-file: "/srv/genisys/genisysinventory.json" OS: os: "debian" version-name: bookworm diff --git a/genisys/server/__init__.py b/genisys/server/__init__.py index a7b1eed8..ce20ce84 100644 --- a/genisys/server/__init__.py +++ b/genisys/server/__init__.py @@ -8,7 +8,7 @@ from signal import signal, SIGTERM from typing_extensions import Dict, TypedDict, cast from genisys.config_parser import YAMLParser -from genisys.server.inventory import Inventory +from genisys.server.genisysinventory import GenisysInventory from genisys.server.http import GenisysHTTPServer, GenisysHTTPRequestHandler import genisys.server.tls @@ -41,13 +41,13 @@ def run(config: YAMLParser): os.chdir(workdir) # install additional data for the server to use - ansible_cfg = config.get_section("ansible") - inventory_path = ansible_cfg.get("inventory", DEFAULT_INVENTORY) + network_cfg = config.get_section("Network") + inventory_path = network_cfg["server"]["inventory-file"] # create a server server_address = network.get('ip', '') server_port = server_options.get("port", DEFAULT_PORT) - httpd = GenisysHTTPServer((server_address, server_port), Inventory(inventory_path), config) + httpd = GenisysHTTPServer((server_address, server_port), GenisysInventory(inventory_path), config) # apply TLS if applicable if 'ssl' in server_options: diff --git a/genisys/server/genisysinventory.py b/genisys/server/genisysinventory.py new file mode 100644 index 00000000..61be160d --- /dev/null +++ b/genisys/server/genisysinventory.py @@ -0,0 +1,98 @@ +import json +from os import stat +from typing_extensions import Self, Optional, Dict + + +class GenisysInventory: + """Handles operations relating to the creation of a temporary inventory + file of all of the client machines booted through Genisys, and to store + metadata associated with each client""" + + HOSTNAME_PREFIX = "genisys" + + def __init__(self: Self, filepath: str): + self.filepath = filepath + self.fd = open(filepath, "r+", encoding="utf-8") + + try: + # Attempt to load existing file + self.running_inventory = json.load(self.fd) + except json.decoder.JSONDecodeError: + # If file is empty create empty inventory + if stat(filepath).st_size == 0: + self.running_inventory = {} + else: + # If file is not empty but cannot be read + error_string = 'Error decoding the GenisysInventory JSON file at ' + filepath + raise ValueError(error_string) + + # Ensure that dictionary structure exists + if "genisys" not in self.running_inventory: + self.running_inventory["genisys"] = {"hosts": []} + + # end __init__ + + def __del__(self: Self): + """Close the file handle if this is the last remaining instance.""" + self.fd.close() + + # end __del__ + + def get_host(self: Self, host: str) -> Optional[Dict]: + """Searches the running inventory for a specifc hostname, + if not found returns None""" + host_list = self.running_inventory["genisys"]["hosts"] + + for element in host_list: + if element["hostname"] == host: + return element + + return None + + # end get_host + + def add_host(self: Self, host) -> str: + """Adds a host to the running inventory, takes in + the JSON body of a request and adds it to the running + and on-disk memory. It also updates the hostname in the + inventory for later assignment.""" + if not isinstance(host, dict): + host_dict = json.loads(host) + else: + host_dict = host + + host_dict["hostname"] = self.get_next_hostname() + + self.running_inventory["genisys"]["hosts"].append(host_dict) + self.update_file() + + return host_dict["hostname"] + + # end add_host + + def update_file(self: Self): + """Writes the current running inventory to the + on-disk file""" + self.fd.seek(0) + json.dump(self.running_inventory, self.fd) + + self.fd.flush() + + # end update_file + + def get_next_hostname(self: Self) -> str: + """Returns the next hostname by checking the inventory's most + recent entry and incrementing numeric component at the end""" + if len(self.running_inventory['genisys']['hosts']) > 0: + last_entry = self.running_inventory["genisys"]["hosts"][-1] + else: + return self.HOSTNAME_PREFIX + "1" + + new_value = int(last_entry["hostname"][7:]) + 1 + + return self.HOSTNAME_PREFIX + str(new_value) + + # end get_next_hostname + + +# end GenisysInventory diff --git a/genisys/server/http.py b/genisys/server/http.py index d3d5cbe6..2392dfb8 100644 --- a/genisys/server/http.py +++ b/genisys/server/http.py @@ -1,15 +1,14 @@ import sys import json -import subprocess from http.server import HTTPServer, BaseHTTPRequestHandler from typing_extensions import Self, Tuple, cast from genisys.config_parser import YAMLParser -from genisys.server.inventory import Inventory +from genisys.server.genisysinventory import GenisysInventory def generate_response(status_code: int, message: str) -> bytes: """Generates a JSON formated HTTP response with the given status and message""" statuses = {200: "OK", 400: "Bad Request", 500: "Internal Server Error"} - response_body = '{"message":"' + message + '"}' + response_body = '{"new-hostname":"' + message + '"}' response = f"HTTP/1.1 {status_code} {statuses[status_code]}\n" response += f"Content-Length: {len(response_body)}\n" response += "Content-Type: application/json\n" @@ -19,7 +18,7 @@ def generate_response(status_code: int, message: str) -> bytes: class GenisysHTTPServer(HTTPServer): """Subclass to manage shared state between server requests""" - def __init__(self: Self, bind: Tuple[str, int], inventory: Inventory, config: YAMLParser): + def __init__(self: Self, bind: Tuple[str, int], inventory: GenisysInventory, config: YAMLParser): super().__init__(bind, GenisysHTTPRequestHandler) self.inventory = inventory self.config = config @@ -27,7 +26,8 @@ def __init__(self: Self, bind: Tuple[str, int], inventory: Inventory, config: YA class GenisysHTTPRequestHandler(BaseHTTPRequestHandler): """Process client "hello"s by running ansible playbooks on a received POST""" def do_POST(self: Self): - """On POST, store the client in the Ansible inventory and run playbooks""" + """On POST, store the client in the Genisys Inventory and in return send + a response with the client's new hostname""" try: # cast server since we know it will only be called from Genisys server = cast(GenisysHTTPServer, self.server) @@ -36,28 +36,15 @@ def do_POST(self: Self): content_length = int(self.headers['Content-Length']) body = json.loads(self.rfile.read(content_length)) - # validate the declared IP and hostname - if body['ip'] != self.client_address[0] \ - or server.inventory.get(body['hostname']) is not None: + # validate the declared IP + if body['ip'] != self.client_address[0]: self.wfile.write(generate_response(400, 'Declared IP is not valid.')) - # add the host to the inventory file - server.inventory.add_host(body['hostname'], { 'ansible_host': body['ip'] }) - - # run the playbooks - ansible_cmd = [ - 'ansible-playbook', - '--inventory', - server.inventory.filename, - '--limit', - body['hostname'] - ] - for playbook in server.config.get_section('ansible').get('playbooks', []): - ansible_cmd.append(playbook) - subprocess.run(ansible_cmd, check=True) + # Add the host to the GenisysInventory file + client_new_hostname = server.inventory.add_host(body) # send success back to the client - self.wfile.write(generate_response(200, 'success')) + self.wfile.write(generate_response(200, client_new_hostname)) except Exception as e: print(f"ERROR: {e}", file=sys.stderr) self.wfile.write( diff --git a/genisys/server/inventory.py b/genisys/server/inventory.py deleted file mode 100644 index 61a07d7a..00000000 --- a/genisys/server/inventory.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing_extensions import Self, Optional, Dict -import yaml - -class Inventory: - """Manages a Yaml-formatted Ansible inventory file by keeping an in-memory "shadow" hosts file - up to date with the on-disk representation. - """ - instances = {} - - def __init__(self: Self, filepath: str): - """Open a file handle and initialize the in-memory representation.""" - # create a new instance - self.filename = filepath - self.fd = open(filepath, 'r+', encoding='utf-8') - self.contents = yaml.safe_load(self.fd) - - # ensure the inventory group exists - if 'genisys' not in self.contents: - self.contents['genisys'] = {'hosts': {}} - - def __del__(self: Self): - """Close the file handle if this is the last remaining instance.""" - self.fd.close() - - def get(self: Self, hostname: str) -> Optional[Dict]: - """Returns None if the host doesn't exist or the settings if it does""" - return self.contents['genisys']['hosts'].get(hostname) - - def add_host(self: Self, hostname: str, settings: Optional[Dict] = None): - """Adds the host to the inventory, updating the in-memory and on-disk file.""" - # Update the in-memory representation - self.contents['genisys']['hosts'][hostname] = settings or {} - - # Truncate the current inventory file - self.fd.seek(0) - - # Write the new invetory to disk - yaml.dump(self.contents, self.fd) - self.fd.flush()