diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index ddc1d4e..faeba94 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -12,7 +12,7 @@ from flask import render_template from flask import url_for from flask import current_app -from capsulflask import operation_model, cli +from capsulflask import hub_model, spoke_model, cli from capsulflask.btcpay import client as btcpay load_dotenv(find_dotenv()) @@ -20,11 +20,18 @@ load_dotenv(find_dotenv()) app = Flask(__name__) app.config.from_mapping( + BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"), SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"), - OPERATION_MODEL=os.environ.get("OPERATION_MODEL", default="mock"), + HUB_MODE_ENABLED=os.environ.get("HUB_MODE_ENABLED", default="False").lower() in ['true', '1', 't', 'y', 'yes'], + SPOKE_MODE_ENABLED=os.environ.get("SPOKE_MODE_ENABLED", default="True").lower() in ['true', '1', 't', 'y', 'yes'], + HUB_MODEL=os.environ.get("HUB_MODEL", default="mock"), + SPOKE_MODEL=os.environ.get("SPOKE_MODEL", default="mock"), LOG_LEVEL=os.environ.get("LOG_LEVEL", default="INFO"), - ADMIN_EMAIL_ADDRESSES=os.environ.get("ADMIN_EMAIL_ADDRESSES", default="ops@cyberia.club"), + SPOKE_HOST_ID=os.environ.get("SPOKE_HOST_ID", default="default"), + SPOKE_HOST_TOKEN=os.environ.get("SPOKE_HOST_TOKEN", default="default"), + HUB_TOKEN=os.environ.get("HUB_TOKEN", default="default"), + HUB_URL=os.environ.get("HUB_URL", default="https://capsul.org"), DATABASE_URL=os.environ.get("DATABASE_URL", default="sql://postgres:dev@localhost:5432/postgres"), DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"), @@ -35,6 +42,7 @@ app.config.from_mapping( MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default="forest@nullhex.com"), MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="forest@nullhex.com"), + ADMIN_EMAIL_ADDRESSES=os.environ.get("ADMIN_EMAIL_ADDRESSES", default="ops@cyberia.club"), PROMETHEUS_URL=os.environ.get("PROMETHEUS_URL", default="https://prometheus.cyberia.club"), @@ -74,27 +82,40 @@ stripe.api_version = app.config['STRIPE_API_VERSION'] app.config['FLASK_MAIL_INSTANCE'] = Mail(app) -if app.config['OPERATION_MODEL'] == "shell_scripts": - app.config['OPERATION_MODEL'] = operation_model.GoshtOperation() -else: - app.config['OPERATION_MODEL'] = operation_model.MockOperation() - app.config['BTCPAY_CLIENT'] = btcpay.Client(api_uri=app.config['BTCPAY_URL'], pem=app.config['BTCPAY_PRIVATE_KEY']) -from capsulflask import db +if app.config['HUB_MODE_ENABLED']: -db.init_app(app) + if app.config['HUB_MODEL'] == "capsul-flask": + app.config['HUB_MODEL'] = hub_model.CapsulFlaskHub() + else: + app.config['HUB_MODEL'] = hub_model.MockHub() + + from capsulflask import db + db.init_app(app) -from capsulflask import auth, landing, console, payment, metrics, cli + from capsulflask import auth, landing, console, payment, metrics, cli, hub_api -app.register_blueprint(landing.bp) -app.register_blueprint(auth.bp) -app.register_blueprint(console.bp) -app.register_blueprint(payment.bp) -app.register_blueprint(metrics.bp) -app.register_blueprint(cli.bp) + app.register_blueprint(landing.bp) + app.register_blueprint(auth.bp) + app.register_blueprint(console.bp) + app.register_blueprint(payment.bp) + app.register_blueprint(metrics.bp) + app.register_blueprint(cli.bp) + app.register_blueprint(hub_api.bp) -app.add_url_rule("/", endpoint="index") + app.add_url_rule("/", endpoint="index") + +if app.config['SPOKE_MODE_ENABLED']: + + if app.config['SPOKE_MODEL'] == "shell-scripts": + app.config['SPOKE_MODEL'] = spoke_model.ShellScriptSpoke() + else: + app.config['SPOKE_MODEL'] = spoke_model.MockSpoke() + + from capsulflask import spoke_api + + app.register_blueprint(spoke_api.bp) @app.after_request def security_headers(response): diff --git a/capsulflask/cli.py b/capsulflask/cli.py index 0c46630..09b5d47 100644 --- a/capsulflask/cli.py +++ b/capsulflask/cli.py @@ -249,13 +249,13 @@ def notify_users_about_account_balance(): if index_to_send == len(warnings)-1: for vm in vms: current_app.logger.warning(f"cron_task: deleting {vm['id']} ( {account['email']} ) due to negative account balance.") - current_app.config["OPERATION_MODEL"].destroy(email=account["email"], id=vm['id']) + current_app.config["HUB_MODEL"].destroy(email=account["email"], id=vm['id']) get_model().delete_vm(email=account["email"], id=vm['id']) def ensure_vms_and_db_are_synced(): db_ids = get_model().all_non_deleted_vm_ids() - virt_ids = current_app.config["OPERATION_MODEL"].list_ids() + virt_ids = current_app.config["HUB_MODEL"].list_ids() db_ids_dict = dict() virt_ids_dict = dict() diff --git a/capsulflask/console.py b/capsulflask/console.py index 1217a5d..d5fd7fb 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -27,7 +27,7 @@ def makeCapsulId(): def double_check_capsul_address(id, ipv4): try: - result = current_app.config["OPERATION_MODEL"].get(id) + result = current_app.config["HUB_MODEL"].get(id) if result.ipv4 != ipv4: ipv4 = result.ipv4 get_model().update_vm_ip(email=session["account"], id=id, ipv4=result.ipv4) @@ -98,7 +98,7 @@ def detail(id): ) else: current_app.logger.info(f"deleting {vm['id']} per user request ({session['account']})") - current_app.config["OPERATION_MODEL"].destroy(email=session['account'], id=id) + current_app.config["HUB_MODEL"].destroy(email=session['account'], id=id) get_model().delete_vm(email=session['account'], id=id) return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True) @@ -125,7 +125,7 @@ def create(): operating_systems = get_model().operating_systems_dict() ssh_public_keys = get_model().list_ssh_public_keys_for_account(session["account"]) account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow()) - capacity_avaliable = current_app.config["OPERATION_MODEL"].capacity_avaliable(512*1024*1024) + capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024) errors = list() if request.method == "POST": @@ -165,7 +165,7 @@ def create(): if len(posted_keys) == 0: errors.append("At least one SSH Public Key is required") - capacity_avaliable = current_app.config["OPERATION_MODEL"].capacity_avaliable(vm_sizes[size]['memory_mb']*1024*1024) + capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(vm_sizes[size]['memory_mb']*1024*1024) if not capacity_avaliable: errors.append(""" @@ -181,7 +181,7 @@ def create(): os=os, ssh_public_keys=list(map(lambda x: x["name"], posted_keys)) ) - current_app.config["OPERATION_MODEL"].create( + current_app.config["HUB_MODEL"].create( email = session["account"], id=id, template_image_file_name=operating_systems[os]['template_image_file_name'], diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 31430c0..3a7baa6 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -2,6 +2,7 @@ from nanoid import generate from flask import current_app from typing import List +from capsulflask.hub_model import HTTPResult class OnlineHost: def __init__(self, id: str, url: str): @@ -284,7 +285,7 @@ class DBModel: self.cursor.execute("SELECT id, https_url FROM hosts WHERE last_health_check > NOW() - INTERVAL '10 seconds'") return list(map(lambda x: OnlineHost(id=x[0], url=x[1]), self.cursor.fetchall())) - def create_operation(self, online_hosts: List[OnlineHost], email: str, payload: str) -> None: + def create_operation(self, online_hosts: List[OnlineHost], email: str, payload: str) -> int: self.cursor.execute( "INSERT INTO operations (email, payload) VALUES (%s, %s) RETURNING id", (email, payload) ) operation_id = self.cursor.fetchone()[0] @@ -293,6 +294,22 @@ class DBModel: self.cursor.execute( "INSERT INTO host_operation (host, operation) VALUES (%s, %s)", (host.id, operation_id) ) self.connection.commit() + return operation_id + + def update_host_operation(self, host_id: str, operation_id: int, assignment_status: str): + self.cursor.execute( + "UPDATE host_operation SET assignment_status = %s WHERE host = %s AND operation = %s", + (assignment_status, host_id, operation_id) + ) + self.connection.commit() + + def host_of_capsul(self, capsul_id: str): + self.cursor.execute("SELECT host from vms where id = %s", (capsul_id,)) + row = self.cursor.fetchone() + if row: + return row[0] + else: + return None diff --git a/capsulflask/hub_api.py b/capsulflask/hub_api.py new file mode 100644 index 0000000..592687a --- /dev/null +++ b/capsulflask/hub_api.py @@ -0,0 +1,22 @@ + +from flask import Blueprint +from flask import current_app +from flask import request +from werkzeug.exceptions import abort + +from capsulflask.db import get_model, my_exec_info_message + +bp = Blueprint("hosts", __name__, url_prefix="/hosts") + +def authorized_for_host(id): + auth_header_value = request.headers.get('Authorization').replace("Bearer ", "") + return get_model().authorized_for_host(id, auth_header_value) + +@bp.route("/heartbeat/", methods=("POST")) +def heartbeat(id): + if authorized_for_host(id): + get_model().host_heartbeat(id) + else: + current_app.logger.info(f"/hosts/heartbeat/{id} returned 401: invalid token") + return abort(401, "invalid host id or token") + diff --git a/capsulflask/hub_model.py b/capsulflask/hub_model.py new file mode 100644 index 0000000..3f15896 --- /dev/null +++ b/capsulflask/hub_model.py @@ -0,0 +1,282 @@ +import subprocess +import re +import sys +import requests +import json +import asyncio +from typing import List, Tuple + +import aiohttp +from flask import current_app +from time import sleep +from os.path import join +from subprocess import run + +from capsulflask.db_model import OnlineHost +from capsulflask.spoke_model import VirtualMachine +from capsulflask.spoke_model import validate_capsul_id +from capsulflask.db import get_model, my_exec_info_message + +class HTTPResult: + def __init__(self, status_code, body=None): + self.status_code = status_code + self.body = body + +class HubInterface: + def capacity_avaliable(self, additional_ram_bytes: int) -> bool: + pass + + def get(self, id: str) -> VirtualMachine: + pass + + def list_ids(self) -> list: + pass + + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory: int, ssh_public_keys: list): + pass + + def destroy(self, email: str, id: str): + pass + +class MockHub(HubInterface): + def capacity_avaliable(self, additional_ram_bytes): + return True + + def get(self, id): + validate_capsul_id(id) + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1") + + def list_ids(self) -> list: + return get_model().all_non_deleted_vm_ids() + + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list): + validate_capsul_id(id) + current_app.logger.info(f"mock create: {id} for {email}") + sleep(1) + + def destroy(self, email: str, id: str): + current_app.logger.info(f"mock destroy: {id} for {email}") + + +class CapsulFlaskHub(HubInterface): + + + async def post_json(self, method: str, url: str, body: str, session: aiohttp.ClientSession) -> HTTPResult: + response = None + try: + response = await session.request( + method=method, + url=url, + json=body, + auth=aiohttp.BasicAuth("hub", current_app.config['HUB_TOKEN']), + verify_ssl=True, + ) + except: + error_message = my_exec_info_message(sys.exc_info()) + response_body = json.dumps({"error_message": f"error contacting spoke: {error_message}"}) + current_app.logger.error(f""" + error contacting spoke: post_json (HTTP {method} {url}) failed with: {error_message}""" + ) + return HTTPResult(-1, response_body) + + response_body = None + try: + response_body = await response.text() + except: + error_message = my_exec_info_message(sys.exc_info()) + response_body = json.dumps({"error_message": f"error reading response from spoke: {error_message}"}) + current_app.logger.error(f""" + error reading response from spoke: HTTP {method} {url} (status {response.status}) failed with: {error_message}""" + ) + + return HTTPResult(response.status, response_body) + + async def make_requests(self, online_hosts: List[OnlineHost], body: str) -> List(HTTPResult): + timeout_seconds = 5 + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout_seconds)) as session: + tasks = [] + # append to tasks in the same order as online_hosts + for host in online_hosts: + tasks.append( + self.post_json(method="POST", url=host.url, body=body, session=session) + ) + # gather is like Promise.all from javascript, it returns a future which resolves to an array of results + # in the same order as the tasks that we passed in -- which were in the same order as online_hosts + results = await asyncio.gather(*tasks) + + return results + + + async def generic_operation(self, hosts: List[OnlineHost], payload: str, immediate_mode: bool) -> Tuple[int, List[HTTPResult]]: + operation_id = get_model().create_operation(hosts, payload) + results = await self.make_requests(hosts, payload) + for i in range(len(hosts)): + host = hosts[i] + result = results[i] + task_result = None + assignment_status = "pending" + if result.status_code == -1: + assignment_status = "no_response_from_host" + if result.status_code != 200: + assignment_status = "error_response_from_host" + else: + valid_statuses = { + "assigned": True, + "not_applicable": True, + "assigned_to_other_host": True, + } + result_is_json = False + result_is_dict = False + result_has_status = False + result_has_valid_status = False + assignment_status = "invalid_response_from_host" + try: + if immediate_mode: + task_result = result.body + result_body = json.loads(result.body) + result_is_json = True + result_is_dict = isinstance(result_body, dict) + result_has_status = result_is_dict and 'assignment_status' in result_body + result_has_valid_status = result_has_status and result_body['assignment_status'] in valid_statuses + if result_has_valid_status: + assignment_status = result_body['assignment_status'] + except: + pass + + if not result_has_valid_status: + current_app.logger.error(f"""error reading assignment_status for operation {operation_id} from host {host.id}: + result_is_json: {result_is_json} + result_is_dict: {result_is_dict} + result_has_status: {result_has_status} + result_has_valid_status: {result_has_valid_status} + """ + ) + + get_model().update_host_operation(host.id, operation_id, assignment_status, task_result) + + return results + + async def capacity_avaliable(self, additional_ram_bytes): + online_hosts = get_model().get_online_hosts() + payload = json.dumps(dict(type="capacity_avaliable", additional_ram_bytes=additional_ram_bytes)) + op = await self.generic_operation(online_hosts, payload, True) + results = op[1] + for result in results: + try: + result_body = json.loads(result.body) + if isinstance(result_body, dict) and 'capacity_avaliable' in result_body and result_body['capacity_avaliable'] == True: + return True + except: + pass + + return False + + + async def get(self, id) -> VirtualMachine: + validate_capsul_id(id) + host = get_model().host_of_capsul(id) + if host is not None: + payload = json.dumps(dict(type="get", id=id)) + op = await self.generic_operation([host], payload, True) + results = op[1] + for result in results: + try: + result_body = json.loads(result.body) + if isinstance(result_body, dict) and ('ipv4' in result_body or 'ipv6' in result_body): + return VirtualMachine(id, host=host, ipv4=result_body['ipv4'], ipv6=result_body['ipv6']) + except: + pass + + return None + + def list_ids(self) -> list: + online_hosts = get_model().get_online_hosts() + payload = json.dumps(dict(type="list_ids")) + op = await self.generic_operation(online_hosts, payload, False) + operation_id = op[0] + results = op[1] + to_return = [] + for i in range(len(results)): + host = online_hosts[i] + result = results[i] + try: + result_body = json.loads(result.body) + if isinstance(result_body, dict) and 'ids' in result_body and isinstance(result_body['ids'], list): + all_valid = True + for id in result_body['ids']: + try: + validate_capsul_id(id) + to_return.append(id) + except: + all_valid = False + if all_valid: + get_model().update_host_operation(host.id, operation_id, None, result.body) + else: + result_json_string = json.dumps({"error_message": "invalid capsul id returned"}) + get_model().update_host_operation(host.id, operation_id, None, result_json_string) + current_app.logger.error(f"""error reading ids for list_ids operation {operation_id}, host {host.id}""") + else: + result_json_string = json.dumps({"error_message": "invalid response, missing 'ids' list"}) + get_model().update_host_operation(host.id, operation_id, "invalid_response_from_host", result_json_string) + current_app.logger.error(f"""missing 'ids' list for list_ids operation {operation_id}, host {host.id}""") + except: + # no need to do anything here since if it cant be parsed then generic_operation will handle it. + pass + + return to_return + + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list): + validate_capsul_id(id) + online_hosts = get_model().get_online_hosts() + payload = json.dumps(dict( + type="create", + email=email, + id=id, + template_image_file_name=template_image_file_name, + vcpus=vcpus, + memory_mb=memory_mb, + ssh_public_keys=ssh_public_keys, + )) + op = await self.generic_operation(online_hosts, payload, False) + operation_id = op[0] + results = op[1] + number_of_assigned = 0 + assigned_hosts = [] + for i in range(len(results)): + host = online_hosts[i] + result = results[i] + try: + result_body = json.loads(result.body) + if isinstance(result_body, dict) and 'assignment_status' in result_body and result_body['assignment_status'] == "assigned": + number_of_assigned += 1 + assigned_hosts.append(host.id) + except: + # no need to do anything here since if it cant be parsed then generic_operation will handle it. + pass + + if number_of_assigned != 1: + assigned_hosts_string = ", ".join(assigned_hosts) + raise ValueError(f"expected create capsul operation {operation_id} to be assigned to one host, it was assigned to {number_of_assigned} ({assigned_hosts_string})") + + + + def destroy(self, email: str, id: str): + validate_capsul_id(id) + result_status = None + host = get_model().host_of_capsul(id) + if host is not None: + payload = json.dumps(dict(type="destroy", id=id)) + op = await self.generic_operation([host], payload, True) + results = op[1] + result_json_string = "" + for result in results: + try: + result_json_string = result.body + result_body = json.loads(result_json_string) + if isinstance(result_body, dict) and 'status' in result_body: + result_status = result_body['status'] + except: + pass + + if not result_status == "success": + raise ValueError(f"""failed to destroy vm "{id}" on host "{host}" for {email}: {result_json_string}""") diff --git a/capsulflask/spoke_api.py b/capsulflask/spoke_api.py new file mode 100644 index 0000000..592687a --- /dev/null +++ b/capsulflask/spoke_api.py @@ -0,0 +1,22 @@ + +from flask import Blueprint +from flask import current_app +from flask import request +from werkzeug.exceptions import abort + +from capsulflask.db import get_model, my_exec_info_message + +bp = Blueprint("hosts", __name__, url_prefix="/hosts") + +def authorized_for_host(id): + auth_header_value = request.headers.get('Authorization').replace("Bearer ", "") + return get_model().authorized_for_host(id, auth_header_value) + +@bp.route("/heartbeat/", methods=("POST")) +def heartbeat(id): + if authorized_for_host(id): + get_model().host_heartbeat(id) + else: + current_app.logger.info(f"/hosts/heartbeat/{id} returned 401: invalid token") + return abort(401, "invalid host id or token") + diff --git a/capsulflask/spoke_model.py b/capsulflask/spoke_model.py new file mode 100644 index 0000000..4c4b081 --- /dev/null +++ b/capsulflask/spoke_model.py @@ -0,0 +1,173 @@ +import subprocess +import re + +from flask import current_app +from time import sleep +from os.path import join +from subprocess import run + +from capsulflask.db import get_model + +def validate_capsul_id(id): + if not re.match(r"^(cvm|capsul)-[a-z0-9]{10}$", id): + raise ValueError(f"vm id \"{id}\" must match \"^capsul-[a-z0-9]{{10}}$\"") + +class VirtualMachine: + def __init__(self, id, host, ipv4=None, ipv6=None): + self.id = id + self.host = host + self.ipv4 = ipv4 + self.ipv6 = ipv6 + +class SpokeInterface: + def capacity_avaliable(self, additional_ram_bytes: int) -> bool: + pass + + def get(self, id: str) -> VirtualMachine: + pass + + def list_ids(self) -> list: + pass + + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory: int, ssh_public_keys: list): + pass + + def destroy(self, email: str, id: str): + pass + +class MockSpoke(SpokeInterface): + def capacity_avaliable(self, additional_ram_bytes): + return True + + def get(self, id): + validate_capsul_id(id) + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1") + + def list_ids(self) -> list: + return get_model().all_non_deleted_vm_ids() + + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list): + validate_capsul_id(id) + current_app.logger.info(f"mock create: {id} for {email}") + sleep(1) + + def destroy(self, email: str, id: str): + current_app.logger.info(f"mock destroy: {id} for {email}") + + +class ShellScriptSpoke(SpokeInterface): + + def validate_completed_process(self, completedProcess, email=None): + emailPart = "" + if email != None: + emailPart = f"for {email}" + + if completedProcess.returncode != 0: + raise RuntimeError(f"""{" ".join(completedProcess.args)} failed {emailPart} with exit code {completedProcess.returncode} + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """) + + def capacity_avaliable(self, additional_ram_bytes): + my_args=[join(current_app.root_path, 'shell_scripts/capacity-avaliable.sh'), str(additional_ram_bytes)] + completedProcess = run(my_args, capture_output=True) + + if completedProcess.returncode != 0: + current_app.logger.error(f""" + capacity-avaliable.sh exited {completedProcess.returncode} with + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """) + return False + + lines = completedProcess.stdout.splitlines() + output = lines[len(lines)-1] + if not output == b"yes": + current_app.logger.error(f"capacity-avaliable.sh exited 0 and returned {output} but did not return \"yes\" ") + return False + + return True + + def get(self, id): + validate_capsul_id(id) + completedProcess = run([join(current_app.root_path, 'shell_scripts/get.sh'), id], capture_output=True) + self.validate_completed_process(completedProcess) + lines = completedProcess.stdout.splitlines() + ipaddr = lines[0].decode("utf-8") + + if not re.match(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$", ipaddr): + return None + + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipaddr) + + def list_ids(self) -> list: + completedProcess = run([join(current_app.root_path, 'shell_scripts/list-ids.sh')], capture_output=True) + self.validate_completed_process(completedProcess) + return list(map(lambda x: x.decode("utf-8"), completedProcess.stdout.splitlines() )) + + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list): + validate_capsul_id(id) + + if not re.match(r"^[a-zA-Z0-9/_.-]+$", template_image_file_name): + raise ValueError(f"template_image_file_name \"{template_image_file_name}\" must match \"^[a-zA-Z0-9/_.-]+$\"") + + for ssh_public_key in ssh_public_keys: + if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@. -]+$", ssh_public_key): + raise ValueError(f"ssh_public_key \"{ssh_public_key}\" must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@. -]+$\"") + + if vcpus < 1 or vcpus > 8: + raise ValueError(f"vcpus \"{vcpus}\" must match 1 <= vcpus <= 8") + + if memory_mb < 512 or memory_mb > 16384: + raise ValueError(f"memory_mb \"{memory_mb}\" must match 512 <= memory_mb <= 16384") + + ssh_keys_string = "\n".join(ssh_public_keys) + + completedProcess = run([ + join(current_app.root_path, 'shell_scripts/create.sh'), + id, + template_image_file_name, + str(vcpus), + str(memory_mb), + ssh_keys_string + ], capture_output=True) + + self.validate_completed_process(completedProcess, email) + lines = completedProcess.stdout.splitlines() + status = lines[len(lines)-1].decode("utf-8") + + vmSettings = f""" + id={id} + template_image_file_name={template_image_file_name} + vcpus={str(vcpus)} + memory={str(memory_mb)} + ssh_public_keys={ssh_keys_string} + """ + + if not status == "success": + raise ValueError(f"""failed to create vm for {email} with: + {vmSettings} + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """) + + def destroy(self, email: str, id: str): + validate_capsul_id(id) + completedProcess = run([join(current_app.root_path, 'shell_scripts/destroy.sh'), id], capture_output=True) + self.validate_completed_process(completedProcess, email) + lines = completedProcess.stdout.splitlines() + status = lines[len(lines)-1].decode("utf-8") + + if not status == "success": + raise ValueError(f"""failed to destroy vm "{id}" for {email}: + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """)