diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index 4cbb1ae..d88ed91 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -56,7 +56,7 @@ app.config.from_mapping( MAIL_SERVER=os.environ.get("MAIL_SERVER", default=""), MAIL_PORT=os.environ.get("MAIL_PORT", default="465"), - MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="True").lower() in ['true', '1', 't', 'y', 'yes'], + MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="False").lower() in ['true', '1', 't', 'y', 'yes'], MAIL_USE_SSL=os.environ.get("MAIL_USE_SSL", default="True").lower() in ['true', '1', 't', 'y', 'yes'], MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default=""), MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), diff --git a/capsulflask/admin.py b/capsulflask/admin.py index ccdf5cd..50cc61e 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -17,9 +17,9 @@ bp = Blueprint("admin", __name__, url_prefix="/admin") @bp.route("/") @admin_account_required def index(): - hosts = get_model().list_hosts_with_networks() - vms_by_host_and_network = get_model().all_non_deleted_vms_by_host_and_network() - network_display_width_px = float(500); + hosts = get_model().list_hosts_with_networks(None) + vms_by_host_and_network = get_model().non_deleted_vms_by_host_and_network(None) + network_display_width_px = float(270) #operations = get_model().list_all_operations() display_hosts = [] @@ -35,26 +35,18 @@ def index(): display_host = dict(name=host_id, networks=value['networks']) for network in display_host['networks']: - ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False) - network_start_int = -1 - network_end_int = -1 - i = 0 - for ipv4_address in ipv4_network: - i += 1 - if i > 2: - if network_start_int == -1: - network_start_int = int(ipv4_address) - - network_end_int = int(ipv4_address) - + + network_start_int = int(ipaddress.ip_address(network["public_ipv4_first_usable_ip"])) + network_end_int = int(ipaddress.ip_address(network["public_ipv4_last_usable_ip"])) + network['allocations'] = [] - network_addresses_width = float((network_end_int-network_start_int)) + network_addresses_width = float((network_end_int-network_start_int)+1) if host_id in vms_by_host_and_network: if network['network_name'] in vms_by_host_and_network[host_id]: for vm in vms_by_host_and_network[host_id][network['network_name']]: ip_address_int = int(ipaddress.ip_address(vm['public_ipv4'])) - if network_start_int < ip_address_int and ip_address_int < network_end_int: + if network_start_int <= ip_address_int and ip_address_int <= network_end_int: allocation = f"{host_id}_{network['network_name']}_{len(network['allocations'])}" inline_styles.append( f""" diff --git a/capsulflask/db.py b/capsulflask/db.py index 8b79ab5..4616c1d 100644 --- a/capsulflask/db.py +++ b/capsulflask/db.py @@ -43,7 +43,7 @@ def init_app(app, is_running_server): hasSchemaVersionTable = False actionWasTaken = False schemaVersion = 0 - desiredSchemaVersion = 17 + desiredSchemaVersion = 18 cursor = connection.cursor() diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index ee71cc8..ff53381 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -1,3 +1,5 @@ +import re + # I was never able to get this type hinting to work correctly # from psycopg2.extensions import connection as Psycopg2Connection, cursor as Psycopg2Cursor import hashlib @@ -65,8 +67,18 @@ class DBModel: # ------ VM & ACCOUNT MANAGEMENT --------- - def all_non_deleted_vms_by_host_and_network(self): - self.cursor.execute("SELECT id, host, network_name, public_ipv4, public_ipv6 FROM vms WHERE deleted IS NULL") + def non_deleted_vms_by_host_and_network(self, host_id): + query = "SELECT id, host, network_name, public_ipv4, public_ipv6 FROM vms WHERE deleted IS NULL" + if host_id is None: + self.cursor.execute(query) + else: + if not re.match(r"^[a-zA-Z0-9_-]+$", host_id): + raise ValueError(f"host_id \"{host_id}\" must match \"^[a-zA-Z0-9_-]+\"") + + # I kept getting "TypeError: not all arguments converted during string formatting" + # when I was trying to mix python string templating with psycopg2 safe parameter passing. + # so i just did all of it in python and check the user-provided data for safety myself (no sql injection). + self.cursor.execute(f"{query} AND host = '{host_id}'") hosts = dict() for row in self.cursor.fetchall(): @@ -181,12 +193,12 @@ class DBModel: ) self.connection.commit() - def create_vm(self, email, id, size, os, ssh_authorized_keys): + def create_vm(self, email, id, size, os, host, network_name, public_ipv4, ssh_authorized_keys): self.cursor.execute(""" - INSERT INTO vms (email, id, size, os, network_name, host) - VALUES (%s, %s, %s, %s, 'virbr1', 'baikal') + INSERT INTO vms (email, id, size, os, host, network_name, public_ipv4) + VALUES (%s, %s, %s, %s, %s, %s, %s) """, - (email, id, size, os) + (email, id, size, os, host, network_name, public_ipv4) ) for ssh_authorized_key in ssh_authorized_keys: @@ -353,18 +365,35 @@ class DBModel: # ------ HOSTS --------- - def list_hosts_with_networks(self): - self.cursor.execute(""" - SELECT hosts.id, hosts.last_health_check, host_network.network_name, host_network.public_ipv4_cidr_block FROM hosts + def list_hosts_with_networks(self, host_id: str): + query = """ + SELECT hosts.id, hosts.last_health_check, host_network.network_name, + host_network.public_ipv4_cidr_block, host_network.public_ipv4_first_usable_ip, host_network.public_ipv4_last_usable_ip + FROM hosts JOIN host_network ON host_network.host = hosts.id - """) + """ + if host_id is None: + self.cursor.execute(query) + else: + if not re.match(r"^[a-zA-Z0-9_-]+$", host_id): + raise ValueError(f"host_id \"{host_id}\" must match \"^[a-zA-Z0-9_-]+\"") + + # I kept getting "TypeError: not all arguments converted during string formatting" + # when I was trying to mix python query string templating with psycopg2 safe parameter passing. + # so i just did all of it in python and check the user-provided data for safety myself (no sql injection). + self.cursor.execute(f"{query} WHERE hosts.id = '{host_id}'") hosts = dict() for row in self.cursor.fetchall(): if row[0] not in hosts: hosts[row[0]] = dict(last_health_check=row[1], networks=[]) - hosts[row[0]]["networks"].append(dict(network_name=row[2], public_ipv4_cidr_block=row[3])) + hosts[row[0]]["networks"].append(dict( + network_name=row[2], + public_ipv4_cidr_block=row[3], + public_ipv4_first_usable_ip=row[4], + public_ipv4_last_usable_ip=row[5] + )) return hosts @@ -414,6 +443,13 @@ class DBModel: self.connection.commit() return operation_id + def update_operation(self, operation_id: int, payload: str): + self.cursor.execute( + "UPDATE operations SET payload = %s WHERE id = %s", + (payload, operation_id) + ) + self.connection.commit() + def update_host_operation(self, host_id: str, operation_id: int, assignment_status: str, result: str): if assignment_status and not result: self.cursor.execute( @@ -440,9 +476,20 @@ class DBModel: else: return None - def host_operation_exists(self, operation_id: int, host_id: str) -> bool: - self.cursor.execute("SELECT operation FROM host_operation WHERE host = %s AND operation = %s",(host_id, operation_id)) - return len(self.cursor.fetchall()) != 0 + def get_payload_json_from_host_operation(self, operation_id: int, host_id: str) -> str: + self.cursor.execute( + """ + SELECT operations.payload FROM operations + JOIN host_operation ON host_operation.operation = operations.id + WHERE host_operation.host = %s AND host_operation.operation = %s + """, + (host_id, operation_id) + ) + row = self.cursor.fetchone() + if row: + return row[0] + else: + return None def claim_operation(self, operation_id: int, host_id: str) -> bool: # have to make a new cursor to set isolation level diff --git a/capsulflask/hub_api.py b/capsulflask/hub_api.py index 04ea4cc..066c41b 100644 --- a/capsulflask/hub_api.py +++ b/capsulflask/hub_api.py @@ -1,7 +1,9 @@ +import json +import ipaddress from flask import Blueprint from flask import current_app -from flask import request +from flask import request, make_response from werkzeug.exceptions import abort from capsulflask.db import get_model @@ -46,15 +48,131 @@ def heartbeat(host_id): @bp.route("/claim-operation//", methods=("POST",)) def claim_operation(operation_id: int, host_id: str): if authorized_for_host(host_id): - exists = get_model().host_operation_exists(operation_id, host_id) - if not exists: - return abort(404, "host operation not found") + payload_json = get_model().get_payload_json_from_host_operation(operation_id, host_id) + if payload_json is None: + error_message = f"{host_id} can't claim operation {operation_id} because host_operation row not found" + current_app.logger.error(error_message) + return abort(404, error_message) + + # right now if there is a can_claim_handler there needs to be a corresponding on_claimed_handler. + # this is sole-ly due to laziness in error handling. + can_claim_handlers = { + "create": can_claim_create, + } + on_claimed_handlers = { + "create": on_create_claimed, + } + error_message = "" + payload = None + payload_is_dict = False + payload_has_type = False + payload_has_valid_type = False + try: + payload = json.loads(payload_json) + payload_is_dict = isinstance(payload, dict) + payload_has_type = payload_is_dict and 'type' in payload + payload_has_valid_type = payload_has_type and payload['type'] in can_claim_handlers + + if not payload_is_dict: + error_message = "invalid json: expected an object" + elif not payload_has_type: + error_message = "invalid json: 'type' field is required" + elif not payload_has_valid_type: + error_message = f"invalid json: expected type \"{payload['type']}\" to be one of [{', '.join(can_claim_handlers.keys())}]" + except: + error_message = "could not parse payload as json" + + if error_message != "": + error_message = f"{host_id} can't claim operation {operation_id} because {error_message}" + current_app.logger.error(error_message) + return abort(400, error_message) + + # we will only return this payload as json if claiming succeeds, so might as well do this now... + payload['assignment_status'] = 'assigned' + + # invoke the appropriate can_claim_handler for this operation type + result_tuple = can_claim_handlers[payload['type']](payload, host_id) + modified_payload = result_tuple[0] + error_message = result_tuple[1] + + if error_message != "": + error_message = f"{host_id} can't claim operation {operation_id} because {error_message}" + current_app.logger.error(error_message) + return abort(400, error_message) + claimed = get_model().claim_operation(operation_id, host_id) if claimed: - return "ok" + modified_payload_json = json.dumps(modified_payload) + get_model().update_operation(operation_id, modified_payload_json) + on_claimed_handlers[payload['type']](modified_payload, host_id) + + response = make_response(modified_payload_json) + response.headers.set("Content-Type", "application/json") + + return response else: - return abort(409, "operation was already assigned to another host") + return abort(409, f"operation was already assigned to another host") else: current_app.logger.warning(f"/hub/claim-operation/{operation_id}/{host_id} returned 401: invalid token") return abort(401, "invalid host id or token") +def can_claim_create(payload, host_id) -> (str, str): + + hosts = get_model().list_hosts_with_networks(host_id) + + if host_id not in hosts: + return "", f"the host \"{host_id}\" does not appear to have any networks." + + networks = hosts[host_id]['networks'] + + vms_by_host_and_network = get_model().non_deleted_vms_by_host_and_network(host_id) + + vms_by_network = dict() + if host_id in vms_by_host_and_network: + vms_by_network = vms_by_host_and_network[host_id] + + allocated_ipv4_address = None + allocated_network_name = None + for network in networks: + vms = [] + if network["network_name"] in vms_by_network: + vms = vms_by_network[network["network_name"]] + + claimed_ipv4s = dict() + for vm in vms: + claimed_ipv4s[vm['public_ipv4']] = True + + + ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False) + ipv4_first_usable_ip = network["public_ipv4_first_usable_ip"] + ipv4_last_usable_ip = network["public_ipv4_last_usable_ip"] + + for ipv4_address in ipv4_network: + within_usable_range = ipv4_first_usable_ip <= str(ipv4_address) and str(ipv4_address) <= ipv4_last_usable_ip + if within_usable_range and str(ipv4_address) not in claimed_ipv4s: + allocated_ipv4_address = str(ipv4_address) + break + + if allocated_ipv4_address is not None: + allocated_network_name = network["network_name"] + break + + if allocated_network_name is None or allocated_ipv4_address is None: + return "", f"host \"{host_id}\" does not have any avaliable IP addresses on any of its networks." + + payload["network_name"] = allocated_network_name + payload["public_ipv4"] = allocated_ipv4_address + + return payload, "" + +def on_create_claimed(payload, host_id): + get_model().create_vm( + email=payload['email'], + id=payload['id'], + size=payload['size'], + os=payload['os'], + host=host_id, + network_name=payload['network_name'], + public_ipv4=payload['public_ipv4'], + ssh_authorized_keys=list(map(lambda x: x["name"], payload['ssh_authorized_keys'])), + ) \ No newline at end of file diff --git a/capsulflask/hub_model.py b/capsulflask/hub_model.py index b9fd1ee..ac2a865 100644 --- a/capsulflask/hub_model.py +++ b/capsulflask/hub_model.py @@ -36,7 +36,7 @@ class MockHub(VirtualizationInterface): 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_authorized_keys: list): + def create(self, email: str, id: str, os: str, size: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): validate_capsul_id(id) current_app.logger.info(f"mock create: {id} for {email}") sleep(1) @@ -180,7 +180,7 @@ class CapsulFlaskHub(VirtualizationInterface): return to_return - def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): + def create(self, email: str, id: str, os: str, size: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list): validate_capsul_id(id) online_hosts = get_model().get_online_hosts() #current_app.logger.debug(f"hub_model.create(): ${len(online_hosts)} hosts") @@ -188,6 +188,8 @@ class CapsulFlaskHub(VirtualizationInterface): type="create", email=email, id=id, + os=os, + size=size, template_image_file_name=template_image_file_name, vcpus=vcpus, memory_mb=memory_mb, diff --git a/capsulflask/schema_migrations/16_up_managed_ips.sql b/capsulflask/schema_migrations/16_up_managed_ips.sql index f8eb80c..f2d1f27 100644 --- a/capsulflask/schema_migrations/16_up_managed_ips.sql +++ b/capsulflask/schema_migrations/16_up_managed_ips.sql @@ -3,12 +3,12 @@ CREATE TABLE host_network ( public_ipv4_cidr_block TEXT NOT NULL, network_name TEXT NOT NULL, host TEXT NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT, - PRIMARY KEY (host, network_name) + CONSTRAINT host_network_pkey PRIMARY KEY (host, network_name) ); - INSERT INTO host_network (host, network_name, public_ipv4_cidr_block) VALUES ('baikal', 'virbr1', '69.61.2.162/27'), - ('baikal', 'virbr2', '69.61.2.194/26'); + ('baikal', 'virbr2', '69.61.2.194/26'), + ('baikal', 'virbr3', '69.61.38.193/26'); ALTER TABLE vms RENAME COLUMN last_seen_ipv4 TO public_ipv4; ALTER TABLE vms RENAME COLUMN last_seen_ipv6 TO public_ipv6; ALTER TABLE vms ADD COLUMN network_name TEXT; @@ -18,7 +18,8 @@ UPDATE vms SET network_name = 'virbr2' WHERE public_ipv4 >= '69.61.2.192'; ALTER TABLE vms ALTER COLUMN network_name SET NOT NULL; -ALTER TABLE vms ADD FOREIGN KEY (host, network_name) REFERENCES host_network(host, network_name) ON DELETE RESTRICT; +ALTER TABLE vms ADD CONSTRAINT vms_host_network_name_fkey FOREIGN KEY (host, network_name) + REFERENCES host_network(host, network_name) ON DELETE RESTRICT; UPDATE schemaversion SET version = 16; diff --git a/capsulflask/schema_migrations/17_down_bridge_and_network_name.sql b/capsulflask/schema_migrations/17_down_bridge_and_network_name.sql new file mode 100644 index 0000000..2f6d0a9 --- /dev/null +++ b/capsulflask/schema_migrations/17_down_bridge_and_network_name.sql @@ -0,0 +1,18 @@ + + +ALTER TABLE vms DROP CONSTRAINT vms_host_network_name_fkey; + +UPDATE host_network SET network_name = 'virbr1' WHERE virtual_bridge_name = "virbr1"; +UPDATE host_network SET network_name = 'virbr2' WHERE virtual_bridge_name = "virbr1"; +UPDATE host_network SET network_name = 'virbr3' WHERE virtual_bridge_name = "virbr2"; + +UPDATE vms SET network_name = 'virbr1' WHERE network_name = "public1"; +UPDATE vms SET network_name = 'virbr2' WHERE network_name = "public2"; +UPDATE vms SET network_name = 'virbr3' WHERE network_name = "public3"; + +ALTER TABLE host_network DROP COLUMN virtual_bridge_name; + +ALTER TABLE vms ADD CONSTRAINT vms_host_network_name_fkey FOREIGN KEY (host, network_name) + REFERENCES host_network(host, network_name) ON DELETE RESTRICT; + +UPDATE schemaversion SET version = 16; diff --git a/capsulflask/schema_migrations/17_up_bridge_and_network_name.sql b/capsulflask/schema_migrations/17_up_bridge_and_network_name.sql new file mode 100644 index 0000000..92d51a5 --- /dev/null +++ b/capsulflask/schema_migrations/17_up_bridge_and_network_name.sql @@ -0,0 +1,26 @@ + + +ALTER TABLE vms DROP CONSTRAINT vms_host_network_name_fkey; + +ALTER TABLE host_network RENAME COLUMN network_name TO virtual_bridge_name; + +ALTER TABLE host_network ADD COLUMN network_name TEXT; + +UPDATE host_network SET network_name = 'public1' WHERE virtual_bridge_name = 'virbr1'; +UPDATE host_network SET network_name = 'public2' WHERE virtual_bridge_name = 'virbr2'; +UPDATE host_network SET network_name = 'public3' WHERE virtual_bridge_name = 'virbr3'; + +UPDATE vms SET network_name = 'public1' WHERE network_name = 'virbr1'; +UPDATE vms SET network_name = 'public2' WHERE network_name = 'virbr2'; +UPDATE vms SET network_name = 'public3' WHERE network_name = 'virbr3'; + +ALTER TABLE host_network ALTER COLUMN network_name SET NOT NULL; + +ALTER TABLE host_network DROP CONSTRAINT host_network_pkey; +ALTER TABLE host_network ADD CONSTRAINT host_network_pkey PRIMARY KEY (host, network_name); + +ALTER TABLE vms ADD CONSTRAINT vms_host_network_name_fkey FOREIGN KEY (host, network_name) + REFERENCES host_network(host, network_name) ON DELETE RESTRICT; + +UPDATE schemaversion SET version = 17; + diff --git a/capsulflask/schema_migrations/18_down_start_ip_end_ip.sql b/capsulflask/schema_migrations/18_down_start_ip_end_ip.sql new file mode 100644 index 0000000..0b7725f --- /dev/null +++ b/capsulflask/schema_migrations/18_down_start_ip_end_ip.sql @@ -0,0 +1,6 @@ + + +ALTER TABLE host_network DROP COLUMN public_ipv4_first_usable_ip; +ALTER TABLE host_network DROP COLUMN public_ipv4_last_usable_ip; + +UPDATE schemaversion SET version = 17; diff --git a/capsulflask/schema_migrations/18_up_start_ip_end_ip.sql b/capsulflask/schema_migrations/18_up_start_ip_end_ip.sql new file mode 100644 index 0000000..b7dc702 --- /dev/null +++ b/capsulflask/schema_migrations/18_up_start_ip_end_ip.sql @@ -0,0 +1,25 @@ + + +ALTER TABLE host_network ADD COLUMN public_ipv4_first_usable_ip TEXT; +ALTER TABLE host_network ADD COLUMN public_ipv4_last_usable_ip TEXT; + +-- public1, 69.61.2.162/27 +UPDATE host_network +SET public_ipv4_first_usable_ip = '69.61.2.163', public_ipv4_last_usable_ip = '69.61.2.190' +WHERE network_name = 'public1'; + +-- public2, 69.61.2.194/26 +UPDATE host_network +SET public_ipv4_first_usable_ip = '69.61.2.195', public_ipv4_last_usable_ip = '69.61.2.254' +WHERE network_name = 'public2'; + +-- public3, 69.61.38.193/26 +UPDATE host_network +SET public_ipv4_first_usable_ip = '69.61.38.194', public_ipv4_last_usable_ip = '69.61.38.254' +WHERE network_name = 'public3'; + +ALTER TABLE host_network ALTER COLUMN public_ipv4_first_usable_ip SET NOT NULL; +ALTER TABLE host_network ALTER COLUMN public_ipv4_last_usable_ip SET NOT NULL; + +UPDATE schemaversion SET version = 18; + diff --git a/capsulflask/shell_scripts/capacity-avaliable.sh b/capsulflask/shell_scripts/capacity-avaliable.sh index d4b94f7..d9fbdb8 100755 --- a/capsulflask/shell_scripts/capacity-avaliable.sh +++ b/capsulflask/shell_scripts/capacity-avaliable.sh @@ -19,9 +19,9 @@ if [ "$ram_bytes_remainder" -le $((20 * 1024 * 1024 * 1024)) ]; then exit 1 fi -ipv4_limit=28 -used_ips=$(grep ip-add "/var/lib/libvirt/dnsmasq/virbr1.status" | cut -d '"' -f 4) -reserved_ips=$(cat "/var/lib/libvirt/dnsmasq/public1.hostsfile" | cut -d ',' -f 2) +ipv4_limit=61 +used_ips=$(grep ip-add "/var/lib/libvirt/dnsmasq/virbr3.status" | cut -d '"' -f 4) +reserved_ips=$(cat "/var/lib/libvirt/dnsmasq/public3.hostsfile" | cut -d ',' -f 2) total_addresses_used=$(printf "$used_ips\n$reserved_ips" | sort | uniq | wc -l) ipv4_count=$(printf "$total_addresses_used") diff --git a/capsulflask/shell_scripts/create.sh b/capsulflask/shell_scripts/create.sh index 0b3be0e..1c74cbc 100755 --- a/capsulflask/shell_scripts/create.sh +++ b/capsulflask/shell_scripts/create.sh @@ -9,6 +9,8 @@ template_file="/tank/img/$2" vcpus="$3" memory="$4" pubkeys="$5" +network_name="$6" +public_ipv4="$7" root_volume_size="25G" if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then @@ -38,6 +40,16 @@ echo "$pubkeys" | while IFS= read -r line; do fi done +if echo "$network_name" | grep -vqE "^[a-zA-Z0-9_-]+"; then + echo "network_name \"$network_name\" must match ^[a-zA-Z0-9_-]+" + exit 1 +fi + +if echo "$public_ipv4" | grep -vqE "^[0-9.]+$"; then + echo "public_ipv4 \"$public_ipv4\" must match ^[0-9.]+$" + exit 1 +fi + disk="/tank/vm/$vmname.qcow2" cdrom="/tank/vm/$vmname.iso" xml="/tank/vm/$vmname.xml" @@ -66,7 +78,7 @@ virt-install \ --os-variant generic \ --virt-type kvm \ --graphics vnc,listen=127.0.0.1 \ - --network network=public1,filterref=clean-traffic,model=virtio \ + --network network=$network_name,filterref=clean-traffic,model=virtio \ --import \ --print-xml > "$xml" diff --git a/capsulflask/spoke_api.py b/capsulflask/spoke_api.py index 01fbe54..3ca86bc 100644 --- a/capsulflask/spoke_api.py +++ b/capsulflask/spoke_api.py @@ -104,7 +104,7 @@ def handle_create(operation_id, request_body): current_app.logger.error(f"/hosts/operation returned 400: operation_id is required for create ") return abort(400, f"bad request; operation_id is required. try POST /spoke/operation/") - parameters = ["email", "id", "template_image_file_name", "vcpus", "memory_mb", "ssh_authorized_keys"] + parameters = ["email", "id", "os", "size", "template_image_file_name", "vcpus", "memory_mb", "ssh_authorized_keys"] error_message = "" for parameter in parameters: if parameter not in request_body: @@ -115,14 +115,28 @@ def handle_create(operation_id, request_body): return abort(400, f"bad request; {error_message}") # only one host should create the vm, so we first race to assign this create operation to ourselves. - # only one host will win this race + # only one host will win this race. authorization_header = f"Bearer {current_app.config['SPOKE_HOST_TOKEN']}" url = f"{current_app.config['HUB_URL']}/hub/claim-operation/{operation_id}/{current_app.config['SPOKE_HOST_ID']}" result = current_app.config['HTTP_CLIENT'].do_http_sync(url, body=None, authorization_header=authorization_header) assignment_status = "" if result.status_code == 200: - assignment_status = "assigned" + try: + assignment_info = json.loads(result.body) + if not isinstance(assignment_info, dict): + return abort(503, f"hub at '{url}' returned 200, but did not return assignment_info json object") + if 'network_name' not in assignment_info: + return abort(503, f"hub at '{url}' returned 200, but the returned assignment_info object did not include network_name") + if 'public_ipv4' not in assignment_info: + return abort(503, f"hub at '{url}' returned 200, but the returned assignment_info object did not include public_ipv4") + + assignment_status = "assigned" + request_body['network_name'] = assignment_info['network_name'] + request_body['public_ipv4'] = assignment_info['public_ipv4'] + except: + return abort(503, f"hub at '{url}' returned 200, but did not return valid json") + elif result.status_code == 409: assignment_status = "assigned_to_other_host" else: @@ -137,7 +151,9 @@ def handle_create(operation_id, request_body): template_image_file_name=request_body['template_image_file_name'], vcpus=request_body['vcpus'], memory_mb=request_body['memory_mb'], - ssh_authorized_keys=request_body['ssh_authorized_keys'], + ssh_authorized_keys=list(map(lambda x: x['content'], request_body['ssh_authorized_keys'])), + network_name=request_body['network_name'], + public_ipv4=request_body['public_ipv4'], ) except: error_message = my_exec_info_message(sys.exc_info()) @@ -147,7 +163,11 @@ def handle_create(operation_id, request_body): params= f"{params} vcpus='{request_body['vcpus'] if 'vcpus' in request_body else 'KeyError'}', " params= f"{params} memory_mb='{request_body['memory_mb'] if 'memory_mb' in request_body else 'KeyError'}', " params= f"{params} ssh_authorized_keys='{request_body['ssh_authorized_keys'] if 'ssh_authorized_keys' 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} public_ipv4='{request_body['public_ipv4'] if 'public_ipv4' in request_body else 'KeyError'}', " + current_app.logger.error(f"spoke_model.create({params}) failed: {error_message}") + return jsonify(dict(assignment_status=assignment_status, error_message=error_message)) return jsonify(dict(assignment_status=assignment_status)) diff --git a/capsulflask/spoke_model.py b/capsulflask/spoke_model.py index a8ad013..9ab0d3a 100644 --- a/capsulflask/spoke_model.py +++ b/capsulflask/spoke_model.py @@ -14,28 +14,37 @@ from capsulflask.shared import VirtualizationInterface, VirtualMachine, validate class MockSpoke(VirtualizationInterface): + + def __init__(self): + self.capsuls = dict() + def capacity_avaliable(self, additional_ram_bytes): return True def get(self, id, get_ssh_host_keys): validate_capsul_id(id) + ipv4 = "1.1.1.1" + if id in self.capsuls: + ipv4 = self.capsuls[id]['public_ipv4'] + if get_ssh_host_keys: ssh_host_keys = json.loads("""[ {"key_type":"ED25519", "content":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8cna0zeKSKl/r8whdn/KmDWhdzuWRVV0GaKIM+eshh", "sha256":"V4X2apAF6btGAfS45gmpldknoDX0ipJ5c6DLfZR2ttQ"}, {"key_type":"RSA", "content":"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCvotgzgEP65JUQ8S8OoNKy1uEEPEAcFetSp7QpONe6hj4wPgyFNgVtdoWdNcU19dX3hpdse0G8OlaMUTnNVuRlbIZXuifXQ2jTtCFUA2mmJ5bF+XjGm3TXKMNGh9PN+wEPUeWd14vZL+QPUMev5LmA8cawPiU5+vVMLid93HRBj118aCJFQxLgrdP48VPfKHFRfCR6TIjg1ii3dH4acdJAvlmJ3GFB6ICT42EmBqskz2MPe0rIFxH8YohCBbAbrbWYcptHt4e48h4UdpZdYOhEdv89GrT8BF2C5cbQ5i9qVpI57bXKrj8hPZU5of48UHLSpXG8mbH0YDiOQOfKX/Mt", "sha256":"ghee6KzRnBJhND2kEUZSaouk7CD6o6z2aAc8GPkV+GQ"}, {"key_type":"ECDSA", "content":"ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLLgOoATz9R4aS2kk7vWoxX+lshK63t9+5BIHdzZeFE1o+shlcf0Wji8cN/L1+m3bi0uSETZDOAWMP3rHLJj9Hk=", "sha256":"aCYG1aD8cv/TjzJL0bi9jdabMGksdkfa7R8dCGm1yYs"} ]""") - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1", state="running", ssh_host_keys=ssh_host_keys) + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running", ssh_host_keys=ssh_host_keys) - return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4="1.1.1.1", state="running") + return VirtualMachine(id, current_app.config["SPOKE_HOST_ID"], ipv4=ipv4, state="running") 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_authorized_keys: list): + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list, network_name: str, public_ipv4: str): validate_capsul_id(id) current_app.logger.info(f"mock create: {id} for {email}") + self.capsuls[id] = dict(email=email, id=id, network_name=network_name, public_ipv4=public_ipv4) sleep(1) def destroy(self, email: str, id: str): @@ -129,7 +138,7 @@ class ShellScriptSpoke(VirtualizationInterface): 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_authorized_keys: list): + def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_authorized_keys: list, network_name: str, public_ipv4: str): validate_capsul_id(id) if not re.match(r"^[a-zA-Z0-9/_.-]+$", template_image_file_name): @@ -139,12 +148,18 @@ class ShellScriptSpoke(VirtualizationInterface): if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$", ssh_authorized_key): raise ValueError(f"ssh_authorized_key \"{ssh_authorized_key}\" must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$\"") - if vcpus < 1 or vcpus > 8: + if isinstance(vcpus, int) and (vcpus < 1 or vcpus > 8): raise ValueError(f"vcpus \"{vcpus}\" must match 1 <= vcpus <= 8") - if memory_mb < 512 or memory_mb > 16384: + if isinstance(memory_mb, int) and (memory_mb < 512 or memory_mb > 16384): raise ValueError(f"memory_mb \"{memory_mb}\" must match 512 <= memory_mb <= 16384") + 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 re.match(r"^[0-9.]+$", public_ipv4): + raise ValueError(f"public_ipv4 \"{public_ipv4}\" must match \"^[0-9.]+$\"") + ssh_keys_string = "\n".join(ssh_authorized_keys) completedProcess = run([ @@ -153,7 +168,9 @@ class ShellScriptSpoke(VirtualizationInterface): template_image_file_name, str(vcpus), str(memory_mb), - ssh_keys_string + ssh_keys_string, + network_name, + public_ipv4 ], capture_output=True) self.validate_completed_process(completedProcess, email) @@ -166,6 +183,8 @@ class ShellScriptSpoke(VirtualizationInterface): vcpus={str(vcpus)} memory={str(memory_mb)} ssh_authorized_keys={ssh_keys_string} + network_name={network_name} + public_ipv4={public_ipv4} """ if not status == "success":