diff --git a/capsulflask/admin.py b/capsulflask/admin.py index ba90252..6167a2b 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -4,7 +4,7 @@ import json import ipaddress import pprint from datetime import datetime, timedelta -from flask import Blueprint, current_app, render_template, make_response, session, request, redirect, url_for +from flask import Blueprint, current_app, render_template, make_response, session, request, redirect, url_for, flash from flask_mail import Message from werkzeug.exceptions import abort from nanoid import generate @@ -21,6 +21,15 @@ bp = Blueprint("admin", __name__, url_prefix="/admin") @admin_account_required def index(): + # these are always required to display the page anyways, so might as well + # grab them right off the bat as they are used inside the POST handler as well. + + db_hosts = get_model().list_hosts_with_networks(None) + db_vms_by_id = get_all_vms_from_db() + virt_vms_by_id = get_all_vms_from_hosts() + network_display_width_px = float(270) + #operations = get_model().list_all_operations() + if request.method == "POST": if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']: return abort(418, f"u want tea") @@ -47,18 +56,78 @@ def index(): ) current_app.logger.info(f"sending email is done.") return redirect(f"{url_for('admin.index')}") + + elif request.form['action'] == "start_or_stop": + if 'id' not in request.form: + return abort(400, "id is required") + if 'desired_state' not in request.form: + return abort(400, "desired_state is required") + id = request.form['id'] + if id not in db_vms_by_id or id not in virt_vms_by_id: + return abort(404, "vm with that id was not found") + + virt_vm = virt_vms_by_id[id] + db_vm = db_vms_by_id[id] + + try: + if request.form['desired_state'] == "running": + if 'macs' in virt_vm and len(virt_vm['macs'].keys()) > 0: + current_app.config["HUB_MODEL"].net_set_dhcp(email=session['account'], host_id=virt_vm['host'], network_name=virt_vm['network_name'], macs=virt_vm['macs'].keys(), add_ipv4=db_vm['public_ipv4']) + + current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="start") + elif request.form['desired_state'] == "shut off": + current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="force-stop") + else: + return abort(400, "desired_state must be either 'running' or 'shut off'") + except: + flash(f"""error during start_or_stop of {id}: {my_exec_info_message(sys.exc_info())}""") + + return redirect(f"{url_for('admin.index')}") + + elif request.form['action'] == "dhcp_reset": + if 'id' not in request.form: + return abort(400, "id is required") + + id = request.form['id'] + if id not in db_vms_by_id or id not in virt_vms_by_id: + return abort(404, "vm with that id was not found") + + virt_vm = virt_vms_by_id[id] + db_vm = db_vms_by_id[id] + + try: + current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="force-stop") + current_app.config["HUB_MODEL"].net_set_dhcp(email=session['account'], host_id=virt_vm['host'], network_name=virt_vm['network_name'], macs=virt_vm['macs'].keys(), remove_ipv4=virt_vm['public_ipv4'], add_ipv4=db_vm['public_ipv4']) + current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="start") + except: + flash(f"""error during dhcp_reset of {id}: {my_exec_info_message(sys.exc_info())}""") + + return redirect(f"{url_for('admin.index')}") + + elif request.form['action'] == "stop_and_expire": + if 'id' not in request.form: + return abort(400, "id is required") + + id = request.form['id'] + if id not in db_vms_by_id or id not in virt_vms_by_id: + return abort(404, "vm with that id was not found") + + virt_vm = virt_vms_by_id[id] + #db_vm = db_vms_by_id[id] + + current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="force-stop") + current_app.config["HUB_MODEL"].net_set_dhcp(email=session['account'], host_id=virt_vm['host'], network_name=virt_vm['network_name'], macs=virt_vm['macs'].keys(), remove_ipv4=virt_vm['public_ipv4']) + + return redirect(f"{url_for('admin.index')}") + else: return abort(400, "unknown form action") - # moving on from the form post stuff... + + # moving on from the form post action stuff... # first create the hosts list w/ ip allocation visualization from the database # - - db_hosts = get_model().list_hosts_with_networks(None) - db_vms_by_id = get_all_vms_from_db() - network_display_width_px = float(270) - #operations = get_model().list_all_operations() display_hosts = [] inline_styles = [f""" @@ -112,7 +181,7 @@ def index(): # Now creating the capsul consistency / running status ui # - virt_vms_by_id = get_all_vms_from_hosts() + # current_app.logger.info(pprint.pformat(db_vms_by_id)) @@ -170,9 +239,6 @@ def index(): "admin.html", csrf_token=session["csrf-token"], display_hosts=display_hosts, - # in_db_but_not_in_virt=in_db_but_not_in_virt, - # needs_to_be_started=needs_to_be_started, - # needs_to_be_started_missing_ipv4=needs_to_be_started_missing_ipv4, network_display_width_px=network_display_width_px, csp_inline_style_nonce=csp_inline_style_nonce, inline_style='\n'.join(inline_styles), diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 2fecd02..a1bc542 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -434,6 +434,14 @@ class DBModel: ) self.connection.commit() + def host_by_id(self, host_id: str) -> OnlineHost: + self.cursor.execute("SELECT hosts.id, hosts.https_url FROM hosts hosts.id = %s", (host_id,)) + row = self.cursor.fetchone() + if row: + return OnlineHost(row[0], row[1]) + else: + return None + def host_of_capsul(self, capsul_id: str) -> OnlineHost: self.cursor.execute("SELECT hosts.id, hosts.https_url from vms JOIN hosts on hosts.id = vms.host where vms.id = %s", (capsul_id,)) row = self.cursor.fetchone() diff --git a/capsulflask/hub_model.py b/capsulflask/hub_model.py index 5abf174..dbe2a1a 100644 --- a/capsulflask/hub_model.py +++ b/capsulflask/hub_model.py @@ -61,6 +61,9 @@ class MockHub(VirtualizationInterface): def vm_state_command(self, email: str, id: str, command: str): current_app.logger.info(f"mock {command}: {id} for {email}") + def net_set_dhcp(self, email: str, host_id: str, network_name: str, macs: list, remove_ipv4: str, add_ipv4: str): + current_app.logger.info(f"mock net_set_dhcp: host_id={host_id} network_name={network_name} macs={','.join(macs)} remove_ipv4={remove_ipv4} add_ipv4={add_ipv4} for {email}") + class CapsulFlaskHub(VirtualizationInterface): def synchronous_operation(self, hosts: List[OnlineHost], email: str, payload: str) -> List[HTTPResult]: @@ -270,3 +273,23 @@ class CapsulFlaskHub(VirtualizationInterface): if not result_status == "success": raise ValueError(f"""failed to {command} vm "{id}" on host "{host.id}" for {email}: {result_json_string}""") + + def net_set_dhcp(self, email: str, host_id: str, network_name: str, macs: list, remove_ipv4: str, add_ipv4: str): + validate_capsul_id(id) + result_status = None + host = get_model().host_by_id(host_id) + if host is not None: + payload = json.dumps(dict(type="net_set_dhcp", network_name=network_name, macs=macs, remove_ipv4=remove_ipv4, add_ipv4=add_ipv4)) + results = self.synchronous_operation([host], email, payload) + 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 net_set_dhcp on host "{host.id}" for {email}: {result_json_string}""") \ No newline at end of file diff --git a/capsulflask/shared.py b/capsulflask/shared.py index 84b0896..2fa4f12 100644 --- a/capsulflask/shared.py +++ b/capsulflask/shared.py @@ -44,6 +44,10 @@ class VirtualizationInterface: def vm_state_command(self, email: str, id: str, command: str): pass + def net_set_dhcp(self, email: str, host_id: str, network_name: str, macs: list, remove_ipv4: str, add_ipv4: str): + pass + + 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}}$\"") diff --git a/capsulflask/shell_scripts/ip-dhcp-host.sh b/capsulflask/shell_scripts/ip-dhcp-host.sh new file mode 100755 index 0000000..a4ae0e0 --- /dev/null +++ b/capsulflask/shell_scripts/ip-dhcp-host.sh @@ -0,0 +1,15 @@ +#!/bin/sh -e +# +# ip-dhcp-host.sh - add or remove a mac address --> ipv4 mapping + +action="$1" +network_name="$2" +mac_address="$3" +ipv4_address="$4" + +[ "$action" != 'add' ] && [ "$action" != 'delete' ] && printf 'you must set $action to either add or delete (1st arg)\n' && exit 1 +[ "$network_name" = '' ] && printf 'you must set $network_name (2nd arg)\n' && exit 1 +[ "$mac_address" = '' ] && printf 'you must set $mac_address (3rd arg)\n' && exit 1 +[ "$ipv4_address" = '' ] && printf 'you must set $ipv4_address (4th arg)\n' && exit 1 + +virsh net-update "$network_name" "$action" ip-dhcp-host "" --live --config diff --git a/capsulflask/spoke_api.py b/capsulflask/spoke_api.py index 1ec47a0..39d1fed 100644 --- a/capsulflask/spoke_api.py +++ b/capsulflask/spoke_api.py @@ -53,6 +53,7 @@ def operation_impl(operation_id: int): "create": handle_create, "destroy": handle_destroy, "vm_state_command": handle_vm_state_command, + "net_set_dhcp": handle_net_set_dhcp, } error_message = "" @@ -222,4 +223,38 @@ def handle_vm_state_command(operation_id, request_body): current_app.logger.error(f"current_app.config['SPOKE_MODEL'].vm_state_command({params}) failed: {error_message}") return jsonify(dict(assignment_status="assigned", status="error", error_message=error_message)) + return jsonify(dict(assignment_status="assigned", status="success")) + +def handle_net_set_dhcp(operation_id, request_body): + + required_properties = ['network_name', 'macs'] + for required_property in required_properties: + if required_property not in request_body: + current_app.logger.error(f"/hosts/operation returned 400: {required_property} is required for net_set_dhcp") + return abort(400, f"bad request; {required_property} is required for net_set_dhcp") + + remove_is_missing = ('remove_ipv4' not in request_body or request_body['remove_ipv4'] is None) + add_is_missing = ('add_ipv4' not in request_body or request_body['add_ipv4'] is None) + + if remove_is_missing and add_is_missing: + current_app.logger.error(f"/hosts/operation returned 400: either remove_ipv4 or add_ipv4 is required for net_set_dhcp") + return abort(400, f"bad request; either remove_ipv4 or add_ipv4 is required for net_set_dhcp") + + if remove_is_missing: + request_body['remove_ipv4'] = None + if add_is_missing: + request_body['add_ipv4'] = None + + try: + current_app.config['SPOKE_MODEL'].net_set_dhcp(email=request_body['email'], network_name=request_body['network_name'], macs=request_body['macs'], remove_ipv4=request_body['remove_ipv4'], add_ipv4=request_body['add_ipv4']) + except: + error_message = my_exec_info_message(sys.exc_info()) + params= f"email='{request_body['email'] if 'email' in request_body else 'KeyError'}', " + params= f"{params} network_name='{request_body['network_name'] if 'network_name' in request_body else 'KeyError'}', " + params= f"{params} macs='{request_body['macs'] if 'macs' in request_body else 'KeyError'}', " + params= f"{params} remove_ipv4='{request_body['remove_ipv4'] if 'remove_ipv4' in request_body else 'KeyError'}', " + params= f"{params} add_ipv4='{request_body['add_ipv4'] if 'add_ipv4' in request_body else 'KeyError'}', " + current_app.logger.error(f"current_app.config['SPOKE_MODEL'].net_set_dhcp({params}) failed: {error_message}") + return jsonify(dict(assignment_status="assigned", status="error", error_message=error_message)) + return jsonify(dict(assignment_status="assigned", status="success")) \ No newline at end of file diff --git a/capsulflask/spoke_model.py b/capsulflask/spoke_model.py index 8b2d5fe..d0a25a1 100644 --- a/capsulflask/spoke_model.py +++ b/capsulflask/spoke_model.py @@ -56,6 +56,10 @@ class MockSpoke(VirtualizationInterface): def vm_state_command(self, email: str, id: str, command: str): current_app.logger.info(f"mock {command}: {id} for {email}") + def net_set_dhcp(self, email: str, host_id: str, network_name: str, macs: list, remove_ipv4: str, add_ipv4: str): + current_app.logger.info(f"mock net_set_dhcp: host_id={host_id} network_name={network_name} macs={','.join(macs)} remove_ipv4={remove_ipv4} add_ipv4={add_ipv4} for {email}") + + class ShellScriptSpoke(VirtualizationInterface): def validate_completed_process(self, completedProcess, email=None): @@ -298,4 +302,31 @@ class ShellScriptSpoke(VirtualizationInterface): self.validate_completed_process(completedProcess, email) returned_string = completedProcess.stdout.decode("utf-8") current_app.logger.info(f"{command} vm {id} for {email} returned: {returned_string}") - + + def net_set_dhcp(self, email: str, host_id: str, network_name: str, macs: list, remove_ipv4: str, add_ipv4: str): + + if not re.match(r"^[a-zA-Z0-9_-]+$", network_name): + raise ValueError(f"network_name \"{network_name}\" must match \"^[a-zA-Z0-9_-]+\"") + + if not isinstance(macs, list): + raise ValueError(f"macs must be a list") + + for mac in macs: + if not re.match(r"^[0-9a-f:]+$", mac): + raise ValueError(f"mac \"{mac}\" must match \"^[0-9a-f:]+$\"") + + if remove_ipv4 != None and remove_ipv4 != "": + if not re.match(r"^[0-9.]+$", remove_ipv4): + raise ValueError(f"remove_ipv4 \"{remove_ipv4}\" must match \"^[0-9.]+$\"") + + for mac in macs: + completedProcess = run([join(current_app.root_path, f"shell_scripts/ip-dhcp-host.sh"), "delete", network_name, mac, remove_ipv4], capture_output=True) + self.validate_completed_process(completedProcess, email) + + if add_ipv4 != None and add_ipv4 != "": + if not re.match(r"^[0-9.]+$", add_ipv4): + raise ValueError(f"add_ipv4 \"{add_ipv4}\" must match \"^[0-9.]+$\"") + + for mac in macs: + completedProcess = run([join(current_app.root_path, f"shell_scripts/ip-dhcp-host.sh"), "add", network_name, mac, add_ipv4], capture_output=True) + self.validate_completed_process(completedProcess, email) diff --git a/capsulflask/templates/admin.html b/capsulflask/templates/admin.html index 859754c..f065402 100644 --- a/capsulflask/templates/admin.html +++ b/capsulflask/templates/admin.html @@ -60,9 +60,9 @@
{{vm['id']}} ({{vm['email']}}): state={{vm['state']}} desired_state={{vm['desired_state']}}
- + - +
@@ -154,7 +154,7 @@
-

📢 Admin Megaphone: Email All Users With Active Capsuls 📢

+

📢 Admin Megaphone: Email All Users who have Active Capsuls 📢