create capsul and capsuls list page working
This commit is contained in:
parent
452f236c6b
commit
231d1ed7c0
@ -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
|
||||
|
@ -6,7 +6,6 @@ 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"),
|
||||
@ -37,5 +36,5 @@ def create_app():
|
||||
|
||||
app.add_url_rule("/", endpoint="index")
|
||||
|
||||
return app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host='127.0.0.1')
|
||||
|
@ -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,6 +65,7 @@ def login():
|
||||
|
||||
return render_template("login-landing.html", email=email)
|
||||
|
||||
for error in errors:
|
||||
flash(error)
|
||||
|
||||
return render_template("login.html")
|
||||
|
@ -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"] =
|
||||
|
||||
# return
|
||||
|
||||
return render_template(
|
||||
"ssh-public-keys.html",
|
||||
ssh_public_keys=map(
|
||||
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 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
|
||||
)
|
||||
|
@ -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("""
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user