diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3cf8c6d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +env +__pycache__ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a566312..46eb3b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,39 @@ -# Use official Python runtime as a parent image -FROM python:3.9 - -#Set the working directory in the container -WORKDIR /app - -#Copy the current directory contenst into container at /app -COPY server.py /app - -#Install Flask -RUN pip install Flask - -#Set SERVER_ID env variable -ENV SERVER_ID "1" - -#Run app.py when container launches -CMD ["python", "server.py"] +FROM python:3.10.12-slim + +WORKDIR /app + +RUN pip install flask requests asyncio httpx Flask-APScheduler matplotlib + +RUN apt-get update +RUN apt-get -y install sudo + +RUN apt-get -y update +RUN apt-get -y install ca-certificates curl gnupg +RUN install -m 0755 -d /etc/apt/keyrings +RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg +RUN chmod a+r /etc/apt/keyrings/docker.gpg + +RUN echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null +RUN apt-get -y update + +RUN apt-get -y install docker-ce-cli + +ENV USER=theuser +RUN adduser --home /home/$USER --disabled-password --gecos GECOS $USER \ + && echo "$USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/$USER \ + && chmod 0440 /etc/sudoers.d/$USER \ + && groupadd docker \ + && usermod -aG docker $USER \ + && chsh -s /bin/zsh $USER +USER $USER + +ENV HOME=/home/$USER + +COPY . /app + +CMD ["python", "load_balancer.py"] + +EXPOSE 5000 diff --git a/README.md b/README.md index fb8796b..4f422a8 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,159 @@ ### This repository contains the implementation of a load balancer using Docker, aimed at efficiently distributing requests among several servers. The load balancer routes requests from multiple clients asynchronously to ensure nearly even distribution of the load across the server replicas. ## Task Description -### Task One -1. Implement a load balancer that routes requests among several servers asynchronously. -2. Use Docker to manage the deployment of the load balancer and servers within a Docker network. -3. Implement a simple web server in Python to handle HTTP requests on specified endpoints (/home and /heartbeat). -4. Use consistent hashing data structure for efficient request distribution. -5. Ensure fault tolerance by spawning new replicas of servers in case of failures. -6. Write clean and well-documented code, along with a README file detailing design choices, assumptions, testing, and performance analysis. -7. Provide a Makefile for deploying and running the code, and version control the project using Git. +### TASK ONE: SERVER +* **Endpoint (/home, method=GET)**: This endpoint returns a string with a unique identifier to distinguish among the replicated server containers. For instance, if a client requests this endpoint and the load balancer schedules the request to Server: 3, then an example return string would be Hello from Server: +Hint: Server ID can be set as an env variable while running a container instance from the docker image of the server + - Command: ```curl -X GET -H "Content-type: application/json" http://localhost:5000/home``` + - Response: ```{"message": "Hello from server_1"}}``` + +* **Endpoint (/heartbeat, method=GET)**: This endpoint sends heartbeat responses upon request. The load balancer further +uses the heartbeat endpoint to identify failures in the set of containers maintained by it. Therefore, you could send an empty +response with a valid response code. + - Command: ```curl -X GET -H "Content-type: application/json" http://localhost:5000/heartbeat``` + - Response: ```{}``` + +### TASK TWO: CONSISTENT HASHING + * In this task, you need to implement a consistent hash map using an array, linked list, or any other data structure. This map data +structure details are given in Appendix A. Use the following parameters and hash functions for your implementation. + - Number of Server Containers managed by the load balancer (N) = 3 + - Total number of slots in the consistent hash map (#slots) = 512 + - Number of virtual servers for each server container (K) = log (512) = 9 2 + - Hash function for request mapping H(i) = i + 2i + 17 2 2 + - Hash function for virtual server mapping Φ(i, j) = i + j + 2j + 25 + +* **Consistant Hashing Algorithm Implementation** + - Implementation Details: + - Uses array-based data structure + - Number of Server Containers (N): 3 + - Total number of slots in the consistent hash map (#slots): 512 + - Number of virtual servers for each server container (K): log(512) = 9 + - Hash functions used: + - Hash function for request mapping H(i): H(i) = i + 2i + 17 + - Hash function for virtual server mapping Φ(i, j): Φ(i, j) = i + j + 2j + 25 + +### TASK THREE: LOAD BALANCER +* **Endpoint (/add, method=POST)**: This endpoint adds new server instances in the load balancer to scale up with increasing client numbers in the system. The endpoint expects a JSON payload that mentions the number of newinstances and their preferred hostnames (same as the container name in docker) in a list. An example request and response is below. + - Command: ``` curl -X POST -H "Content-Type: application/json" --data-binary "{\"n\": 4, \"hostnames\": [\"server11\", \"server12\", \"server13\", \"new_servers4\"]}" http://localhost:5000/add ``` + - Response: ```{"message": {"N": 5,"replicas": ["server12","new_servers4","server_1","server11","server13"]}, + "status": "successful"}``` + * Perform simple sanity checks on the request payload and ensure that hostnames mentioned in the Payload are less than or equal to newly added instances. Note that the hostnames are preferably set. One can never set the hostnames. In that case, the hostnames (container names) are set randomly. However, sending a hostname list with greater length than newly added instances will result in an error. + - Command: ```curl -X POST -H "Content-Type: application/json" --data-binary "{\"n\": 2, \"hostnames\": [\"server11\", \"server12\", \"server13\", \"new_servers4\"]}" http://localhost:5000/add``` + - Response: ```{"message": " Length of hostname list is more than newly added instances","status": "failure"}``` + +* **Endpoint (/rep, method=GET)**: This endpoint only returns the status of the replicas managed by the load balancer. The response contains the number of replicas and their hostname in the docker internal network:n1 as mentioned in Fig. 1. An example response is shown below. + - Command: ``` curl -X GET -H "Content-type: application/json" http://localhost:5000/rep ``` + - Response: ```{"message": {"N": 0,"replicas": []},"status": "successful"}``` + +* **Endpoint (/rm, method=DELETE)**: This endpoint removes server instances in the load balancer to scale down with decreasing client or system maintenance. The endpoint expects a JSON payload that mentions the number of instances to be removed and their preferred hostnames (same as container name in docker) in a list. An example request and response is below. + - Command: ```curl -X POST -H "Content-Type: application/json" --data-binary "{\"n\": 2, \"hostnames\": [\"server11\", \"server12\"] +}" http://localhost:5000/rm ``` + - Response: ```{"message": {"N": 3,"replicas": ["new_servers4","server_1","server13"]},"status": "successful"}``` + - *shows that the server12 and server11 have been removed successfully* + +* **Endpoint (/checkpoint, method=GET)**: This endpoint is used to view all of the servers that are currently being used. Additonally, it provides a list of the amount each server has that aids in visualising the load balancing. An example request and response is below. + - Command: ```curl -X GET -H "Content-type: application/json" http://localhost:5000/checkpoint``` + - Response: ```{"requests": {"new_servers4": 10,"server13": 28,"server_1": 63}, "servers": ["new_servers4","server_1","server13"]}``` + - *Using the existing servers, "new_servers4","server_1","server13", you are able to see the distribution of requests to each server after sending 100 requests to the ```/home``` endpoint* + +* **Endpoint (/graph, method=GET)**: This endpoint is used to create a bar graph using the distribution data from the ```/checkpoint``` endpoint, where the server names are on the x-axis, and the number of requests are on the y-axis. An example request and response is below. + - Command: ```curl -X GET -H "Content-type: application/json" -o endpoint_example.png http://localhost:5000/graph``` + - Response: ``` % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 100 23576 100 23576 0 0 57646 0 --:--:-- --:--:-- --:--:-- 57784``` + - *a new graph is created, "endpoint_example.png", that shows the distribution as seen in ```checkpoint``` data* + - Graph: + - + +### TASK FOUR: ANALYSIS +* Launch 10000 async requests on N = 3 server containers and report the request count handled by each server instance in a bar chart. Explain your observations in the graph and your view on the performance. + - After adding three servers ('1', '2', '3'): + - Ran 10,000 requests routed to the ```/home``` and used the endpoint ```/server_stats``` to view the number of requests each server recieved + - Graph: + - + - Observations: + - Servers 1 and 2 bear the majority of the load, with Server 2 consistently handling the highest load followed by Server 1. + - In contrast, Server 3 carries a significantly lighter load compared to the other servers, indicating a potential imbalance in the load distribution strategy. + + +* Next, increment N from 2 to 6 and launch 10000 requests on each such increment. Report the average load of the servers at each run in a line chart. Explain your observations in the graph and your view on the scalability of the load balancer implementation + - When N = 2 + - Graph: + - + - When N = 3 + - Graph: + - + - When N = 4 + - Graph: + - + - When N = 5 + - Graph: + - + - When N = 6 + - Graph: + - + - Average load accross each server with 10,000 requests: + - Graph: + - + - Observation: + - In the case of two servers, both servers handle a relatively similar load, with Server 1 slightly edging out Server 2. + - This suggests a relatively balanced load distribution, although there is still room for improvement to ensure equitable resource utilization across the servers. + +* Test all endpoints of the load balancer and show that in case of server failure, the load balancer spawns a new instance quickly to handle the load. + - By simulating a forced exit of a server and the latency betweeen a new server arriving, this is shown in a graph below where ```N``` represents the amount of servers: + - | Number of Servers | Latency for New Server Spawn | + |-------------------|------------------------------| + | 2 servers | 1.2313279 seconds | + | 3 servers | 0.8681224 seconds | + | 4 servers | 0.2518046 seconds | + | 5 servers | 0.2355351 seconds | + +* Finally, modify the hash functions H(i), Φ(i, j) and report the observations from (A-1) and (A-2). + - To achieve a better distribution, the following changes were made to the consistent hashing function: + - Number of Server Containers managed by the load balancer (N) = 3 + - Total number of slots in the consistent hash map (#slots) = 512 + - Number of virtual servers for each server container (K) = 20 + - Hash function for request mapping H(i) = Rid % M + - Hash function for virtual server mapping Φ(i, j) = (Sid + i) % M + - Although there is still a bias towards one of the servers, the load balancer is able to effectively balance the load accross all respective servers + - Additionally, after closing a healthy server using the ```/rm``` endpoint, all of its previous requests are distributed amongst the existing healthy servers + + * Launch 10000 async requests on N = 3 server containers and report the request count handled by each server instance in a bar chart. Explain your observations in the graph and your view on the performance. + - After adding three servers ('1', '2', '3'): + - Graph: + - + - Observations: + - Server 1 consistently handles the highest load, followed by Server 2 and then Server 3. Despite minor variations, this trend remains consistent across the different server counts. + - The performance of the load balancer appears effective in distributing the load somewhat evenly across the servers, with Server 1 consistently bearing the highest load + + * Next, increment N from 2 to 6 and launch 10000 requests on each such increment. Report the average load of the servers at each run in a line chart. Explain your observations in the graph and your view on the scalability of the load balancer implementation + - When N = 2 + - Graph: + - + - When N = 3 + - Graph: + - + - When N = 4 + - Graph: + - + - When N = 5 + - Graph: + - + - When N = 6 + - Graph: + - + - Average load accross each server with 10,000 requests: + - Graph: + - + - Observation: + - In the case of two servers, Server 1 consistently handles a higher load compared to Server 2, suggesting an imbalance in the load distribution. + - Despite this, the load balancer demonstrates a basic ability to distribute the load across multiple servers as compared to the first consistant hashing which happened to skew more into the first two servers + + + + + + ## Group Members 1. 137991 - Jesse Kamau diff --git a/consistent_hash.py b/consistent_hash.py new file mode 100644 index 0000000..957a311 --- /dev/null +++ b/consistent_hash.py @@ -0,0 +1,59 @@ +import hashlib + +class ConsistantHash: + def __init__(self): + self.slots = 512 + self.k = 20 + self.consistant_hash = [0] * self.slots + self.map = {} + + def h(self, i: int) -> int: + return (i*i + 2*i + 17) % self.slots + + def fi(self, i: int, j: int) -> int: + return (i*i + j*j + 2*j + 25) % self.slots + + def get_server_id(self, server: str) -> int: + return int(hashlib.md5(server.encode()).hexdigest(), 16) % self.slots + + def build(self, server_list: set[str]): + for server in server_list: + self.add_server_to_hash(server) + + def get_server_from_request(self, request_id: int) -> str: + req_pos = self.h(request_id) + for i in range(self.slots): + if self.consistant_hash[req_pos] != 0: + return self.consistant_hash[req_pos] + else: + req_pos = (req_pos + 1) % self.slots + return None + + def add_server_to_hash(self, server: str): + server_id = self.get_server_id(server) + for j in range(self.k): + pos = self.fi(server_id, j) + if self.consistant_hash[pos] == 0: + self.consistant_hash[pos] = server + else: + original_pos = pos + while self.consistant_hash[pos] != 0: + pos = (pos + 1) % self.slots + if pos == original_pos: + raise Exception("Hash table is full") + self.consistant_hash[pos] = server + self.map[server] = server_id + + def remove_server_from_hash(self, server: str, request_counts: dict): + server_id = self.map[server] + for i in range(self.slots): + if self.consistant_hash[i] == server: + self.consistant_hash[i] = 0 + del self.map[server] + + total_requests = request_counts.pop(server, 0) + servers = list(self.map.keys()) + if servers: + requests_per_server = total_requests // len(servers) + for s in servers: + request_counts[s] += requests_per_server diff --git a/images/first/2 servers.png b/images/first/2 servers.png new file mode 100644 index 0000000..e2d6387 Binary files /dev/null and b/images/first/2 servers.png differ diff --git a/images/first/3 servers.png b/images/first/3 servers.png new file mode 100644 index 0000000..c8f19f8 Binary files /dev/null and b/images/first/3 servers.png differ diff --git a/images/first/4 servers.png b/images/first/4 servers.png new file mode 100644 index 0000000..ca0ef01 Binary files /dev/null and b/images/first/4 servers.png differ diff --git a/images/first/5 servers.png b/images/first/5 servers.png new file mode 100644 index 0000000..72d5fbb Binary files /dev/null and b/images/first/5 servers.png differ diff --git a/images/first/6 servers.png b/images/first/6 servers.png new file mode 100644 index 0000000..5658f2b Binary files /dev/null and b/images/first/6 servers.png differ diff --git a/images/first/average.png b/images/first/average.png new file mode 100644 index 0000000..91b625e Binary files /dev/null and b/images/first/average.png differ diff --git a/images/second/2 servers.png b/images/second/2 servers.png new file mode 100644 index 0000000..91630d1 Binary files /dev/null and b/images/second/2 servers.png differ diff --git a/images/second/3 servers.png b/images/second/3 servers.png new file mode 100644 index 0000000..ebecc48 Binary files /dev/null and b/images/second/3 servers.png differ diff --git a/images/second/4 servers.png b/images/second/4 servers.png new file mode 100644 index 0000000..b9bf553 Binary files /dev/null and b/images/second/4 servers.png differ diff --git a/images/second/5 servers.png b/images/second/5 servers.png new file mode 100644 index 0000000..9190753 Binary files /dev/null and b/images/second/5 servers.png differ diff --git a/images/second/6 servers.png b/images/second/6 servers.png new file mode 100644 index 0000000..db88a1b Binary files /dev/null and b/images/second/6 servers.png differ diff --git a/images/second/average.png b/images/second/average.png new file mode 100644 index 0000000..dcb811f Binary files /dev/null and b/images/second/average.png differ diff --git a/images/task4_a42_2servers.png b/images/task4_a42_2servers.png new file mode 100644 index 0000000..2ceb372 Binary files /dev/null and b/images/task4_a42_2servers.png differ diff --git a/images/task4_a42_3servers.png b/images/task4_a42_3servers.png new file mode 100644 index 0000000..724c0ee Binary files /dev/null and b/images/task4_a42_3servers.png differ diff --git a/images/task4_a42_4servers.png b/images/task4_a42_4servers.png new file mode 100644 index 0000000..2114a31 Binary files /dev/null and b/images/task4_a42_4servers.png differ diff --git a/images/task4_a42_5servers.png b/images/task4_a42_5servers.png new file mode 100644 index 0000000..df0b3eb Binary files /dev/null and b/images/task4_a42_5servers.png differ diff --git a/images/task4_a42_6servers.png b/images/task4_a42_6servers.png new file mode 100644 index 0000000..01d5bc3 Binary files /dev/null and b/images/task4_a42_6servers.png differ diff --git a/load_balancer.py b/load_balancer.py new file mode 100644 index 0000000..e99c2e1 --- /dev/null +++ b/load_balancer.py @@ -0,0 +1,232 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from flask import Flask, jsonify, request, Response +from subprocess import Popen, PIPE +from consistent_hash import ConsistantHash +from utils import ( + errHostnameListInconsistent, + errInvalidRequest, + get_container_rm_command, + get_container_run_command, + get_random_name, + get_random_number, + get_server_health, + get_unhealty_servers, + validateRequest, +) + +import signal +import asyncio +import logging as log +import random +import matplotlib.pyplot as plt +import io + + +NETWORK_NAME = "load_balancer_network" + +app = Flask(__name__) +consistant_hash = ConsistantHash() +servers = {'Server-1', 'Server-2'} +request_counts = {server: 0 for server in servers} + +def check_servers(): + global servers + + log.debug("Checking server health...") + unhealthy_servers = asyncio.run(get_unhealty_servers(servers)) + print("Unhealthy servers: ", unhealthy_servers, flush=True) + for server in unhealthy_servers: + print(f"Removing {server}", flush=True) + command = get_container_rm_command(server, NETWORK_NAME) + res = Popen(command, stdout=PIPE, stderr=PIPE) + stdout, stderr = res.communicate() + if res.returncode != 0: + log.error(f"Error in removing {server}: {stderr.decode().strip()}") + else: + log.info(f"Removed {server}") + consistant_hash.remove_server_from_hash(server, request_counts) + servers.remove(server) + request_counts.pop(server, None) + +sched = BackgroundScheduler(daemon=True) +sched.add_job(check_servers, 'interval', seconds=30) +sched.start() + +@app.route('/heartbeat', methods=['GET']) +def heartbeat(): + return jsonify({}), 200 + +@app.route('/rep', methods=['GET']) +def rep(): + global servers + + healthy_servers = asyncio.run(get_server_health(servers)) + output = { + 'message': { + 'N': len(healthy_servers), + 'replicas': healthy_servers + }, + 'status': 'successful' + } + return jsonify(output), 200 + +@app.route('/add', methods=['POST']) +def add(): + global servers, request_counts + + n, hostnames, err = validateRequest(request) + if err is errInvalidRequest: + return jsonify({ + 'message': ' Request payload in invalid format', + 'status': 'failure' + }), 400 + if err is errHostnameListInconsistent: + return jsonify({ + 'message': ' Length of hostname list is more than newly added instances', + 'status': 'failure' + }), 400 + + random_servers = 0 + for hostname in hostnames: + if hostname in servers: + log.info(f"Server {hostname} already exists") + random_servers += 1 + continue + + command = get_container_run_command(hostname, NETWORK_NAME) + res = Popen(command, stdout=PIPE, stderr=PIPE) + stdout, stderr = res.communicate() + if res.returncode != 0: + log.error(f"Error in adding {hostname}: {stderr.decode().strip()}") + else: + servers.add(hostname) + request_counts[hostname] = 0 + consistant_hash.add_server_to_hash(hostname) + log.info(f"Added {hostname}") + + random_servers += n - len(hostnames) + random_servers_up = 0 + while random_servers_up < random_servers: + server_name = get_random_name(7) + command = get_container_run_command(server_name) + res = Popen(command, stdout=PIPE, stderr=PIPE) + stdout, stderr = res.communicate() + if res.returncode != 0: + log.error(f"Error in adding server with random name {server_name}: {stderr.decode().strip()}") + else: + servers.add(server_name) + request_counts[server_name] = 0 + consistant_hash.add_server_to_hash(server_name) + log.info(f"Added {server_name}") + random_servers_up += 1 + + output = { + 'message': { + 'N': len(servers), + 'replicas': list(servers) + }, + 'status': 'successful' + } + return jsonify(output), 200 + +@app.route('/rm', methods=['POST']) +def rm(): + global servers, request_counts + + n, hostnames, err = validateRequest(request) + if err is errInvalidRequest: + return jsonify({ + 'message': ' Request payload in invalid format', + 'status': 'failure' + }), 400 + if err is errHostnameListInconsistent: + return jsonify({ + 'message': ' Length of hostname list is more than removable instances', + 'status': 'failure' + }), 400 + + random_servers = 0 + for hostname in hostnames: + if hostname not in servers: + log.info(f"Server {hostname} does not exist") + random_servers += 1 + continue + + command = get_container_rm_command(hostname) + res = Popen(command, stdout=PIPE, stderr=PIPE) + stdout, stderr = res.communicate() + if res.returncode != 0: + log.error(f"Error in removing {hostname}: {stderr.decode().strip()}") + else: + log.info(f"Removed {hostname}") + consistant_hash.remove_server_from_hash(hostname, request_counts) + servers.remove(hostname) + request_counts.pop(hostname, None) + + random_servers += n - len(hostnames) + random_servers_up = 0 + while random_servers_up < random_servers: + server_name = random.choice(list(servers)) + command = get_container_rm_command(server_name) + res = Popen(command, stdout=PIPE, stderr=PIPE) + stdout, stderr = res.communicate() + if res.returncode != 0: + log.error(f"Error in removing random server with name {server_name}: {stderr.decode().strip()}") + else: + log.info(f"Removed {server_name}") + consistant_hash.remove_server_from_hash(server_name, request_counts) + servers.remove(server_name) + request_counts.pop(server_name, None) + random_servers_up += 1 + + output = { + 'message': { + 'N': len(servers), + 'replicas': list(servers) + }, + 'status': 'successful' + } + return jsonify(output), 200 + +@app.route('/checkpoint', methods=['GET']) +def checkpoint(): + global servers, request_counts + output = { + 'servers': list(servers), + 'requests': request_counts + } + return jsonify(output), 200 + +@app.route('/home', methods=['GET']) +def home(): + global servers, request_counts + + server_name = consistant_hash.get_server_from_request(get_random_number(6)) + request_counts[server_name] += 1 + return jsonify({"message": f"Hello from {server_name}"}), 200 + +@app.route('/graph', methods=['GET']) +def generate_graph(): + global servers, request_counts + + # Create lists for servers and request counts + server_names = list(servers) + counts = [request_counts[server] for server in server_names] + + # Create a bar plot + plt.bar(server_names, counts) + plt.xlabel('Server Name') + plt.ylabel('Requests Per Server') + plt.title('Distribution of Requests Among Servers Using Updated Algorithm') + + # Save the plot to a byte buffer + buffer = io.BytesIO() + plt.savefig(buffer, format='png') + buffer.seek(0) + + # Return the plot as a response + return Response(buffer.getvalue(), mimetype='image/png') + +if __name__ == '__main__': + consistant_hash.build(servers) + app.run(debug=True, host='0.0.0.0', port='5000') diff --git a/server.py b/server.py deleted file mode 100644 index 4af4135..0000000 --- a/server.py +++ /dev/null @@ -1,20 +0,0 @@ -#!usr/bin/env python - -from flask import Flask, jsonify - -app = Flask(__name__) - -#Server ID passed as environment variable -import os -SERVER_ID = os.getenv("SERVER_ID", "Unknown") - -@app.route("/home", methods = ["GET"]) -def home(): - return jsonify({"message": f"Hello from Server: {SERVER_ID}", "status": "successful"}), 200 - -@app.route("/heartbeat", methods = ["GET"]) -def heartbeat(): - return "", 200 - -if __name__ == "__main__": - app.run(debug = True, host = "0.0.0.0", port = 5000) diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..c2ca4d6 --- /dev/null +++ b/utils.py @@ -0,0 +1,83 @@ +from flask import Request +import asyncio +import httpx +import random +import string + +errInvalidRequest = "Invalid request" +errHostnameListInconsistent = "Hostname List Inconsistent" + +def validateRequest(request: Request): + req = request.get_json() + + if req['n'] is None or req['n'] <= 0: + return None, None, errInvalidRequest + if req['n'] > 0 and len(req['hostnames']) > req['n']: + return None, None, errHostnameListInconsistent + + if req['hostnames'] is None: + return req['n'], [], None + + return req['n'], req['hostnames'], None + +async def fetch_url(url) -> bool: + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, timeout=5) + response.raise_for_status() + return True + except: + return False + +async def fetch_all_urls(urls) -> list[bool]: + tasks = [asyncio.create_task(fetch_url(url)) for url in urls] + responses = await asyncio.gather(*tasks) + return responses + +async def get_server_health(servers: list[str]) -> list[str]: + urls = [f"http://{server}:5000/heartbeat" for server in servers] + responses = await fetch_all_urls(urls) + + output: list[str] = [] + + for server, result in zip(servers, responses): + if result == True: + output.append(server) + + return output + +async def get_unhealty_servers(servers: list[str]) -> set[str]: + urls = [f"http://{server}:5000/heartbeat" for server in servers] + responses = await fetch_all_urls(urls) + + output: set[str] = set() + + for server, result in zip(servers, responses): + if result == False: + output.add(server) + + return output + +def get_container_run_command(hostname: str, network_name: str) -> list[str]: + output = ["sudo", "docker", "run", + "--name", hostname, + "--network", network_name, + "--network-alias", hostname, + "-e", f"SERVER_ID={hostname}", + "-d", "server" + ] + return output + +def get_container_rm_command(hostname: str) -> list[str]: + output = ["sudo", "docker", "rm", "-f", hostname] + return output + +def get_random_name(length: int) -> str: + output = ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) + + return output + +def get_random_number(length: int) -> int: + output = ''.join(random.choices(string.digits, k=length)) + + return int(output) \ No newline at end of file