capsul-flask/capsulflask/console.py

463 lines
15 KiB
Python
Raw Normal View History

from base64 import b64encode
from datetime import datetime, timedelta
import json
2020-05-11 16:57:39 +00:00
import re
2020-05-11 21:24:37 +00:00
import sys
from flask import Blueprint
from flask import flash
from flask import current_app
from flask import g
from flask import request
from flask import session
from flask import render_template
from flask import redirect
from flask import url_for
from werkzeug.exceptions import abort
from nanoid import generate
2020-05-13 05:28:53 +00:00
from capsulflask.metrics import durations as metric_durations
from capsulflask.auth import account_required
from capsulflask.db import get_model
from capsulflask.shared import my_exec_info_message
from capsulflask.payment import poll_btcpay_session
from capsulflask import cli
bp = Blueprint("console", __name__, url_prefix="/console")
def make_capsul_id():
letters_n_nummers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10)
return f"capsul-{letters_n_nummers}"
def double_check_capsul_address(id, ipv4, get_ssh_host_keys):
try:
result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys)
if result != None and result.ipv4 != None and result.ipv4 != ipv4:
ipv4 = result.ipv4
get_model().update_vm_ip(email=session["account"], id=id, ipv4=result.ipv4)
if result != None and result.ssh_host_keys != None and get_ssh_host_keys:
get_model().update_vm_ssh_host_keys(email=session["account"], id=id, ssh_host_keys=result.ssh_host_keys)
except:
current_app.logger.error(f"""
the virtualization model threw an error in double_check_capsul_address of {id}:
{my_exec_info_message(sys.exc_info())}"""
)
return None
2020-05-11 21:24:37 +00:00
return result
2020-05-11 21:24:37 +00:00
@bp.route("/")
@account_required
def index():
vms = get_vms()
vms = list(filter(lambda x: not x['deleted'], vms))
created = request.args.get('created')
2020-05-17 04:21:09 +00:00
# this is here to prevent xss
if created and not re.match(r"^(cvm|capsul)-[a-z0-9]{10}$", created):
created = '___________'
2020-05-11 21:24:37 +00:00
# for now we are going to check the IP according to the virt model
# on every request. this could be done by a background job and cached later on...
for vm in vms:
result = double_check_capsul_address(vm["id"], vm["ipv4"], False)
if result is not None:
vm["ipv4"] = result.ipv4
vm["state"] = result.state
else:
vm["state"] = "unknown"
2020-05-11 21:24:37 +00:00
2021-02-18 03:57:09 +00:00
mappedVms = []
for vm in vms:
2021-02-18 03:57:57 +00:00
ip_display = vm['ipv4']
2021-02-18 03:57:09 +00:00
if not ip_display:
if vm["state"] == "running":
ip_display = "..booting.."
else:
ip_display = "unknown"
ip_display_class = "ok"
2021-02-18 03:57:57 +00:00
if not vm['ipv4']:
2021-02-18 03:57:09 +00:00
if vm["state"] == "running":
ip_display_class = "waiting-pulse"
else:
ip_display_class = "yellow"
mappedVms.append(dict(
2021-02-18 03:57:57 +00:00
id=vm['id'],
size=vm['size'],
state=vm['state'],
2021-02-18 03:57:09 +00:00
ipv4=ip_display,
ipv4_status=ip_display_class,
2021-02-18 03:57:57 +00:00
os=vm['os'],
created=vm['created'].strftime("%b %d %Y")
2021-02-18 03:57:09 +00:00
))
2020-05-11 21:24:37 +00:00
2021-02-18 03:57:09 +00:00
return render_template("capsuls.html", vms=mappedVms, has_vms=len(vms) > 0, created=created)
2020-05-11 16:57:39 +00:00
2020-05-15 17:23:42 +00:00
@bp.route("/<string:id>", methods=("GET", "POST"))
2020-05-11 16:57:39 +00:00
@account_required
2020-05-11 21:24:37 +00:00
def detail(id):
2020-05-13 05:28:53 +00:00
duration=request.args.get('duration')
if not duration:
duration = "5m"
vm = get_model().get_vm_detail(email=session["account"], id=id)
2020-05-11 16:57:39 +00:00
2020-05-11 21:24:37 +00:00
if vm is None:
return abort(404, f"{id} doesn't exist.")
2020-05-11 16:57:39 +00:00
2020-05-15 17:23:42 +00:00
if vm['deleted']:
return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True)
2020-05-11 16:57:39 +00:00
vm["created"] = vm['created'].strftime("%b %d %Y %H:%M")
vm["ssh_authorized_keys"] = ", ".join(vm["ssh_authorized_keys"]) if len(vm["ssh_authorized_keys"]) > 0 else "<missing>"
2020-05-15 17:23:42 +00:00
if request.method == "POST":
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
return abort(418, f"u want tea")
2020-05-15 17:23:42 +00:00
if 'action' not in request.form:
return abort(400, "action is required")
if request.form['action'] == "start":
current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="start")
vm["state"] = "starting"
return render_template("capsul-detail.html", vm=vm)
elif request.form['action'] == "delete":
if 'are_you_sure' not in request.form or not request.form['are_you_sure']:
return render_template(
"capsul-detail.html",
csrf_token = session["csrf-token"],
vm=vm,
delete=True
)
else:
current_app.logger.info(f"deleting {vm['id']} per user request ({session['account']})")
current_app.config["HUB_MODEL"].destroy(email=session['account'], id=id)
get_model().delete_vm(email=session['account'], id=id)
return render_template("capsul-detail.html", vm=vm, deleted=True)
elif request.form['action'] == "force-stop":
if 'are_you_sure' not in request.form or not request.form['are_you_sure']:
return render_template(
"capsul-detail.html",
csrf_token = session["csrf-token"],
vm=vm,
force_stop=True,
)
else:
current_app.logger.info(f"force stopping {vm['id']} per user request ({session['account']})")
current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="force-stop")
vm["state"] = "stopped"
return render_template(
"capsul-detail.html",
csrf_token = session["csrf-token"],
vm=vm,
durations=list(map(lambda x: x.strip("_"), metric_durations.keys())),
duration=duration
)
2020-05-15 17:23:42 +00:00
else:
return abort(400, "action must be either delete, force-stop, or start")
2020-05-15 17:23:42 +00:00
else:
needs_ssh_host_keys = "ssh_host_keys" not in vm or len(vm["ssh_host_keys"]) == 0
vm_from_virt_model = double_check_capsul_address(vm["id"], vm["ipv4"], needs_ssh_host_keys)
if vm_from_virt_model is not None:
vm["ipv4"] = vm_from_virt_model.ipv4
vm["state"] = vm_from_virt_model.state
if needs_ssh_host_keys:
vm["ssh_host_keys"] = vm_from_virt_model.ssh_host_keys
2021-02-18 03:10:20 +00:00
else:
vm["state"] = "unknown"
if vm["state"] == "running" and not vm["ipv4"]:
vm["state"] = "starting"
2020-05-15 17:23:42 +00:00
return render_template(
"capsul-detail.html",
csrf_token = session["csrf-token"],
vm=vm,
2020-05-15 17:23:42 +00:00
durations=list(map(lambda x: x.strip("_"), metric_durations.keys())),
duration=duration
)
2020-05-11 16:57:39 +00:00
def _create(vm_sizes, operating_systems, public_keys_for_account, server_data):
errors = list()
size = server_data.get("size")
os = server_data.get("os")
posted_keys_count = int(server_data.get("ssh_authorized_key_count"))
if not size:
errors.append("Size is required")
elif size not in vm_sizes:
errors.append(f"Invalid size {size}")
if not os:
errors.append("OS is required")
elif os not in operating_systems:
errors.append(f"Invalid os {os}")
posted_keys = 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 server_data:
posted_name = server_data.get(f"ssh_key_{i}")
key = None
for x in public_keys_for_account:
if x['name'] == posted_name:
key = x
if key:
posted_keys.append(key)
else:
errors.append(f"SSH Key \"{posted_name}\" doesn't exist")
if len(posted_keys) == 0:
errors.append("At least one SSH Public Key is required")
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(
vm_sizes[size]['memory_mb']*1024*1024
)
if not capacity_avaliable:
errors.append("""
host(s) at capacity. no capsuls can be created at this time. sorry.
""")
if len(errors) == 0:
2021-07-11 10:35:35 +00:00
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))
)
current_app.config["HUB_MODEL"].create(
email = session["account"],
id=id,
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))
)
return id, errors
return None, errors
@bp.route("/create", methods=("GET", "POST"))
@account_required
def create():
vm_sizes = get_model().vm_sizes_dict()
operating_systems = get_model().operating_systems_dict()
public_keys_for_account = get_model().list_ssh_public_keys_for_account(session["account"])
2020-05-15 04:40:27 +00:00
account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow())
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024)
if request.method == "POST":
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
return abort(418, f"u want tea")
id, errors = _create(
vm_sizes,
operating_systems,
public_keys_for_account,
request.form)
if len(errors) == 0:
2021-07-11 21:47:12 +00:00
for error in errors:
flash(error)
return redirect(f"{url_for('console.index')}?created={id}")
affordable_vm_sizes = dict()
for key, vm_size in vm_sizes.items():
# if a user deposits $7.50 and then creates an f1-s vm which costs 7.50 a month,
# then they have to delete the vm and re-create it, they will not be able to, they will have to pay again.
# so for UX it makes a lot of sense to give a small margin of 25 cents for usability sake
if vm_size["dollars_per_month"] <= account_balance+0.25:
affordable_vm_sizes[key] = vm_size
2020-05-13 18:56:43 +00:00
if not capacity_avaliable:
2020-05-16 04:19:01 +00:00
current_app.logger.warning(f"when capsul capacity is restored, send an email to {session['account']}")
2020-05-13 18:56:43 +00:00
return render_template(
2020-05-11 16:57:39 +00:00
"create-capsul.html",
csrf_token = session["csrf-token"],
2020-05-13 18:56:43 +00:00
capacity_avaliable=capacity_avaliable,
2020-05-12 05:45:37 +00:00
account_balance=format(account_balance, '.2f'),
ssh_authorized_keys=public_keys_for_account,
ssh_authorized_key_count=len(public_keys_for_account),
no_ssh_public_keys=len(public_keys_for_account) == 0,
operating_systems=operating_systems,
cant_afford=len(affordable_vm_sizes) == 0,
vm_sizes=affordable_vm_sizes
)
@bp.route("/keys", methods=("GET", "POST"))
2020-05-11 21:24:37 +00:00
@account_required
def ssh_api_keys():
2020-05-11 21:24:37 +00:00
errors = list()
token = None
2020-05-11 21:24:37 +00:00
if request.method == "POST":
if "csrf-token" not in request.form or request.form['csrf-token'] != session['csrf-token']:
return abort(418, f"u want tea")
action = request.form["action"]
if action == 'upload_ssh_key':
content = None
content = request.form["content"].replace("\r", " ").replace("\n", " ").strip()
name = request.form["name"]
if not name or len(name.strip()) < 1:
parts = re.split(" +", content)
if len(parts) > 2 and len(parts[2].strip()) > 0:
name = parts[2].strip()
else:
name = parts[0].strip()
else:
errors.append("Name is required")
if not re.match(r"^[0-9A-Za-z_@:. -]+$", name):
errors.append(f"Key name '{name}' must match \"^[0-9A-Za-z_@:. -]+$\"")
2020-05-11 21:24:37 +00:00
if not content or len(content.strip()) < 1:
errors.append("Content is required")
else:
if not re.match(r"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$", content):
errors.append("Content must match \"^(ssh|ecdsa)-[0-9A-Za-z+/_=@:. -]+$\"")
2020-05-11 21:24:37 +00:00
if get_model().ssh_public_key_name_exists(session["account"], name):
2020-05-11 21:24:37 +00:00
errors.append("A key with that name already exists")
if len(errors) == 0:
get_model().create_ssh_public_key(session["account"], name, content)
2020-05-11 21:24:37 +00:00
elif action == "delete_ssh_key":
get_model().delete_ssh_public_key(session["account"], name)
2020-05-11 21:24:37 +00:00
elif action == "generate_api_token":
name = request.form["name"]
if name == '':
name = datetime.utcnow().strftime('%y-%m-%d %H:%M:%S')
token = b64encode(
get_model().generate_api_token(session["account"], name).encode('utf-8')
).decode('utf-8')
elif action == "delete_api_token":
get_model().delete_api_token(session["account"], request.form["id"])
2020-05-11 21:24:37 +00:00
for error in errors:
flash(error)
ssh_keys_list=list(map(
2020-05-11 21:24:37 +00:00
lambda x: dict(name=x['name'], content=f"{x['content'][:20]}...{x['content'][len(x['content'])-20:]}"),
get_model().list_ssh_public_keys_for_account(session["account"])
2020-05-11 21:24:37 +00:00
))
api_tokens_list = get_model().list_api_tokens(session["account"])
return render_template(
"keys.html",
csrf_token = session["csrf-token"],
api_tokens=api_tokens_list,
ssh_public_keys=ssh_keys_list,
generated_api_token=token,
)
2020-05-11 21:24:37 +00:00
def get_vms():
if 'user_vms' not in g:
g.user_vms = get_model().list_vms_for_account(session["account"])
return g.user_vms
2020-05-11 21:24:37 +00:00
def get_payments():
if 'user_payments' not in g:
g.user_payments = get_model().list_payments_for_account(session["account"])
return g.user_payments
2020-05-12 05:45:37 +00:00
average_number_of_days_in_a_month = 30.44
2020-05-17 04:24:11 +00:00
def get_vm_months_float(vm, as_of):
end_datetime = vm["deleted"] if vm["deleted"] else as_of
days = float((end_datetime - vm["created"]).total_seconds())/float(60*60*24)
if days < 1:
days = float(1)
return days / average_number_of_days_in_a_month
2020-05-15 04:40:27 +00:00
def get_account_balance(vms, payments, as_of):
vm_cost_dollars = 0.0
2020-05-15 04:40:27 +00:00
for vm in vms:
2020-05-17 04:24:11 +00:00
vm_months = get_vm_months_float(vm, as_of)
vm_cost_dollars += vm_months * float(vm["dollars_per_month"])
2020-05-15 04:40:27 +00:00
payment_dollars_total = float( sum(map(lambda x: 0 if x["invalidated"] else x["dollars"], payments)) )
return payment_dollars_total - vm_cost_dollars
@bp.route("/account-balance")
@account_required
def account_balance():
payment_sessions = get_model().list_payment_sessions_for_account(session['account'])
for payment_session in payment_sessions:
if payment_session['type'] == 'btcpay':
poll_btcpay_session(payment_session['id'])
payments = get_payments()
vms = get_vms()
balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7))
balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1))
balance_now = get_account_balance(vms, payments, datetime.utcnow())
warning_index = -1
warning_text = ""
warnings = cli.get_warnings_list()
for i in range(0, len(warnings)):
if warnings[i]['get_active'](balance_1w, balance_1d, balance_now):
warning_index = i
if warning_index > -1:
pluralize_capsul = "s" if len(vms) > 1 else ""
warning_id = warnings[warning_index]['id']
warning_text = cli.get_warning_headline(warning_id, pluralize_capsul)
2020-05-12 05:45:37 +00:00
vms_billed = list()
for vm in get_vms():
2020-05-17 04:24:11 +00:00
vm_months = get_vm_months_float(vm, datetime.utcnow())
2020-05-12 05:45:37 +00:00
vms_billed.append(dict(
id=vm["id"],
dollars_per_month=vm["dollars_per_month"],
created=vm["created"].strftime("%b %d %Y"),
deleted=vm["deleted"].strftime("%b %d %Y") if vm["deleted"] else "N/A",
months=format(vm_months, '.3f'),
dollars=format(vm_months * float(vm["dollars_per_month"]), '.2f')
))
return render_template(
"account-balance.html",
2020-05-12 05:45:37 +00:00
has_vms=len(vms_billed)>0,
vms_billed=vms_billed,
warning_text=warning_text,
payments=list(map(
lambda x: dict(
dollars=x["dollars"],
class_name="invalidated" if x["invalidated"] else "",
created=x["created"].strftime("%b %d %Y")
),
payments
)),
2020-05-12 05:45:37 +00:00
has_payments=len(payments)>0,
account_balance=format(balance_now, '.2f')
)