💊 capsul.org cloud compute service - python flask web application
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

441 lines
16 KiB

import re
import sys
import json
from datetime import datetime, timedelta
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
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, get_vm_months_float, get_account_balance, average_number_of_days_in_a_month
from capsulflask.payment import poll_btcpay_session, check_if_shortterm_flag_can_be_unset
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, existing_ipv4, get_ssh_host_keys):
try:
result = current_app.config["HUB_MODEL"].get(id, get_ssh_host_keys)
if result != None and result.ipv4 != "" and (existing_ipv4 == None or existing_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
return result
@bp.route("/")
@account_required
def index():
vms = get_vms()
vms = list(filter(lambda x: not x['deleted'], vms))
created = request.args.get('created')
# this is here to prevent xss
if created and not re.match(r"^(cvm|capsul)-[a-z0-9]{10}$", created):
created = '___________'
# 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"
mappedVms = []
for vm in vms:
ip_display = vm['ipv4']
if not ip_display:
if vm["state"] == "running":
ip_display = "..booting.."
else:
ip_display = "unknown"
ip_display_class = "ok"
if not vm['ipv4']:
if vm["state"] == "running":
ip_display_class = "waiting-pulse"
else:
ip_display_class = "yellow"
mappedVms.append(dict(
id=vm['id'],
size=vm['size'],
state=vm['state'],
ipv4=ip_display,
ipv4_status=ip_display_class,
os=vm['os'],
created=vm['created'].strftime("%b %d %Y")
))
return render_template("capsuls.html", vms=mappedVms, has_vms=len(vms) > 0, created=created)
@bp.route("/<string:id>", methods=("GET", "POST"))
@account_required
def detail(id):
duration=request.args.get('duration')
if not duration:
duration = "5m"
vm = get_model().get_vm_detail(email=session["account"], id=id)
if vm is None:
return abort(404, f"{id} doesn't exist.")
if vm['deleted']:
return render_template("capsul-detail.html", vm=vm, delete=True, deleted=True)
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>"
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")
if 'action' not in request.form:
return abort(400, "action is required")
if request.form['action'] == "start":
get_model().set_desired_state(email=session["account"], vm_id=id, desired_state="running")
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)
# now that the user has 1 less vm, check if thier account can fund all thier vms for
# 1 month and if so, set all thier vms to longterm
check_if_shortterm_flag_can_be_unset(session['account'])
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']})")
get_model().set_desired_state(email=session["account"], vm_id=id, desired_state="shut off")
current_app.config["HUB_MODEL"].vm_state_command(email=session['account'], id=id, command="force-stop")
vm["state"] = "stopped"
flash("please note that your account will still be billed for this capsul while it is in a stopped state")
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
)
else:
return abort(400, "action must be either delete, force-stop, or start")
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
else:
vm["state"] = "unknown"
if vm["state"] == "running" and not vm["ipv4"]:
vm["state"] = "starting"
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
)
@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"])
account_balance = get_account_balance(get_vms(), get_payments(), datetime.utcnow())
account_balance_in_one_month = get_account_balance(get_vms(), get_payments(), datetime.utcnow() + timedelta(days=average_number_of_days_in_a_month))
capacity_avaliable = current_app.config["HUB_MODEL"].capacity_avaliable(512*1024*1024)
errors = list()
month_funded_vm_sizes = dict()
hour_funded_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_in_one_month+0.25:
month_funded_vm_sizes[key] = vm_size
one_month_in_hours = float(730.5)
two_hours_as_fraction_of_month = float(2)/one_month_in_hours
if float(vm_size["dollars_per_month"])*two_hours_as_fraction_of_month <= account_balance:
hour_funded_vm_sizes[key] = vm_size
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")
size = request.form["size"]
os = request.form["os"]
if not size:
errors.append("Size is required")
elif size not in vm_sizes:
errors.append(f"Invalid size {size}")
elif size not in hour_funded_vm_sizes:
errors.append(f"Your account must have enough credit to run an {size} for 2 hours before you will be allowed to create it")
if not os:
errors.append("OS is required")
elif os not in operating_systems:
errors.append(f"Invalid os {os}")
posted_keys_count = int(request.form["ssh_authorized_key_count"])
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 request.form:
posted_name = request.form[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:
id = make_capsul_id()
# we can't create the vm record in the DB yet because its IP address needs to be allocated first.
# so it will be created when the allocation happens inside the hub_api.
current_app.config["HUB_MODEL"].create(
email = session["account"],
id=id,
os=os,
size=size,
shortterm=(size not in month_funded_vm_sizes),
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: dict(name=x['name'], content=x['content']), posted_keys))
)
return redirect(f"{url_for('console.index')}?created={id}")
for error in errors:
flash(error)
if not capacity_avaliable:
current_app.logger.warning(f"when capsul capacity is restored, send an email to {session['account']}")
return render_template(
"create-capsul.html",
csrf_token = session["csrf-token"],
capacity_avaliable=capacity_avaliable,
account_balance=format(account_balance, '.2f'),
account_balance_in_one_month=format(account_balance_in_one_month, '.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(hour_funded_vm_sizes) == 0,
vm_sizes=hour_funded_vm_sizes,
month_funded_vm_sizes=month_funded_vm_sizes
)
@bp.route("/ssh", methods=("GET", "POST"))
@account_required
def ssh_public_keys():
errors = list()
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")
method = request.form["method"]
content = None
if method == "POST":
content = request.form["content"].replace("\r", " ").replace("\n", " ").strip()
name = request.form["name"]
if not name or len(name.strip()) < 1:
if method == "POST":
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_@:. -]+$\"")
if method == "POST":
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+/_=@:. -]+$\"")
if get_model().ssh_public_key_name_exists(session["account"], name):
errors.append("A key with that name already exists")
if len(errors) == 0:
get_model().create_ssh_public_key(session["account"], name, content)
elif method == "DELETE":
if len(errors) == 0:
get_model().delete_ssh_public_key(session["account"], name)
for error in errors:
flash(error)
keys_list=list(map(
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"])
))
return render_template(
"ssh-public-keys.html",
csrf_token = session["csrf-token"],
ssh_public_keys=keys_list,
has_ssh_public_keys=len(keys_list) > 0
)
def get_vms():
if 'user_vms' not in g:
g.user_vms = get_model().list_vms_for_account(session["account"])
return g.user_vms
def get_payments():
if 'user_payments' not in g:
g.user_payments = get_model().list_payments_for_account(session["account"])
return g.user_payments
@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)
vms_billed = list()
for vm in get_vms():
vm_months = get_vm_months_float(vm, datetime.utcnow())
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",
has_vms=len(vms_billed)>0,
vms_billed=vms_billed,
warning_text=warning_text,
btcpay_enabled=current_app.config["BTCPAY_URL"] != "",
btcpay_is_working="BTCPAY_CLIENT" in current_app.config,
payments=list(map(
lambda x: dict(
dollars=x["dollars"],
class_name="invalidated" if x["invalidated"] else "",
created=x["created"].strftime("%b %d %Y")
),
payments
)),
has_payments=len(payments)>0,
account_balance=format(balance_now, '.2f')
)