From 79ef90c380a831c44906eb565059ea2368528590 Mon Sep 17 00:00:00 2001 From: forest Date: Fri, 9 Jul 2021 17:08:38 -0500 Subject: [PATCH 01/13] hub allocate capsul IP addr when the create operation is being claimed create.sh will now be passed two extra arguments from the web app: network_name and public_ipv4_address network_name will be virbr1 or virbr2 or whatever the network is called and public_ipv4_address will be an ipv4 from that network which is not currently being used --- capsulflask/admin.py | 6 +-- capsulflask/db_model.py | 42 ++++++++++++--- capsulflask/hub_api.py | 108 ++++++++++++++++++++++++++++++++++--- capsulflask/spoke_api.py | 23 +++++++- capsulflask/spoke_model.py | 18 +++++-- 5 files changed, 175 insertions(+), 22 deletions(-) diff --git a/capsulflask/admin.py b/capsulflask/admin.py index ccdf5cd..22f94d6 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(500) #operations = get_model().list_all_operations() display_hosts = [] diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index fcd99f4..d7df878 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -56,8 +56,12 @@ 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: + self.cursor.execute(f"{query} AND host = %s", (host_id)) hosts = dict() for row in self.cursor.fetchall(): @@ -318,11 +322,15 @@ class DBModel: # ------ HOSTS --------- - def list_hosts_with_networks(self): - self.cursor.execute(""" + 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 FROM hosts JOIN host_network ON host_network.host = hosts.id - """) + """ + if host_id is None: + self.cursor.execute(query) + else: + self.cursor.execute(f"{query} WHERE hosts.id = %s", (host_id)) hosts = dict() for row in self.cursor.fetchall(): @@ -379,6 +387,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( @@ -405,9 +420,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..11b91f9 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,109 @@ 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) + + can_claim_handlers = { + "create": can_claim_create, + } + 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 is not "": + 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) + payload_json = result_tuple[0] + error_message = result_tuple[1] + + if error_message is not "": + 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" + get_model().update_operation(operation_id, payload_json) + + response = make_response(payload_json) + response.header.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) + i = 0 + for ipv4_address in ipv4_network: + i += 1 + if i > 2 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_address"] = allocated_ipv4_address + + return json.dumps(payload), "" \ No newline at end of file diff --git a/capsulflask/spoke_api.py b/capsulflask/spoke_api.py index 01fbe54..16be975 100644 --- a/capsulflask/spoke_api.py +++ b/capsulflask/spoke_api.py @@ -115,14 +115,29 @@ 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: + 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_address' not in assignment_info: + return abort(503, f"hub at '{url}' returned 200, but the returned assignment_info object did not include public_ipv4_address") + + request_body['network_name'] = assignment_info['network_name'] + request_body['public_ipv4_address'] = assignment_info['public_ipv4_address'] + except: + return abort(503, f"hub at '{url}' returned 200, but did not return valid json") + assignment_status = "assigned" + elif result.status_code == 409: assignment_status = "assigned_to_other_host" else: @@ -138,6 +153,8 @@ def handle_create(operation_id, request_body): vcpus=request_body['vcpus'], memory_mb=request_body['memory_mb'], ssh_authorized_keys=request_body['ssh_authorized_keys'], + network_name=request_body['network_name'], + public_ipv4_address=request_body['public_ipv4_address'], ) except: error_message = my_exec_info_message(sys.exc_info()) @@ -147,7 +164,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_address='{request_body['public_ipv4_address'] if 'public_ipv4_address' 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..a11270d 100644 --- a/capsulflask/spoke_model.py +++ b/capsulflask/spoke_model.py @@ -129,7 +129,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_address: str): validate_capsul_id(id) if not re.match(r"^[a-zA-Z0-9/_.-]+$", template_image_file_name): @@ -139,12 +139,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_address): + raise ValueError(f"public_ipv4_address \"{public_ipv4_address}\" must match \"^[0-9.]+$\"") + ssh_keys_string = "\n".join(ssh_authorized_keys) completedProcess = run([ @@ -153,7 +159,9 @@ class ShellScriptSpoke(VirtualizationInterface): template_image_file_name, str(vcpus), str(memory_mb), - ssh_keys_string + ssh_keys_string, + network_name, + public_ipv4_address ], capture_output=True) self.validate_completed_process(completedProcess, email) @@ -166,6 +174,8 @@ class ShellScriptSpoke(VirtualizationInterface): vcpus={str(vcpus)} memory={str(memory_mb)} ssh_authorized_keys={ssh_keys_string} + network_name={network_name} + public_ipv4_address={public_ipv4_address} """ if not status == "success": From a2f2e744e46d35a352a0e0fc21123835f961d409 Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 11 Jul 2021 10:28:47 -0500 Subject: [PATCH 02/13] MAIL_USE_TLS=False, MAIL_USE_SSL=True defaults --- capsulflask/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index ab8df11..f85f268 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=""), From fcbea1e29b11e6c474bbaa7c5a9990e4254e9731 Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 11 Jul 2021 12:18:58 -0500 Subject: [PATCH 03/13] fixing capsul creation after I broke it with the pre-allocated IP address changes --- capsulflask/admin.py | 2 +- capsulflask/console.py | 13 +++++-------- capsulflask/db_model.py | 26 ++++++++++++++++++++------ capsulflask/hub_api.py | 37 ++++++++++++++++++++++++++++--------- capsulflask/hub_model.py | 6 ++++-- capsulflask/spoke_api.py | 17 ++++++++--------- capsulflask/spoke_model.py | 25 +++++++++++++++++-------- 7 files changed, 83 insertions(+), 43 deletions(-) diff --git a/capsulflask/admin.py b/capsulflask/admin.py index 22f94d6..511c94d 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -54,7 +54,7 @@ def index(): 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/console.py b/capsulflask/console.py index 55e0ffa..e97b805 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -245,20 +245,17 @@ def create(): if len(errors) == 0: id = make_capsul_id() - get_model().create_vm( - email=session["account"], - id=id, - size=size, - os=os, - ssh_authorized_keys=list(map(lambda x: x["name"], posted_keys)) - ) + # we can't create the vm record in the DB yet because its IP address needs to be allocated first. + # so it will be created when the allocation happens inside the hub_api. current_app.config["HUB_MODEL"].create( email = session["account"], id=id, + os=os, + size=size, template_image_file_name=operating_systems[os]['template_image_file_name'], vcpus=vm_sizes[size]['vcpus'], memory_mb=vm_sizes[size]['memory_mb'], - ssh_authorized_keys=list(map(lambda x: x["content"], posted_keys)) + ssh_authorized_keys=list(map(lambda x: dict(name=x['name'], content=x['content']), posted_keys)) ) return redirect(f"{url_for('console.index')}?created={id}") diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index d7df878..a6ef58d 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -1,4 +1,6 @@ +import re + # I was never able to get this type hinting to work correctly # from psycopg2.extensions import connection as Psycopg2Connection, cursor as Psycopg2Cursor from nanoid import generate @@ -61,7 +63,13 @@ class DBModel: if host_id is None: self.cursor.execute(query) else: - self.cursor.execute(f"{query} AND host = %s", (host_id)) + 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(): @@ -150,12 +158,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) - VALUES (%s, %s, %s, %s) + 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: @@ -330,7 +338,13 @@ class DBModel: if host_id is None: self.cursor.execute(query) else: - self.cursor.execute(f"{query} WHERE hosts.id = %s", (host_id)) + 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(): diff --git a/capsulflask/hub_api.py b/capsulflask/hub_api.py index 11b91f9..4d7e9cb 100644 --- a/capsulflask/hub_api.py +++ b/capsulflask/hub_api.py @@ -54,9 +54,14 @@ def claim_operation(operation_id: int, host_id: str): 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 @@ -77,7 +82,7 @@ def claim_operation(operation_id: int, host_id: str): except: error_message = "could not parse payload as json" - if error_message is not "": + 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) @@ -87,20 +92,22 @@ def claim_operation(operation_id: int, host_id: str): # invoke the appropriate can_claim_handler for this operation type result_tuple = can_claim_handlers[payload['type']](payload, host_id) - payload_json = result_tuple[0] + modified_payload = result_tuple[0] error_message = result_tuple[1] - if error_message is not "": + 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: - get_model().update_operation(operation_id, payload_json) + 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(payload_json) - response.header.set("Content-Type", "application/json") + response = make_response(modified_payload_json) + response.headers.set("Content-Type", "application/json") return response else: @@ -116,7 +123,7 @@ def can_claim_create(payload, host_id) -> (str, str): if host_id not in hosts: return "", f"the host \"{host_id}\" does not appear to have any networks." - networks = hosts[host_id].networks + networks = hosts[host_id]['networks'] vms_by_host_and_network = get_model().non_deleted_vms_by_host_and_network(host_id) @@ -151,6 +158,18 @@ def can_claim_create(payload, host_id) -> (str, str): 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_address"] = allocated_ipv4_address + payload["public_ipv4"] = allocated_ipv4_address - return json.dumps(payload), "" \ No newline at end of file + 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/spoke_api.py b/capsulflask/spoke_api.py index 16be975..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: @@ -128,16 +128,15 @@ def handle_create(operation_id, request_body): 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_address' not in assignment_info: - return abort(503, f"hub at '{url}' returned 200, but the returned assignment_info object did not include public_ipv4_address") + 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_address'] = assignment_info['public_ipv4_address'] + request_body['public_ipv4'] = assignment_info['public_ipv4'] except: return abort(503, f"hub at '{url}' returned 200, but did not return valid json") - assignment_status = "assigned" - elif result.status_code == 409: assignment_status = "assigned_to_other_host" else: @@ -152,9 +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_address=request_body['public_ipv4_address'], + public_ipv4=request_body['public_ipv4'], ) except: error_message = my_exec_info_message(sys.exc_info()) @@ -165,7 +164,7 @@ def handle_create(operation_id, request_body): 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_address='{request_body['public_ipv4_address'] if 'public_ipv4_address' 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}") diff --git a/capsulflask/spoke_model.py b/capsulflask/spoke_model.py index a11270d..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, network_name: str, public_ipv4_address: str): + 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): @@ -148,8 +157,8 @@ class ShellScriptSpoke(VirtualizationInterface): 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_address): - raise ValueError(f"public_ipv4_address \"{public_ipv4_address}\" must match \"^[0-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) @@ -161,7 +170,7 @@ class ShellScriptSpoke(VirtualizationInterface): str(memory_mb), ssh_keys_string, network_name, - public_ipv4_address + public_ipv4 ], capture_output=True) self.validate_completed_process(completedProcess, email) @@ -175,7 +184,7 @@ class ShellScriptSpoke(VirtualizationInterface): memory={str(memory_mb)} ssh_authorized_keys={ssh_keys_string} network_name={network_name} - public_ipv4_address={public_ipv4_address} + public_ipv4={public_ipv4} """ if not status == "success": From ad9c3476c75fd74dcbdf3181b3890c6e996aa085 Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 11:29:33 -0500 Subject: [PATCH 04/13] simplify cidr block logic a little bit --- capsulflask/admin.py | 15 +++++++-------- capsulflask/hub_api.py | 9 ++++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/capsulflask/admin.py b/capsulflask/admin.py index 511c94d..49c685f 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -35,17 +35,16 @@ def index(): display_host = dict(name=host_id, networks=value['networks']) for network in display_host['networks']: + public_ipv4_cidr_block_split = network["public_ipv4_cidr_block"].split("/") + if len(public_ipv4_cidr_block_split) != 2: + raise ValueError(f"network {network['network_name']} has invalid cidr block {network['public_ipv4_cidr_block']}") + + network_start_int = int(ipaddress.ip_address(public_ipv4_cidr_block_split[0]))+1 + 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_end_int = int(ipv4_address) network['allocations'] = [] network_addresses_width = float((network_end_int-network_start_int)) diff --git a/capsulflask/hub_api.py b/capsulflask/hub_api.py index 4d7e9cb..3482cf9 100644 --- a/capsulflask/hub_api.py +++ b/capsulflask/hub_api.py @@ -142,11 +142,14 @@ def can_claim_create(payload, host_id) -> (str, str): for vm in vms: claimed_ipv4s[vm['public_ipv4']] = True + public_ipv4_cidr_block_split = network["public_ipv4_cidr_block"].split("/") + if len(public_ipv4_cidr_block_split) != 2: + raise ValueError(f"network {network['network_name']} has invalid cidr block {network['public_ipv4_cidr_block']}") + + ipv4_network_first_address_int = int(ipaddress.ip_address(public_ipv4_cidr_block_split[0])) ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False) - i = 0 for ipv4_address in ipv4_network: - i += 1 - if i > 2 and str(ipv4_address) not in claimed_ipv4s: + if int(ipv4_address) > ipv4_network_first_address_int and str(ipv4_address) not in claimed_ipv4s: allocated_ipv4_address = str(ipv4_address) break From aaf33a245b1308f694cc5857962ba7d3f2e1de0c Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 11:29:37 -0500 Subject: [PATCH 05/13] point capsul at new public3/virbr3 network --- capsulflask/shell_scripts/capacity-avaliable.sh | 6 +++--- capsulflask/shell_scripts/create.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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..9ae1ebf 100755 --- a/capsulflask/shell_scripts/create.sh +++ b/capsulflask/shell_scripts/create.sh @@ -66,7 +66,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=public3,filterref=clean-traffic,model=virtio \ --import \ --print-xml > "$xml" From be54117736e6c28715805ac1f4c5b33a22caa3f6 Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 12:16:32 -0500 Subject: [PATCH 06/13] migration 17: network_name -> virtual_bridge_name, add network_name col --- capsulflask/db.py | 2 +- .../schema_migrations/16_up_managed_ips.sql | 9 ++++--- .../17_down_bridge_and_network_name.sql | 18 +++++++++++++ .../17_up_bridge_and_network_name.sql | 26 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 capsulflask/schema_migrations/17_down_bridge_and_network_name.sql create mode 100644 capsulflask/schema_migrations/17_up_bridge_and_network_name.sql diff --git a/capsulflask/db.py b/capsulflask/db.py index 57137b1..1b75fc0 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 = 16 + desiredSchemaVersion = 17 cursor = connection.cursor() 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; + From b459e56f3a123e34876473405f32f6b8585e8db7 Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 12:19:56 -0500 Subject: [PATCH 07/13] use the provided network_name when creating a capsul --- capsulflask/shell_scripts/create.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/capsulflask/shell_scripts/create.sh b/capsulflask/shell_scripts/create.sh index 9ae1ebf..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=public3,filterref=clean-traffic,model=virtio \ + --network network=$network_name,filterref=clean-traffic,model=virtio \ --import \ --print-xml > "$xml" From 6bdb1331534d84c9292d43758975e100c6280ecf Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 12:23:27 -0500 Subject: [PATCH 08/13] make admin display work better on phones --- capsulflask/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/capsulflask/admin.py b/capsulflask/admin.py index 49c685f..b29574e 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -19,7 +19,7 @@ bp = Blueprint("admin", __name__, url_prefix="/admin") def index(): 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(500) + network_display_width_px = float(380) #operations = get_model().list_all_operations() display_hosts = [] From fbe9c7fca44b47f786940aab55b5e849b332928b Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 12:27:07 -0500 Subject: [PATCH 09/13] fix last IP address hanging off the end of the display on admin page --- capsulflask/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/capsulflask/admin.py b/capsulflask/admin.py index b29574e..8368224 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -19,7 +19,7 @@ bp = Blueprint("admin", __name__, url_prefix="/admin") def index(): 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(380) + network_display_width_px = float(270) #operations = get_model().list_all_operations() display_hosts = [] @@ -47,7 +47,7 @@ def index(): network_end_int = int(ipv4_address) 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]: From 06a2bd3a6f096e7bf5f694e5b80e56a0b8195c8f Mon Sep 17 00:00:00 2001 From: forest Date: Mon, 12 Jul 2021 14:38:56 -0500 Subject: [PATCH 10/13] add public_ipv4_first_usable_ip, public_ipv4_last_usable_ip --- capsulflask/admin.py | 11 ++------ capsulflask/db.py | 2 +- capsulflask/db_model.py | 11 ++++++-- capsulflask/hub_api.py | 10 ++++---- .../18_down_start_ip_end_ip.sql | 6 +++++ .../18_up_start_ip_end_ip.sql | 25 +++++++++++++++++++ 6 files changed, 48 insertions(+), 17 deletions(-) create mode 100644 capsulflask/schema_migrations/18_down_start_ip_end_ip.sql create mode 100644 capsulflask/schema_migrations/18_up_start_ip_end_ip.sql diff --git a/capsulflask/admin.py b/capsulflask/admin.py index 8368224..50cc61e 100644 --- a/capsulflask/admin.py +++ b/capsulflask/admin.py @@ -35,17 +35,10 @@ def index(): display_host = dict(name=host_id, networks=value['networks']) for network in display_host['networks']: - public_ipv4_cidr_block_split = network["public_ipv4_cidr_block"].split("/") - if len(public_ipv4_cidr_block_split) != 2: - raise ValueError(f"network {network['network_name']} has invalid cidr block {network['public_ipv4_cidr_block']}") - network_start_int = int(ipaddress.ip_address(public_ipv4_cidr_block_split[0]))+1 + 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"])) - ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False) - network_end_int = -1 - for ipv4_address in ipv4_network: - network_end_int = int(ipv4_address) - network['allocations'] = [] network_addresses_width = float((network_end_int-network_start_int)+1) diff --git a/capsulflask/db.py b/capsulflask/db.py index 1b75fc0..23b3621 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 a6ef58d..976985a 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -332,7 +332,9 @@ class DBModel: 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 FROM hosts + 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: @@ -351,7 +353,12 @@ class DBModel: 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 diff --git a/capsulflask/hub_api.py b/capsulflask/hub_api.py index 3482cf9..066c41b 100644 --- a/capsulflask/hub_api.py +++ b/capsulflask/hub_api.py @@ -142,14 +142,14 @@ def can_claim_create(payload, host_id) -> (str, str): for vm in vms: claimed_ipv4s[vm['public_ipv4']] = True - public_ipv4_cidr_block_split = network["public_ipv4_cidr_block"].split("/") - if len(public_ipv4_cidr_block_split) != 2: - raise ValueError(f"network {network['network_name']} has invalid cidr block {network['public_ipv4_cidr_block']}") - ipv4_network_first_address_int = int(ipaddress.ip_address(public_ipv4_cidr_block_split[0])) 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: - if int(ipv4_address) > ipv4_network_first_address_int and str(ipv4_address) not in claimed_ipv4s: + 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 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; + From 6b53c80a7109787d3a146ae3c198ce6f33d50181 Mon Sep 17 00:00:00 2001 From: 3wc <3wc.cyberia@doesthisthing.work> Date: Fri, 9 Jul 2021 23:27:41 +0200 Subject: [PATCH 11/13] Initial attempt at Docker --- .drone.yml | 14 ++++++++++++++ Dockerfile | 26 ++++++++++++++++++++++++++ docker-compose.yml | 26 ++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .drone.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..a487bc8 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,14 @@ +--- +kind: pipeline +name: publish docker image +steps: + - name: build and publish + image: plugins/docker + settings: + username: + from_secret: docker_reg_username_3wc + password: + from_secret: docker_reg_passwd_3wc + repo: 3wordchant/capsul-flask + tags: latest + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b7af19 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.8-alpine + +RUN apk add gettext git gcc python3-dev musl-dev \ + libffi-dev zlib-dev jpeg-dev libjpeg postgresql-dev build-base \ + --virtual .build-dependencies + +RUN mkdir /code +WORKDIR /code +COPY Pipfile Pipfile.lock /code/ + +RUN pip install pipenv setuptools wheel cppy + +RUN pipenv install --system --deploy --verbose + +RUN apk del .build-dependencies \ + && rm -rf /var/cache/apk/* /tmp/* + +RUN apk add --no-cache libpq libstdc++ libjpeg + +COPY . /code/ + +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--worker-connections", "1000", "app:app"] + +VOLUME /code + +EXPOSE 5000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac5946e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +--- +version: "3.8" + +services: + app: + image: 3wordchant/capsul-flask:latest + build: . + volumes: + - "./:/code" + depends_on: + - db + ports: + - "5000:5000" + environment: + - "POSTGRES_CONNECTION_PARAMETERS=host=db port=5432 user=capsul password=capsul dbname=capsul" + db: + image: "postgres:9.6.5" + volumes: + - "postgres:/var/lib/postgresql/data" + environment: + POSTGRES_USER: capsul + POSTGRES_PASSWORD: capsul + POSTGRES_DB: capsul + +volumes: + postgres: From 6db163365c96a1df5633771992764e80e5e75967 Mon Sep 17 00:00:00 2001 From: 3wc <3wc.cyberia@doesthisthing.work> Date: Sat, 10 Jul 2021 14:23:33 +0200 Subject: [PATCH 12/13] Multi-stage build oh my! --- Dockerfile | 28 +++++++++++++++++----------- docker-compose.yml | 2 +- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9b7af19..0bfea76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,32 @@ -FROM python:3.8-alpine +FROM python:3.8-alpine as build RUN apk add gettext git gcc python3-dev musl-dev \ libffi-dev zlib-dev jpeg-dev libjpeg postgresql-dev build-base \ --virtual .build-dependencies -RUN mkdir /code -WORKDIR /code -COPY Pipfile Pipfile.lock /code/ +RUN mkdir -p /app/{code,venv} +WORKDIR /app/code +COPY Pipfile Pipfile.lock /app/code/ -RUN pip install pipenv setuptools wheel cppy +RUN python3 -m venv /app/venv +RUN pip install pipenv setuptools +ENV PATH="/app/venv/bin:$PATH" VIRTUAL_ENV="/app/venv" +RUN pip install wheel cppy +# Install dependencies into the virtual environment with Pipenv +RUN pipenv install --deploy --verbose -RUN pipenv install --system --deploy --verbose - -RUN apk del .build-dependencies \ - && rm -rf /var/cache/apk/* /tmp/* +FROM python:3.8-alpine RUN apk add --no-cache libpq libstdc++ libjpeg -COPY . /code/ +COPY . /app/code/ +WORKDIR /app/code + +COPY --from=build /app/venv /app/venv +ENV PATH="/app/venv/bin:$PATH" VIRTUAL_ENV="/app/venv" CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--worker-connections", "1000", "app:app"] -VOLUME /code +VOLUME /app/code EXPOSE 5000 diff --git a/docker-compose.yml b/docker-compose.yml index ac5946e..11b546b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: image: 3wordchant/capsul-flask:latest build: . volumes: - - "./:/code" + - "./:/app/code" depends_on: - db ports: From e9934a8a6bd81a1793683669483787534178a8af Mon Sep 17 00:00:00 2001 From: 3wc <3wc.cyberia@doesthisthing.work> Date: Sun, 11 Jul 2021 12:36:10 +0200 Subject: [PATCH 13/13] Use Flask server in development --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 11b546b..ad93bde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,9 @@ services: - "5000:5000" environment: - "POSTGRES_CONNECTION_PARAMETERS=host=db port=5432 user=capsul password=capsul dbname=capsul" + # The image uses gunicorn by default, let's override it with Flask's + # built-in development server + command: ["flask", "run", "-h", "0.0.0.0", "-p", "5000"] db: image: "postgres:9.6.5" volumes: