create capsul and capsuls list page working

This commit is contained in:
forest 2020-05-11 15:13:20 -05:00
parent 452f236c6b
commit 231d1ed7c0
11 changed files with 210 additions and 124 deletions

View File

@ -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

View File

@ -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')

View File

@ -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")

View File

@ -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
)
created_os = os
if error:
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
)

View File

@ -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("""

View File

@ -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;

View File

@ -21,12 +21,14 @@
{% endif %}
</div>
</div>
<div class="nav-row half-margin">
<div class="nav-row half-margin wrap">
<a href="/faq">FAQ</a>
<a href="/changelog">Changelog</a>
{% if session["account"] %}
<a href="/console/">Console</a>
<a href="/console">Capsuls</a>
<a href="/console/ssh">SSH Public Keys</a>
<a href="/console/billing">Account Balance</a>
{% endif %}
<a href="/support">Support</a>

View File

@ -1,10 +1,16 @@
{% extends 'console-base.html' %}
{% extends 'base.html' %}
{% block title %}Console{% endblock %}
{% block consoletitle %}Console{% endblock %}
{% block consolecontent %}
{% if vms[0] is defined %}
{% block content %}
<div class="third-margin">
<h1>Capsuls</h1>
</div>
<div class="third-margin">
{% if has_vms %}
<div class="row third-margin justify-end">
<a href="/console/create">Create Capsul</a>
</div>
<table>
<thead>
<tr>
@ -17,21 +23,20 @@
</thead>
<tbody>
{% for vm in vms %}
<a href="/console/{{ vm['id'] }}">
<tr>
<td>{{ vm["id"] }}</td>
<td><a href="/console/{{ vm['id'] }}">{{ vm["id"] }}</a></td>
<td>{{ vm["size"] }}</td>
<td>{{ vm["ipv4"] }}</td>
<td class="{{ vm['ipv4_status'] }}">{{ vm["ipv4"] }}</td>
<td>{{ vm["os"] }}</td>
<td>{{ vm["created"] }}</td>
</tr>
</a>
{% endfor %}
</tbody>
</table>
{% else %}
You don't have any Capsuls running. <a href="/console/create">Create one</a> today!
{% endif %}
</div>
{% endblock %}
{% block pagesource %}/templates/capsuls.html{% endblock %}

View File

@ -1,11 +1,26 @@
{% extends 'console-base.html' %}
{% extends 'base.html' %}
{% block title %}Create{% endblock %}
{% block consoletitle %}Console - Create Capsul{% endblock %}
{% block consolecontent %}
{% if ssh_public_keys[0] is defined %}
{% block content %}
<div class="third-margin">
<h1>Create Capsul</h1>
</div>
<div class="third-margin">
{% if created_os %}
<p>
Your Capsul was successfully created! You should already see it listed on the
<a href="/console/">Capsuls page</a>, but it may not have obtained an IP address yet.
Its IP address should become visible once the machine has booted and taken a DHCP lease.
</p>
{% if created_os == 'debian10' %}
<p>
Note: because Debian delays fully booting until after entropy has been generated, Debian Capsuls
may take an extra-long time to obtain an IP address, like up to 10 minutes. Be patient.
</p>
{% endif %}
{% else %}
{% if has_ssh_public_keys %}
<pre>
CAPSUL SIZES
============
@ -36,6 +51,18 @@ f1-xxx $57.58 8 16G 25G 16TB
{% endfor %}
</select>
</div>
<div class="row justify-start">
<input type="hidden" name="ssh_public_key_count" value="{{ ssh_public_key_count}}"/>
<label class="align" for="ssh_keys">SSH Public Keys</label>
<div id="ssh_keys">
{% for key in ssh_public_keys %}
<label for="ssh_key_{{ loop.index - 1 }}">
<input type="checkbox" id="ssh_key_{{ loop.index - 1 }}" name="ssh_key_{{ loop.index - 1 }}" value="{{ key['name'] }}"/>
{{ key['name'] }}
</label>
{% endfor %}
</div>
</div>
<div class="row justify-end">
<input type="submit" value="Create">
</div>
@ -45,6 +72,8 @@ f1-xxx $57.58 8 16G 25G 16TB
<p>You must <a href="/console/ssh/upload">upload one</a> before you can create a Capsul.</p>
{% endif %}
{% endif %}
</div>
{% endblock %}
{% block subcontent %}

View File

@ -1,12 +1,13 @@
{% extends 'console-base.html' %}
{% extends 'base.html' %}
{% block title %}SSH Public Keys{% endblock %}
{% block consoletitle %}Console - SSH Public Keys{% endblock %}
{% block consolecontent %}
{% if ssh_public_keys[0] is defined %} <hr/> {% endif %}
{% block content %}
<div class="third-margin">
<h1>SSH PUBLIC KEYS</h1>
</div>
<div class="third-margin">
{% if has_ssh_public_keys %} <hr/> {% endif %}
{% for ssh_public_key in ssh_public_keys %}
<form method="post">
@ -20,7 +21,7 @@
</form>
{% endfor %}
{% if ssh_public_keys[0] is defined %} <hr/> {% endif %}
{% if has_ssh_public_keys %} <hr/> {% endif %}
<div class="third-margin">
<h1>UPLOAD A NEW SSH KEY</h1>
@ -50,6 +51,7 @@
<input type="submit" value="Upload">
</div>
</form>
</div>
{% endblock %}
{% block pagesource %}/templates/ssh-public-keys.html{% endblock %}

View File

@ -25,7 +25,7 @@ class VirtualizationInterface:
def list_ids(self) -> list:
pass
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory: int, ssh_public_keys: list) -> VirtualMachine:
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory: int, ssh_public_keys: list):
pass
def destroy(self, email: str, id: str):
@ -42,8 +42,7 @@ class MockVirtualization(VirtualizationInterface):
def create(self, email: str, id: str, template_image_file_name: str, vcpus: int, memory_mb: int, ssh_public_keys: list):
validate_capsul_id(id)
print(f"mock create: {id} for {email}")
sleep(5)
return VirtualMachine(id, ipv4="1.1.1.1")
sleep(1)
def destroy(self, email: str, id: str):
print(f"mock destroy: {id} for {email}")
@ -127,22 +126,6 @@ class ShellScriptVirtualization(VirtualizationInterface):
{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 {id} ({email}) to obtain an IP address:
{vmSettings}
""")
def destroy(self, email: str, id: str):
validate_capsul_id(id)
completedProcess = run([join(current_app.root_path, 'shell_scripts/destroy.sh'), id], capture_output=True)