capsul-flask/capsulflask/cli.py

322 lines
13 KiB
Python
Raw Permalink Normal View History

import os
import re
import sys
from datetime import datetime, timedelta
import click
from flask.cli import with_appcontext
from flask import Blueprint
2020-05-15 04:40:27 +00:00
from flask import current_app
from flask_mail import Message
from capsulflask.db import get_model
from capsulflask.shared import my_exec_info_message, get_warnings_list, get_account_balance, btcpay_invoice_is_paid
2022-02-08 23:16:52 +00:00
from capsulflask.payment import check_if_shortterm_flag_can_be_unset
from capsulflask.consistency import get_all_vms_from_db, get_all_vms_from_hosts, get_inconsistent_capsuls_information
bp = Blueprint('cli', __name__)
@bp.cli.command('sql')
@click.option('-f', help='script filename')
@click.option('-c', help='sql command')
@with_appcontext
def sql_script(f, c):
"""Run a sql script against the database. script is run 1 command at a time inside a single transaction."""
model = get_model()
script = ""
if f:
filepath = os.path.join(os.getcwd(), f)
if not os.path.isfile(filepath):
raise f"{filepath} is not a file"
with open(filepath, 'rb') as file:
script = file.read().decode("utf8")
elif c:
script = c
else:
click.echo(f"you must provide sql to run either inline with the -c argument or in a file with the -f argument")
return
commands = re.split(";\\s+", script)
for command in commands:
if command.strip() != "":
model.cursor.execute(command)
if re.match("^\\s*select", command, re.IGNORECASE) is not None:
for row in model.cursor.fetchall():
def format_value(x):
if isinstance(x, bool):
2020-05-15 04:40:27 +00:00
return "true" if x else "false"
if not x :
return "null"
if isinstance(x, datetime):
return x.isoformat()
return f"{x}"
click.echo(", ".join(list(map(format_value, row))))
else:
click.echo(f"{model.cursor.rowcount} rows affected.")
model.connection.commit()
2021-07-26 20:29:34 +00:00
@bp.cli.command('account-balance')
@click.option('-u', help='users email address (or comma delimited list of emls)')
2021-07-26 20:29:34 +00:00
@with_appcontext
def account_balance(u):
accounts = u.split(",")
2021-07-26 20:29:34 +00:00
click.echo(".")
click.echo(".")
for account in accounts:
if account != "":
vms = get_model().list_vms_for_account(account)
payments = get_model().list_payments_for_account(account)
click.echo(account + ": " + "{:.2f}".format(get_account_balance(vms, payments, datetime.utcnow())))
2021-07-26 20:29:34 +00:00
click.echo(".")
2024-04-08 23:49:50 +00:00
@bp.cli.command('payment-maintenance')
@click.option('-d', help='delete vms yes or no. pass "true" to delete vms. (like -d true) ')
@with_appcontext
2024-04-08 23:49:50 +00:00
def cli_payment_maintenance(f):
payment_maintenance(f == "true")
2020-05-15 04:40:27 +00:00
2024-04-08 23:49:50 +00:00
def payment_maintenance(delete_vms):
2020-05-15 04:40:27 +00:00
# make sure btcpay payments get completed (in case we miss a webhook), otherwise invalidate the payment
2020-05-16 04:19:01 +00:00
current_app.logger.info("cron_task: starting clean_up_unresolved_btcpay_invoices")
clean_up_unresolved_btcpay_invoices()
2020-05-16 04:19:01 +00:00
current_app.logger.info("cron_task: finished clean_up_unresolved_btcpay_invoices")
2022-02-08 23:16:52 +00:00
# notify when funds are about to run out and delete long-term vms once account reaches -$10
2020-05-16 04:19:01 +00:00
current_app.logger.info("cron_task: starting notify_users_about_account_balance")
notify_users_about_account_balance()
2020-05-16 04:19:01 +00:00
current_app.logger.info("cron_task: finished notify_users_about_account_balance")
2022-02-08 23:16:52 +00:00
# delete short-term vms and notify user once account reaches $0
current_app.logger.info("cron_task: starting delete_shortterm_vms_if_account_is_empty")
delete_shortterm_vms_if_account_is_empty()
current_app.logger.info("cron_task: finished delete_shortterm_vms_if_account_is_empty")
# make sure vm system and DB are synced
2020-05-16 04:19:01 +00:00
current_app.logger.info("cron_task: starting ensure_vms_and_db_are_synced")
ensure_vms_and_db_are_synced()
2020-05-16 04:19:01 +00:00
current_app.logger.info("cron_task: finished ensure_vms_and_db_are_synced")
2020-05-15 04:40:27 +00:00
def clean_up_unresolved_btcpay_invoices():
unresolved_btcpay_invoices = get_model().get_unresolved_btcpay_invoices()
for unresolved_invoice in unresolved_btcpay_invoices:
invoice_id = unresolved_invoice['id']
btcpay_invoice = None
try:
btcpay_invoice = current_app.config['BTCPAY_CLIENT'].get_invoice(invoice_id)
except:
current_app.logger.error(f"""
error was thrown when contacting btcpay server for invoice {invoice_id}:
{my_exec_info_message(sys.exc_info())}"""
)
continue
days = float((datetime.now() - unresolved_invoice['created']).total_seconds())/float(60*60*24)
if btcpay_invoice_is_paid(btcpay_invoice, pending_confirmation_as_paid=False):
2020-05-16 04:19:01 +00:00
current_app.logger.info(
f"resolving btcpay invoice {invoice_id} "
f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as completed "
)
2022-02-08 23:16:52 +00:00
resolved_invoice_email = get_model().btcpay_invoice_resolved(invoice_id, True)
if resolved_invoice_email is not None:
check_if_shortterm_flag_can_be_unset(resolved_invoice_email)
2020-05-15 04:40:27 +00:00
elif days >= 1:
2020-05-16 04:19:01 +00:00
current_app.logger.info(
f"resolving btcpay invoice {invoice_id} "
f"({unresolved_invoice['email']}, ${unresolved_invoice['dollars']}) as invalidated, "
f"btcpay server invoice status: {btcpay_invoice['status']}"
)
2020-05-15 04:40:27 +00:00
get_model().btcpay_invoice_resolved(invoice_id, False)
get_model().delete_payment_session("btcpay", invoice_id)
2020-05-15 04:40:27 +00:00
def notify_users_about_account_balance():
accounts = get_model().all_accounts()
out_of_bounds_accounts = dict()
vms_to_delete = []
2020-05-15 04:40:27 +00:00
for account in accounts:
vms = get_model().list_vms_for_account(account['email'])
payments = get_model().list_payments_for_account(account['email'])
balance_1w = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=7))
balance_1d = get_account_balance(vms, payments, datetime.utcnow() + timedelta(days=1))
2020-05-15 04:40:27 +00:00
balance_now = get_account_balance(vms, payments, datetime.utcnow())
current_warning = account['account_balance_warning']
2022-04-11 20:16:50 +00:00
if balance_now < -11 and len(list(filter(lambda vm: not vm['deleted'], vms))) > 0:
out_of_bounds_accounts[account['email']] = balance_now
2022-02-08 23:16:52 +00:00
longterm_vms = list(filter(lambda vm: vm['shortterm'] == False, vms))
current_longterm_vms = list(filter(lambda vm: not vm['deleted'], longterm_vms))
2022-02-08 23:16:52 +00:00
if len(current_longterm_vms) == 0:
2022-02-08 23:16:52 +00:00
continue
pluralize_capsul = "s" if len(longterm_vms) > 1 else ""
2020-05-15 04:40:27 +00:00
warnings = get_warnings_list()
2020-05-15 04:40:27 +00:00
current_warning_index = -1
if current_warning:
for i in range(0, len(warnings)):
if warnings[i]['id'] == current_warning:
current_warning_index = i
index_to_send = -1
for i in range(0, len(warnings)):
if i > current_warning_index and warnings[i]['get_active'](balance_1w, balance_1d, balance_now):
2020-05-15 04:40:27 +00:00
index_to_send = i
last_warning_is_active = warnings[len(warnings)-1]['get_active'](balance_1w, balance_1d, balance_now)
2020-05-15 04:40:27 +00:00
if index_to_send > -1:
2020-05-16 04:19:01 +00:00
current_app.logger.info(f"cron_task: sending {warnings[index_to_send]['id']} warning email to {account['email']}.")
get_body = warnings[index_to_send]['get_body']
get_subject = warnings[index_to_send]['get_subject']
2020-05-15 04:40:27 +00:00
current_app.config["FLASK_MAIL_INSTANCE"].send(
Message(
get_subject(pluralize_capsul),
body=get_body(current_app.config['BASE_URL'], pluralize_capsul),
sender=current_app.config["MAIL_DEFAULT_SENDER"],
2020-05-15 04:40:27 +00:00
recipients=[account['email']]
)
)
get_model().set_account_balance_warning(account['email'], warnings[index_to_send]['id'])
if last_warning_is_active:
for vm in current_longterm_vms:
current_app.logger.warning(f"cron_task: would delete {vm['id']} ( {account['email']} ) due to negative account balance.")
vms_to_delete.append(vm['id'])
# current_app.config["HUB_MODEL"].destroy(email=account["email"], id=vm['id'])
# get_model().delete_vm(email=account["email"], id=vm['id'])
if len(out_of_bounds_accounts) > 0:
lines_redacted = ["The following accounts have out-of-bounds account balances: (Un-redacted email addresses avaliable in the logs)", ""]
lines = ["The following accounts have out-of-bounds account balances:", ""]
for email, balance in out_of_bounds_accounts.items():
lines.append(f"{email}: ${format(balance, '.2f')}")
lines_redacted.append(f"*******: ${format(balance, '.2f')}")
email_addresses_raw = current_app.config['ADMIN_NOTIFICATION_EMAIL_ADDRESSES'].split(",")
email_addresses = list(filter(lambda x: len(x) > 6, map(lambda x: x.strip(), email_addresses_raw ) ))
current_app.logger.info(f"notify_users_about_account_balance: sending out of bounds account balances email to {','.join(email_addresses)}:")
for line in lines:
current_app.logger.info(f"notify_users_about_account_balance: {line}.")
current_app.config["FLASK_MAIL_INSTANCE"].send(
Message(
"Capsul Out Of Bounds Account Balance Notification",
sender=current_app.config["MAIL_DEFAULT_SENDER"],
body="\n".join(lines_redacted),
recipients=email_addresses
)
)
if len(vms_to_delete) > 0:
current_app.config["FLASK_MAIL_INSTANCE"].send(
Message(
"VMs would be deleted",
sender=current_app.config["MAIL_DEFAULT_SENDER"],
body="\n".join(vms_to_delete),
recipients=current_app.config['ADMIN_NOTIFICATION_EMAIL_ADDRESSES'].split(",")
)
)
2020-05-15 04:40:27 +00:00
2022-02-08 23:16:52 +00:00
def delete_shortterm_vms_if_account_is_empty():
accounts = get_model().all_accounts()
for account in accounts:
vms = get_model().list_vms_for_account(account['email'])
payments = get_model().list_payments_for_account(account['email'])
balance = get_account_balance(vms, payments, datetime.utcnow())
shortterm_vms = list(filter(lambda vm: vm['shortterm'] == True and not vm['deleted'], vms))
2022-02-08 23:16:52 +00:00
if len(shortterm_vms) > 0 and balance <= 0:
pluralize_capsul = "s" if len(shortterm_vms) > 1 else ""
pluralize_past_tense = "have" if len(shortterm_vms) > 1 else "has"
current_app.config["FLASK_MAIL_INSTANCE"].send(
Message(
f"Short-term Capsul{pluralize_capsul} Deleted",
body=(
f"You have run out of funds! Your Short-term Capsul{pluralize_capsul} {pluralize_past_tense} been deleted.\n\n"
),
sender=current_app.config["MAIL_DEFAULT_SENDER"],
recipients=[account['email']]
)
)
for vm in shortterm_vms:
current_app.logger.warning(f"cron_task: deleting shortterm vm {vm['id']} ( {account['email']} ) due to negative account balance.")
current_app.config["HUB_MODEL"].destroy(email=account["email"], id=vm['id'])
get_model().delete_vm(email=account["email"], id=vm['id'])
def ensure_vms_and_db_are_synced():
db_vms_by_id = get_all_vms_from_db()
virt_vms_by_id = get_all_vms_from_hosts(db_vms_by_id)
inconsistency_info = get_inconsistent_capsuls_information(db_vms_by_id, virt_vms_by_id)
errors = list()
for vm in inconsistency_info['in_db_but_not_in_virt']:
errors.append(f"{vm['id']} ({vm['email']}) is in the database but not in the virtualization model")
for vm in inconsistency_info['state_not_equal_to_desired_state']:
errors.append(f"{vm['id']} ({vm['email']}) is {vm['state']} but it is supposed to be {vm['desired_state']}")
for vm in inconsistency_info['stole_someone_elses_ip_and_own_ip_avaliable']:
errors.append(f"{vm['id']} ({vm['email']}) stole_someone_elses_ip_and_own_ip_avaliable current_ipv4={vm['current_ipv4']} desired_ipv4={vm['desired_ipv4']}")
for vm in inconsistency_info['stole_someone_elses_ip_but_own_ip_also_stolen']:
errors.append(f"{vm['id']} ({vm['email']}) stole_someone_elses_ip_but_own_ip_also_stolen current_ipv4={vm['current_ipv4']} desired_ipv4={vm['desired_ipv4']}")
for vm in inconsistency_info['has_wrong_ip']:
errors.append(f"{vm['id']} ({vm['email']}) has_wrong_ip current_ipv4={vm['current_ipv4']} desired_ipv4={vm['desired_ipv4']}")
if len(errors) > 0:
email_addresses_raw = current_app.config['ADMIN_NOTIFICATION_EMAIL_ADDRESSES'].split(",")
email_addresses = list(filter(lambda x: len(x) > 6, map(lambda x: x.strip(), email_addresses_raw ) ))
current_app.logger.info(f"cron_task: sending inconsistency warning email to {','.join(email_addresses)}:")
for error in errors:
current_app.logger.info(f"cron_task: {error}.")
2020-05-22 02:45:38 +00:00
current_app.config["FLASK_MAIL_INSTANCE"].send(
Message(
"Capsul Consistency Check Failed",
sender=current_app.config["MAIL_DEFAULT_SENDER"],
body="\n".join(errors),
recipients=email_addresses
)
)