diff --git a/README.md b/README.md index 7e0dcf5..ed808cc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ Run the app FLASK_APP=capsulflask flask run ``` +Run the app in gunicorn locally +``` +pip install gunicorn +.venv/bin/gunicorn --bind 127.0.0.1:5000 capsulflask:app +``` + ## postgres database schema management capsulflask has a concept of a schema version. When the application starts, it will query the database for a table named diff --git a/capsulflask/__init__.py b/capsulflask/__init__.py index 36fefed..fcf56fd 100644 --- a/capsulflask/__init__.py +++ b/capsulflask/__init__.py @@ -6,36 +6,35 @@ from flask import render_template from capsulflask import virt_model -def create_app(): - app = Flask(__name__) - app.config.from_mapping( - BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"), - SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"), - DATABASE_URL=os.environ.get("DATABASE_URL", default="sql://postgres:dev@localhost:5432/postgres"), - DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"), +app = Flask(__name__) +app.config.from_mapping( + BASE_URL=os.environ.get("BASE_URL", default="http://localhost:5000"), + SECRET_KEY=os.environ.get("SECRET_KEY", default="dev"), + DATABASE_URL=os.environ.get("DATABASE_URL", default="sql://postgres:dev@localhost:5432/postgres"), + DATABASE_SCHEMA=os.environ.get("DATABASE_SCHEMA", default="public"), - MAIL_SERVER=os.environ.get("MAIL_SERVER", default="m1.nullhex.com"), - MAIL_PORT=os.environ.get("MAIL_PORT", default="587"), - MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="True").lower() in ['true', '1', 't', 'y', 'yes'], - MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default="forest@nullhex.com"), - MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), - MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="forest@nullhex.com"), - ) + MAIL_SERVER=os.environ.get("MAIL_SERVER", default="m1.nullhex.com"), + MAIL_PORT=os.environ.get("MAIL_PORT", default="587"), + MAIL_USE_TLS=os.environ.get("MAIL_USE_TLS", default="True").lower() in ['true', '1', 't', 'y', 'yes'], + MAIL_USERNAME=os.environ.get("MAIL_USERNAME", default="forest@nullhex.com"), + MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""), + MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="forest@nullhex.com"), +) - app.config['FLASK_MAIL_INSTANCE'] = Mail(app) - app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() +app.config['FLASK_MAIL_INSTANCE'] = Mail(app) +app.config['VIRTUALIZATION_MODEL'] = virt_model.MockVirtualization() - from capsulflask import db +from capsulflask import db - db.init_app(app) +db.init_app(app) - from capsulflask import auth, landing, console +from capsulflask import auth, landing, console - app.register_blueprint(landing.bp) - app.register_blueprint(auth.bp) - app.register_blueprint(console.bp) +app.register_blueprint(landing.bp) +app.register_blueprint(auth.bp) +app.register_blueprint(console.bp) - app.add_url_rule("/", endpoint="index") - - return app +app.add_url_rule("/", endpoint="index") +if __name__ == "__main__": + app.run(host='127.0.0.1') diff --git a/capsulflask/auth.py b/capsulflask/auth.py index 6f0ace0..51535e2 100644 --- a/capsulflask/auth.py +++ b/capsulflask/auth.py @@ -33,17 +33,17 @@ def account_required(view): def login(): if request.method == "POST": email = request.form["email"] - error = None + errors = list() if not email: - error = "email is required" + errors.append("email is required") elif len(email.strip()) < 6 or email.count('@') != 1 or email.count('.') == 0: - error = "enter a valid email address" + errors.append("enter a valid email address") - if error is None: + if len(errors) == 0: token = get_model().login(email) if token is None: - error = "too many logins. please use one of the existing login links that have been emailed to you" + errors.append("too many logins. please use one of the existing login links that have been emailed to you") else: link = f"{current_app.config['BASE_URL']}/auth/magic/{token}" @@ -65,7 +65,8 @@ def login(): return render_template("login-landing.html", email=email) - flash(error) + for error in errors: + flash(error) return render_template("login.html") diff --git a/capsulflask/console.py b/capsulflask/console.py index 7b219bc..43f24e3 100644 --- a/capsulflask/console.py +++ b/capsulflask/console.py @@ -25,62 +25,63 @@ def makeCapsulId(): @bp.route("/") @account_required def index(): - return render_template("capsuls.html", vms=get_model().list_vms_for_account(session["account"])) + vms = list(map( + lambda x: dict( + id=x['id'], + size=x['size'], + ipv4=(x['ipv4'] if x['ipv4'] else "..booting.."), + ipv4_status=("ok" if x['ipv4'] else "waiting-pulse"), + os=x['os'], + created=x['created'].strftime("%b %d %Y %H:%M") + ), + get_model().list_vms_for_account(session["account"]) + )) + return render_template("capsuls.html", vms=vms, has_vms=len(vms) > 0) @bp.route("/ssh", methods=("GET", "POST")) @account_required def ssh_public_keys(): db_model = get_model() - error = None - + errors = list() + if request.method == "POST": method = request.form["method"] name = request.form["name"] if not name or len(name.strip()) < 1: - error = "Name is required" + errors.append("Name is required") elif not re.match(r"^[0-9A-Za-z_ -]+$", name): - error = "Name must match \"^[0-9A-Za-z_ -]+$\"" + errors.append("Name must match \"^[0-9A-Za-z_ -]+$\"") if method == "POST": content = request.form["content"] if not content or len(content.strip()) < 1: - error = "Content is required" + errors.append("Content is required") else: content = content.replace("\r", "").replace("\n", "") if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$", content): - error = "Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$\"" + errors.append("Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@ -]+$\"") if db_model.ssh_public_key_name_exists(session["account"], name): - error = "A key with that name already exists" + errors.append("A key with that name already exists") - if error is None: + if len(errors) == 0: db_model.create_ssh_public_key(session["account"], name, content) elif method == "DELETE": - if error is None: + if len(errors) == 0: db_model.delete_ssh_public_key(session["account"], name) - if error: + for error in errors: flash(error) - # keys_list= - # for key in keys_list: - # if len(key['content']) > 40: - # print(key['content']) - # print(f"{key['content'][:20]}...{key['content'][len(key['content'])-20:]}") - # key["content"] = + keys_list=list(map( + lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"), + db_model.list_ssh_public_keys_for_account(session["account"]) + )) - # return - - return render_template( - "ssh-public-keys.html", - ssh_public_keys=map( - lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"), - db_model.list_ssh_public_keys_for_account(session["account"]) - ) - ) + return render_template("ssh-public-keys.html", ssh_public_keys=keys_list, has_ssh_public_keys=len(keys_list) > 0) @bp.route("/create", methods=("GET", "POST")) @account_required @@ -89,22 +90,45 @@ def create(): vm_sizes = db_model.vm_sizes_dict() operating_systems = db_model.operating_systems_dict() ssh_public_keys = db_model.list_ssh_public_keys_for_account(session["account"]) - error = None + errors = list() + created_os = None if request.method == "POST": + size = request.form["size"] os = request.form["os"] if not size: - error = "Size is required" + errors.append("Size is required") elif size not in vm_sizes: - error = f"Invalid size {size}" + errors.append(f"Invalid size {size}") if not os: - error = "OS is required" + errors.append("OS is required") elif os not in operating_systems: - error = f"Invalid os {os}" + errors.append(f"Invalid os {os}") - if error is None: + posted_keys_count = int(request.form["ssh_public_key_count"]) + posted_keys_contents = list() + + if posted_keys_count > 1000: + errors.append("something went wrong with ssh keys") + else: + for i in range(0, posted_keys_count): + if f"ssh_key_{i}" in request.form: + posted_name = request.form[f"ssh_key_{i}"] + key_content = None + for key in ssh_public_keys: + if key['name'] == posted_name: + key_content = key['content'] + if key_content: + posted_keys_contents.append(key_content) + else: + errors.append(f"SSH Key \"{posted_name}\" doesn't exist") + + if len(posted_keys_contents) == 0: + errors.append("At least one SSH Public Key is required") + + if len(errors) == 0: id = makeCapsulId() db_model.create_vm( email=session["account"], @@ -115,17 +139,22 @@ def create(): current_app.config["VIRTUALIZATION_MODEL"].create( email = session["account"], id=id, - template_image_file_name=operating_systems[os].template_image_file_name, - vcpus=vm_sizes[size].vcpus, - memory=vm_sizes[size].memory + template_image_file_name=operating_systems[os]['template_image_file_name'], + vcpus=vm_sizes[size]['vcpus'], + memory_mb=vm_sizes[size]['memory_mb'], + ssh_public_keys=posted_keys_contents ) - - if error: + created_os = os + + for error in errors: flash(error) return render_template( "create-capsul.html", + created_os=created_os, ssh_public_keys=ssh_public_keys, + ssh_public_key_count=len(ssh_public_keys), + has_ssh_public_keys=len(ssh_public_keys) > 0, operating_systems=operating_systems, vm_sizes=vm_sizes ) diff --git a/capsulflask/db_model.py b/capsulflask/db_model.py index 3e9f153..875e478 100644 --- a/capsulflask/db_model.py +++ b/capsulflask/db_model.py @@ -34,7 +34,7 @@ class DBModel: def all_vm_ids(self,): self.cursor.execute("SELECT id FROM vms") - return map(lambda x: x[0], self.cursor.fetchall()) + return list(map(lambda x: x[0], self.cursor.fetchall())) def operating_systems_dict(self,): self.cursor.execute("SELECT id, template_image_file_name, description FROM os_images") @@ -56,10 +56,10 @@ class DBModel: def list_ssh_public_keys_for_account(self, email): self.cursor.execute("SELECT name, content, created FROM ssh_public_keys WHERE email = %s", (email, )) - return map( + return list(map( lambda x: dict(name=x[0], content=x[1], created=x[2]), self.cursor.fetchall() - ) + )) def ssh_public_key_name_exists(self, email, name): self.cursor.execute( "SELECT name FROM ssh_public_keys where email = %s AND name = %s", (email, name) ) @@ -80,15 +80,14 @@ class DBModel: def list_vms_for_account(self, email): self.cursor.execute(""" - SELECT vms.id, vms.last_seen_ipv4, vms.last_seen_ipv6, vms.size, os_images.description, vms.created, vms.deleted - FROM vms JOIN os_images on os_images.id = vms.os - WHERE vms.email = %s""", + SELECT vms.id, vms.last_seen_ipv4, vms.last_seen_ipv6, vms.size, vms.os, vms.created, vms.deleted + FROM vms WHERE vms.email = %s""", (email, ) ) - return map( + return list(map( lambda x: dict(id=x[0], ipv4=x[1], ipv6=x[2], size=x[3], os=x[4], created=x[5], deleted=x[6]), self.cursor.fetchall() - ) + )) def create_vm(self, email, id, size, os): self.cursor.execute(""" diff --git a/capsulflask/static/style.css b/capsulflask/static/style.css index ad63369..2e73077 100644 --- a/capsulflask/static/style.css +++ b/capsulflask/static/style.css @@ -30,10 +30,13 @@ a:hover, a:active, a:visited { display: flex; justify-content: space-between; } +.nav-row a { + white-space: nowrap; +} .nav-row:last-child { justify-content: center; } -.nav-row:last-child a, .nav-row:last-child div { +.nav-row:last-child a { margin: 0 1em; } @@ -73,13 +76,13 @@ main { justify-content: space-around; width: 100%; } -.row.wrap { +.wrap { flex-wrap: wrap; } -.row.justify-start { +.justify-start { justify-content: flex-start; } -.row.justify-end { +.justify-end { justify-content: flex-end; } @@ -116,6 +119,7 @@ select { background-repeat: no-repeat; background-position: bottom 0.65em right 0.8em; background-size: 0.5em; + padding-right: 2em; } input, textarea { @@ -139,14 +143,16 @@ textarea { height: 6em; } +input[type=checkbox] { + margin: 0; +} + input[type=submit], select, textarea { font: calc(0.40rem + 1vmin) monospace; border: 1px solid #777e73; background-color: #bdc7b810; } - - input[type=submit], select { cursor: pointer; } @@ -162,6 +168,31 @@ ul li { margin: 0.5em 0; } +table{ + border-collapse: collapse; +} +thead { + background: #bdc7b812; +} +td, th { + padding: 0.2em 1em; +} +th { + border-right: 4px solid #241e1e; + text-align: left; +} +td { + border-bottom: 2px dotted #777e7355; +} + +.waiting-pulse { + animation: waiting-pulse 1s ease-in-out 0s infinite forwards alternate; +} +@keyframes waiting-pulse { + from { color: rgba(221, 169, 56, 0.8); } + to { color: rgba(221, 169, 56, 0.2); } +} + .code { display: inline-block; padding: 0.5em 2em; diff --git a/capsulflask/templates/base.html b/capsulflask/templates/base.html index f825db6..2045872 100644 --- a/capsulflask/templates/base.html +++ b/capsulflask/templates/base.html @@ -21,12 +21,14 @@ {% endif %} -