From 426fad7b106d61e61bdfe5492b914f43d826c0f8 Mon Sep 17 00:00:00 2001 From: forest Date: Sun, 10 May 2020 18:59:30 -0500 Subject: [PATCH] first try at creating VirtualizationInterface --- capsulflask/auth.py | 20 +-- capsulflask/db.py | 6 +- capsulflask/db_model.py | 33 +++++ .../02_down_accounts_vms_etc.sql | 10 +- .../02_up_accounts_vms_etc.sql | 64 ++++++++- capsulflask/shell_scripts/create.sh | 79 +++++++++++ capsulflask/shell_scripts/destroy.sh | 48 +++++++ capsulflask/shell_scripts/get.sh | 16 +++ capsulflask/shell_scripts/listIds.sh | 3 + capsulflask/templates/base.html | 2 +- capsulflask/virt_model.py | 134 ++++++++++++++++++ test | 2 + 12 files changed, 395 insertions(+), 22 deletions(-) create mode 100644 capsulflask/db_model.py create mode 100644 capsulflask/shell_scripts/create.sh create mode 100644 capsulflask/shell_scripts/destroy.sh create mode 100644 capsulflask/shell_scripts/get.sh create mode 100644 capsulflask/shell_scripts/listIds.sh create mode 100644 capsulflask/virt_model.py create mode 100644 test diff --git a/capsulflask/auth.py b/capsulflask/auth.py index 673a8b1..6540a85 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -49,17 +49,17 @@ def login(): current_app.config["FLASK_MAIL_INSTANCE"].send( Message( - "Click This Link to Login to Capsul", - body=f""" - Navigate to {link} to log into capsul. + "Click This Link to Login to Capsul", + body=f""" + Navigate to {link} to log into capsul. - If you didn't request this, ignore this message. - """, - html=f""" -

Navigate to {link} to log into capsul.

-

If you didn't request this, ignore this message.

- """, - recipients=[email] + If you didn't request this, ignore this message. + """, + html=f""" +

Navigate to {link} to log into capsul.

+

If you didn't request this, ignore this message.

+ """, + recipients=[email] ) ) diff --git a/capsulflask/db.py b/capsulflask/db.py index f877fb3..a1299d2 100644 --- a/capsulflask/db.py +++ b/capsulflask/db.py @@ -8,7 +8,7 @@ from psycopg2 import pool from flask import current_app from flask import g -from capsulflask.model import Model +from capsulflask.db_model import DBModel def init_app(app): databaseUrl = urlparse(app.config['DATABASE_URL']) @@ -111,8 +111,8 @@ def get_model(): if 'model' not in g: connection = current_app.config['PSYCOPG2_CONNECTION_POOL'].getconn() cursor = connection.cursor() - g.model = Model(connection, cursor) - return g.model + g.db_model = DBModel(connection, cursor) + return g.db_model def close_db(e=None): diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py new file mode 100644 index 0000000..20cf67b --- /dev/null +++ b/capsulflask/db_model.py @@ -0,0 +1,33 @@ + +from nanoid import generate + + +class DBModel: + def __init__(self, connection, cursor): + self.connection = connection + self.cursor = cursor + + def login(self, email): + self.cursor.execute("SELECT * FROM accounts WHERE email = %s", (email, )) + if len(self.cursor.fetchall()) == 0: + self.cursor.execute("INSERT INTO accounts (email) VALUES (%s)", (email, )) + + self.cursor.execute("SELECT token FROM login_tokens WHERE email = %s", (email, )) + if len(self.cursor.fetchall()) > 2: + return None + + token = generate() + self.cursor.execute("INSERT INTO login_tokens (email, token) VALUES (%s, %s)", (email, token)) + self.connection.commit() + + return token + + def consumeToken(self, token): + self.cursor.execute("SELECT email FROM login_tokens WHERE token = %s", (token, )) + rows = self.cursor.fetchall() + if len(rows) > 0: + email = rows[0][0] + self.cursor.execute("DELETE FROM login_tokens WHERE email = %s", (email, )) + self.connection.commit() + return email + return None \ No newline at end of file diff --git a/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql index f501143..195be9b 100644 --- a/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_down_accounts_vms_etc.sql @@ -1,9 +1,17 @@ DROP TABLE payments; -DROP TABLE logintokens; +DROP TABLE login_tokens; + +DROP TABLE vm_ssh_public_key; DROP TABLE vms; +DROP TABLE vm_sizes; + +DROP TABLE os_images; + +DROP TABLE ssh_public_keys; + DROP TABLE accounts; UPDATE schemaversion SET version = 1; \ No newline at end of file diff --git a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql index 52dfaae..98433f2 100644 --- a/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql +++ b/capsulflask/schema_migrations/02_up_accounts_vms_etc.sql @@ -1,15 +1,48 @@ CREATE TABLE accounts ( - email TEXT PRIMARY KEY NOT NULL, - created TIMESTAMP NOT NULL DEFAULT NOW() + email TEXT PRIMARY KEY NOT NULL, + created TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE os_images ( + id TEXT PRIMARY KEY NOT NULL, + template_image_file_name TEXT NOT NULL, + description TEXT NOT NULL +); + +CREATE TABLE vm_sizes ( + id TEXT PRIMARY KEY NOT NULL, + dollars_per_month NUMERIC(8, 2) NOT NULL, + memory_megabytes INTEGER NOT NULL, + vcpus INTEGER NOT NULL +); + +CREATE TABLE ssh_public_keys ( + id SERIAL PRIMARY KEY, + email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, + name TEXT NOT NULL, + content TEXT NOT NULL, + UNIQUE (id, email) ); CREATE TABLE vms ( - id TEXT PRIMARY KEY NOT NULL, - email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, - created TIMESTAMP NOT NULL DEFAULT NOW(), - deleted TIMESTAMP NOT NULL + id TEXT PRIMARY KEY NOT NULL, + email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, + os TEXT REFERENCES os_images(id) ON DELETE RESTRICT, + size TEXT REFERENCES vm_sizes(id) ON DELETE RESTRICT, + created TIMESTAMP NOT NULL DEFAULT NOW(), + deleted TIMESTAMP, + UNIQUE (id, email) +); + +CREATE TABLE vm_ssh_public_key ( + ssh_public_key_id INTEGER NOT NULL, + email TEXT NOT NULL, + vm_id TEXT NOT NULL, + FOREIGN KEY (email, ssh_public_key_id) REFERENCES ssh_public_keys(email, id) ON DELETE RESTRICT, + FOREIGN KEY (email, vm_id) REFERENCES vms(email, id) ON DELETE RESTRICT, + PRIMARY KEY (email, vm_id, ssh_public_key_id) ); CREATE TABLE payments ( @@ -19,11 +52,28 @@ CREATE TABLE payments ( PRIMARY KEY (email, created) ); -CREATE TABLE logintokens ( +CREATE TABLE login_tokens ( email TEXT REFERENCES accounts(email) ON DELETE RESTRICT, created TIMESTAMP NOT NULL DEFAULT NOW(), token TEXT NOT NULL, PRIMARY KEY (email, created) ); +INSERT INTO os_images (id, template_image_file_name, description) +VALUES ('debian10', 'debian-10-genericcloud-amd64-20191117-80.qcow2', 'Debian 10 (Buster)'), + ('centos7', 'CentOS-7-x86_64-GenericCloud.qcow2', 'CentOS 7'), + ('centos8', 'CentOS-8-GenericCloud-8.1.1911-20200113.3.x86_64.qcow2', 'CentOS 8'), + ('ubuntu18', 'ubuntu-18.04-minimal-cloudimg-amd64.img', 'Ubuntu 18.04 LTS (Bionic Beaver)'), + ('alpine311', 'alpine-cloud-2020-04-18.qcow2', 'Alpine Linux 3.11'), + ('openbsd66', 'openbsd-cloud-2020-05.qcow2', 'OpenBSD 6.6'), + ('guix110', 'guixsystem-cloud-2020-05.qcow2', 'Guix System 1.1.0'); + +INSERT INTO vm_sizes (id, dollars_per_month, memory_megabytes, vcpus) +VALUES ('f1-s', 5.33, 512, 1), + ('f1-m', 7.16, 1024, 1), + ('f1-l', 8.92, 2048, 1), + ('f1-x', 16.16, 4096, 2), + ('f1-xx', 29.66, 8192, 4), + ('f1-xxx', 57.58, 16384, 8); + UPDATE schemaversion SET version = 2; \ No newline at end of file diff --git a/capsulflask/shell_scripts/create.sh b/capsulflask/shell_scripts/create.sh new file mode 100644 index 0000000..f280a55 --- /dev/null +++ b/capsulflask/shell_scripts/create.sh @@ -0,0 +1,79 @@ +#!/bin/sh -e +# +# create VMs for the capsul service +# developed by Cyberia Heavy Industries +# POSIX or die + +vmname="$1" +template_file="/tank/img/$2" +vcpus="$3" +memory="$4" +pubkeys="$5" +root_voume_size="25G" + +if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then + echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"' + exit 1 +fi + +if [ ! -f "$template_file" ]; then + echo "template $template_file not found" + exit 1 +fi + +if echo "$vcpus" | grep -vqE "^[0-9]+$"; then + echo "vcpus \"$vcpus\" must be an integer" + exit 1 +fi + +if echo "$memory" | grep -vqE "^[0-9]+$"; then + echo "memory \"$memory\" must be an integer" + exit 1 +fi + +echo "$pubkeys" | while IFS= read -r line; do + if echo "$line" | grep -vqE "^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$"; then + echo "pubkey \"$line\" must match "'"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$"' + exit 1 + fi +done + +disk="/tank/vm/$vmname.qcow2" +cdrom="/tank/vm/$vmname.iso" +xml="/tank/vm/$vmname.xml" + +if [ -f /tank/vm/$vmname.qcow2 ]; then + echo "Randomly generated name matched an existing VM! Odds are like one in a billion. Buy a lotto ticket." + exit 1 +fi + +qemu-img create -f qcow2 -b "$template_file" "$disk" +cp /tank/config/cyberia-cloudinit.yml /tmp/cloudinit.yml +echo "$pubkeys" | while IFS= read -r line; do + echo " - $line" >> /tmp/cloudinit.yml +done + +cloud-localds "$cdrom" /tmp/cloudinit.yml + +qemu-img resize "$disk" "$root_voume_size" +virt-install \ + --memory "$memory" \ + --vcpus "$vcpus" \ + --name "$vmname" \ + --disk "$disk",bus=virtio \ + --disk "$cdrom",device=cdrom \ + --os-type Linux \ + --os-variant generic \ + --virt-type kvm \ + --graphics vnc,listen=127.0.0.1 \ + --network network=public1,filterref=clean-traffic,model=virtio \ + --import \ + --print-xml > "$xml" + +# --network network=public6,filterref=clean-traffic,model=virtio +chmod 0600 "$xml" "$disk" "$cdrom" +virsh define "$xml" +virsh start "$vmname" + +echo "success" + diff --git a/capsulflask/shell_scripts/destroy.sh b/capsulflask/shell_scripts/destroy.sh new file mode 100644 index 0000000..2285fe4 --- /dev/null +++ b/capsulflask/shell_scripts/destroy.sh @@ -0,0 +1,48 @@ +#!/bin/sh + +three_dots() { + printf '.' + sleep 0.01 + printf '.' + sleep 0.01 + printf '.\n' +} + +vmname="$1" + +if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then + echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"' + exit 1 +fi + +echo "deleting $vmname:" +count=$(ls /tank/vm/$vmname* 2> /dev/null | wc -l) +if [ "$count" -gt 3 ]; then + echo "too many files found, exiting cowardly:" + echo "$(ls /tank/vm/$vmname*)" + exit 1 +fi + +virsh list --name --all | grep -q -E "^$vmname$" +if [ "$?" -eq 1 ]; then + echo "Error: $vmname not found" + exit 1 +fi + +virsh list --name --state-running | grep -q -E "^$vmname$" +if [ "$?" -eq 0 ]; then + printf ' stopping vm'; three_dots + virsh destroy "$vmname" > /dev/null +fi + +printf ' undefining xml.'; three_dots +if ! [ -f "/tank/vm/$vmname.qcow2" ]; then + echo "/tank/vm/$vmname.qcow2 is not a file, exiting cowardly" + exit 1 +fi + +virsh undefine "$vmname" > /dev/null +printf ' deleting disks.' ; three_dots +rm /tank/vm/$vmname* + +echo "success" \ No newline at end of file diff --git a/capsulflask/shell_scripts/get.sh b/capsulflask/shell_scripts/get.sh new file mode 100644 index 0000000..33ee75a --- /dev/null +++ b/capsulflask/shell_scripts/get.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +vmname="$1" + +if echo "$vmname" | grep -vqE '^capsul-[a-z0-9]{10}$'; then + echo "vmname $vmname must match "'"^capsul-[a-z0-9]{10}$"' + exit 1 +fi + +if virsh list --name --all | grep -vqE "^$vmname$" ; then + echo "Error: $vmname not found" + exit 1 +fi + +# this gets the ipv4 +virsh domifaddr "$vmname" | awk '/vnet/ {print $4}' | cut -d'/' -f1 diff --git a/capsulflask/shell_scripts/listIds.sh b/capsulflask/shell_scripts/listIds.sh new file mode 100644 index 0000000..1f1c3b5 --- /dev/null +++ b/capsulflask/shell_scripts/listIds.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +virsh list --all | grep running | grep -v ' Id' | grep -v -- '----' | awk '{print $2}' | sort \ No newline at end of file diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index 181542a..9f152c3 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -3,7 +3,7 @@ -{% block title %}{% endblock %}{% if self.title() %} - {% endif %}capsul +{% block title %}{% endblock %}{% if self.title() %} - {% endif %}capsul💊 diff --git a/capsulflask/virt_model.py b/capsulflask/virt_model.py new file mode 100644 index 0000000..f7a3356 --- /dev/null +++ b/capsulflask/virt_model.py @@ -0,0 +1,134 @@ +import subprocess +import re + +from flask import current_app +from time import sleep +from os.path import join +from subprocess import run + +class VirtualMachine: + def __init__(self, id, ipv4=None, ipv6=None): + self.id = id + self.ipv4 = ipv4 + self.ipv6 = ipv6 + +class VirtualizationInterface: + def get(self, id: str) -> VirtualMachine: + pass + + def listIds(self) -> list: + pass + + def create(self, id: str, template_file_name: str, memory: int, vcpus: int, ssh_public_keys: list) -> VirtualMachine: + pass + + def destroy(self, id: str): + pass + +class ShellScriptVirtualization(VirtualizationInterface): + + def validateId(self, id): + if not re.match(r"^capsul-[a-z0-9]{10}$", id): + raise ValueError(f"vm id \"{id}\" must match \"^capsul-[a-z0-9]{{10}}$\"") + + def validateCompletedProcess(self, completedProcess): + if completedProcess.returncode != 0: + raise RuntimeError(f"""{" ".join(completedProcess.args)} failed with exit code {completedProcess.returncode} + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """) + + def get(self, id): + self.validateId(id) + completedProcess = run([join(current_app.root_path, 'shell_scripts/get.sh'), id], capture_output=True) + self.validateCompletedProcess(completedProcess) + lines = completedProcess.stdout.splitlines() + + if not re.match(r"^([0-9]{1,3}\.){3}[0-9]{1,3}$", lines[0]): + return None + + return VirtualMachine(id, ipv4=lines[0]) + + def listIds(self) -> list: + completedProcess = run([join(current_app.root_path, 'shell_scripts/listIds.sh')], capture_output=True) + self.validateCompletedProcess(completedProcess) + return completedProcess.stdout.splitlines() + + def create(self, id: str, template_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list): + self.validateId(id) + + if not re.match(r"^[a-zA-Z0-9_.-]$", template_file_name): + raise ValueError(f"template_file_name \"{template_file_name}\" must match \"^[a-zA-Z0-9_.-]$\"") + + for ssh_public_key in ssh_public_keys: + if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$", ssh_public_key): + raise ValueError(f"ssh_public_key \"{ssh_public_key}\" must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$\"") + + if vcpus < 1 or vcpus > 8: + raise ValueError(f"vcpus \"{vcpus}\" must match 1 <= vcpus <= 8") + + if memory_mb < 512 or memory_mb > 16384: + raise ValueError(f"memory_mb \"{memory_mb}\" must match 512 <= memory_mb <= 16384") + + ssh_keys_string = "\n".join(ssh_public_keys) + + completedProcess = run([ + join(current_app.root_path, 'shell_scripts/create.sh'), + id, + template_file_name, + str(vcpus), + str(memory_mb), + ssh_keys_string + ], capture_output=True) + + self.validateCompletedProcess(completedProcess) + lines = completedProcess.stdout.splitlines() + + vmSettings = f""" + id={id} + template_file_name={template_file_name} + vcpus={str(vcpus)} + memory={str(memory_mb)} + ssh_public_keys={ssh_keys_string} + """ + + if not lines[len(lines)-1] == "success": + raise ValueError(f"""failed to create vm with: + {vmSettings} + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """) + + for _ in range(0, 10): + sleep(6) + result = self.get(id) + if result != None: + return result + + for _ in range(0, 10): + sleep(60) + result = self.get(id) + if result != None: + return result + + raise TimeoutError(f"""timed out waiting for vm to obtain an IP address: + {vmSettings} + """) + + def destroy(self, id: str): + self.validateId(id) + completedProcess = run([join(current_app.root_path, 'shell_scripts/destroy.sh'), id], capture_output=True) + self.validateCompletedProcess(completedProcess) + lines = completedProcess.stdout.splitlines() + + if not lines[len(lines)-1] == "success": + raise ValueError(f"""failed to destroy vm "{id}": + stdout: + {completedProcess.stdout} + stderr: + {completedProcess.stderr} + """) diff --git a/test b/test new file mode 100644 index 0000000..eae63ce --- /dev/null +++ b/test @@ -0,0 +1,2 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKD3XzZTbTteIgnaFY+fiiOl9EnNN+twyNchnWjCkYqv forest@tower +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKD3XzZTbTteIgnaFY+fiiOl9EnNN+twyNchnWjCkYqv forest@tower \ No newline at end of file