fix: Invoice generation must not consider deleted VMs' names for
ci/woodpecker/push/woodpecker Pipeline was successful Details

checking if payment is already fulfilled

DESCRIPTION
    Invoice generation is dependent on instance_name. Deleting a VM
    doesn't delete the corresponding payments record since payment
    receipts should be preserved for accounting purposes.

    But being heavily dependent on instance_name, without taking deleted
    VMs into account produces incorrect behavior under certain
    circumstances: if a VM named 'foo' is paid for and is deleted before
    its billing cycle is competed and a new VM is created with the same
    name, either by the same user or a different user, invoice won't be
    generated for the new VM since a payment record already exists for
    that billing cycle for the VM named 'foo'.

    Marking deleted VMs' payment records unsuitable for checking if a VM
    is already paid for will result in correct behavior.

fixes: https://gitea.hostea.org/Hostea/dashboard/issues/38
fix-invoice-generation-vm-name-dependency
Aravinth Manivannan 2022-07-08 20:43:22 +05:30
parent cc12d1a77d
commit b12cc044da
Signed by: realaravinth
GPG Key ID: AD9F0F08E855ED88
5 changed files with 210 additions and 123 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.3 on 2022-07-08 13:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("billing", "0004_payment_billing_pay_paid_by_77f57c_idx"),
]
operations = [
migrations.AddField(
model_name="payment",
name="vm_deleted",
field=models.BooleanField(default=False),
),
]

View File

@ -67,6 +67,8 @@ class Payment(BasePayment):
date = models.DateTimeField(auto_now_add=True, blank=True)
objects = PaymentModelManager()
vm_deleted = models.BooleanField(default=False, null=False)
def get_failure_url(self) -> str:
url = urlparse(settings.PAYMENT_HOST)
return urlunparse(

View File

@ -45,7 +45,9 @@ def payment_fullfilled(instance: Instance) -> bool:
delta = __get_delta()
payment = None
for p in Payment.objects.filter(date__gt=(delta), instance_name=instance.name):
for p in Payment.objects.filter(
date__gt=(delta), instance_name=instance.name, vm_deleted=False
):
if p.status == PaymentStatus.CONFIRMED:
return True
@ -77,7 +79,9 @@ def generate_invoice(instance: Instance) -> Payment:
delta = __get_delta()
payment = None
for p in Payment.objects.filter(date__gt=(delta), instance_name=instance.name):
for p in Payment.objects.filter(
date__gt=(delta), instance_name=instance.name, vm_deleted=False
):
if p.status == PaymentStatus.CONFIRMED:
raise GenerateInvoiceException(code=GenerateInvoiceErrorCode.ALREADY_PAID)
if any([p.status == PaymentStatus.INPUT, p.status == PaymentStatus.WAITING]):

View File

@ -13,6 +13,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import time
from io import StringIO
from django.test import TestCase, Client, override_settings
from django.core.management import call_command
@ -22,7 +23,9 @@ from accounts.tests import register_util, login_util
from dash.tests import create_configurations, create_instance_util, infra_custom_config
from infrastructure.management.commands.vm import translate_sizes
from .utils import Infra, Worker
from billing.utils import payment_fullfilled
from .utils import Infra, Worker, create_vm_if_not_exists, delete_vm
from .models import InstanceCreated, Job, JobType
@ -36,138 +39,190 @@ class InfraUtilTest(TestCase):
register_util(t=self, username=self.username)
create_configurations(t=self)
@override_settings(HOSTEA=infra_custom_config(test_name="test_path_util"))
def test_path_utils(self):
infra = Infra()
subdomain = "foo"
base = infra.repo_path
# @override_settings(HOSTEA=infra_custom_config(test_name="test_path_util"))
# def test_path_utils(self):
# infra = Infra()
# subdomain = "foo"
# base = infra.repo_path
#
# self.assertEqual(
# base.joinpath(f"inventory/host_vars/{subdomain}-host/"),
# infra._host_vars_dir(subdomain=subdomain),
# )
#
# self.assertEqual(
# base.joinpath(f"inventory/host_vars/{subdomain}-host/gitea.yml"),
# infra._gitea_path(subdomain=subdomain),
# )
#
# self.assertEqual(
# base.joinpath(f"inventory/host_vars/{subdomain}-host/provision.yml"),
# infra._provision_path(subdomain=subdomain),
# )
#
# self.assertEqual(
# base.joinpath(f"inventory/{subdomain}-backup.yml"),
# infra._backup_path(subdomain=subdomain),
# )
#
# self.assertEqual(
# base.joinpath(f"hosts-scripts/{subdomain}-host.sh"),
# infra._hostscript_path(subdomain=subdomain),
# )
#
# @override_settings(HOSTEA=infra_custom_config(test_name="test_add_vm"))
# def test_add_vm(self):
# c = Client()
# login_util(self, c, "accounts.home")
# subdomain = "add_vm"
#
# create_instance_util(
# t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
# )
#
# instance = Instance.objects.get(name=subdomain)
#
# infra = Infra()
# before_add = infra._sha()
# (password, after_add) = infra.add_vm(instance=instance)
# self.assertNotEqual(before_add, after_add)
#
# before_rm = after_add
# after_rm = infra.remove_vm(instance=instance)
# self.assertNotEqual(before_rm, after_rm)
#
# @override_settings(HOSTEA=infra_custom_config(test_name="test_cmd"))
# def test_cmd(self):
# subdomain = "cmd_vm"
# infra = Infra()
# c = Client()
# login_util(self, c, "accounts.home")
#
# self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
# # username exists
# call_command(
# "vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
# )
#
# instance = Instance.objects.get(name=subdomain)
#
# self.assertEqual(infra.get_flavor(instance=instance), "openstack_flavor_medium")
#
# self.assertEqual(instance.owned_by, self.user)
# self.assertEqual(
# instance.configuration_id, InstanceConfiguration.objects.get(name="s1-4")
# )
#
# instance_created = InstanceCreated.objects.get(instance=instance)
# self.assertEqual(instance_created.instance, instance)
#
# self.assertEqual(instance_created.created, True)
#
# # run create vm command again with same configuration to crudely check idempotency
# call_command(
# "vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
# )
#
# # run create vm command again with different configuration but same name
# # to crudely check idempotency
# call_command(
# "vm", "create", subdomain, f"--owner={self.username}", "--flavor=large"
# )
# instance.refresh_from_db()
# # verify new size is updated in DB
# self.assertEqual(
# str.strip(instance.configuration_id.name)
# == str.strip(translate_sizes("large")),
# True,
# )
#
# # verify new size is updated in repository
# self.assertEqual(
# str.strip(infra.translate_size(instance=instance))
# == str.strip(infra.get_flavor(instance=instance)),
# True,
# )
#
# call_command("vm", "delete", subdomain)
#
# self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
# host_vars_dir = infra._host_vars_dir(subdomain)
# self.assertEqual(host_vars_dir.exists(), False)
#
# # run delete VM command to crudely check idempotency
# call_command("vm", "delete", subdomain)
#
# def test_worker(self):
# subdomain = "gitea" # yes, gitea.hostea.org exists. will use it till I
# # figure out how to use requests_mock within django
# c = Client()
# login_util(self, c, "accounts.home")
# create_instance_util(
# t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
# )
#
# instance = Instance.objects.get(name=subdomain)
# job = Job.objects.create(instance=instance, job_type=JobType.PING)
# gitea_uri = Infra.get_gitea_uri(instance=instance)
# print(f"mocking {gitea_uri}")
#
# w = Worker(job=job)
# w.start()
# time.sleep(15)
# self.assertEqual(w.is_alive(), False)
# w.join()
# self.assertEqual(
# Job.objects.filter(instance=instance, job_type=JobType.PING).exists(), True
# )
#
@override_settings(HOSTEA=infra_custom_config(test_name="test_vm_delete_payments"))
def test_vm_delete_payments(self):
"""
Test if the dashboard generates invoices for a VM crated with a name
matching a VM name that was deleted that existed.
self.assertEqual(
base.joinpath(f"inventory/host_vars/{subdomain}-host/"),
infra._host_vars_dir(subdomain=subdomain),
)
self.assertEqual(
base.joinpath(f"inventory/host_vars/{subdomain}-host/gitea.yml"),
infra._gitea_path(subdomain=subdomain),
)
self.assertEqual(
base.joinpath(f"inventory/host_vars/{subdomain}-host/provision.yml"),
infra._provision_path(subdomain=subdomain),
)
self.assertEqual(
base.joinpath(f"inventory/{subdomain}-backup.yml"),
infra._backup_path(subdomain=subdomain),
)
self.assertEqual(
base.joinpath(f"hosts-scripts/{subdomain}-host.sh"),
infra._hostscript_path(subdomain=subdomain),
)
@override_settings(HOSTEA=infra_custom_config(test_name="test_add_vm"))
def test_add_vm(self):
ref: https://gitea.hostea.org/Hostea/dashboard/issues/38#issuecomment-1162
"""
c = Client()
login_util(self, c, "accounts.home")
subdomain = "add_vm"
instance_name = "test_vm_delete_payments"
infra = Infra()
create_instance_util(
t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
t=self, c=c, instance_name=instance_name, config=self.instance_config[0]
)
instance = Instance.objects.get(name=subdomain)
instance = Instance.objects.get(name=instance_name)
self.assertEqual(payment_fullfilled(instance=instance), True)
create_vm_if_not_exists(instance=instance)
infra = Infra()
before_add = infra._sha()
(password, after_add) = infra.add_vm(instance=instance)
self.assertNotEqual(before_add, after_add)
# delete VM
delete_vm(instance=instance)
self.assertEqual(Instance.objects.filter(name=instance_name).exists(), False)
before_rm = after_add
after_rm = infra.remove_vm(instance=instance)
self.assertNotEqual(before_rm, after_rm)
@override_settings(HOSTEA=infra_custom_config(test_name="test_cmd"))
def test_cmd(self):
subdomain = "cmd_vm"
infra = Infra()
c = Client()
login_util(self, c, "accounts.home")
self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
# username exists
# re-create VM with management command as it bypasses payments. We
# usually use create_instance_util but it will pay for the instance too
call_command(
"vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
"vm", "create", instance_name, f"--owner={self.username}", "--flavor=medium"
)
# verify VM is created
self.assertEqual(Instance.objects.filter(name=instance_name).exists(), True)
# verify payment is unfulfilled
instance = Instance.objects.get(name=instance_name)
self.assertEqual(payment_fullfilled(instance=instance), False)
instance = Instance.objects.get(name=subdomain)
# generate invoice
stdout = StringIO()
stderr = StringIO()
self.assertEqual(infra.get_flavor(instance=instance), "openstack_flavor_medium")
self.assertEqual(instance.owned_by, self.user)
self.assertEqual(
instance.configuration_id, InstanceConfiguration.objects.get(name="s1-4")
)
instance_created = InstanceCreated.objects.get(instance=instance)
self.assertEqual(instance_created.instance, instance)
self.assertEqual(instance_created.created, True)
# run create vm command again with same configuration to crudely check idempotency
call_command(
"vm", "create", subdomain, f"--owner={self.username}", "--flavor=medium"
)
# run create vm command again with different configuration but same name
# to crudely check idempotency
call_command(
"vm", "create", subdomain, f"--owner={self.username}", "--flavor=large"
)
instance.refresh_from_db()
# verify new size is updated in DB
self.assertEqual(
str.strip(instance.configuration_id.name)
== str.strip(translate_sizes("large")),
True,
)
# verify new size is updated in repository
self.assertEqual(
str.strip(infra.translate_size(instance=instance))
== str.strip(infra.get_flavor(instance=instance)),
True,
)
call_command("vm", "delete", subdomain)
self.assertEqual(Instance.objects.filter(name=subdomain).exists(), False)
host_vars_dir = infra._host_vars_dir(subdomain)
self.assertEqual(host_vars_dir.exists(), False)
# run delete VM command to crudely check idempotency
call_command("vm", "delete", subdomain)
def test_worker(self):
subdomain = "gitea" # yes, gitea.hostea.org exists. will use it till I
# figure out how to use requests_mock within django
c = Client()
login_util(self, c, "accounts.home")
create_instance_util(
t=self, c=c, instance_name=subdomain, config=self.instance_config[0]
)
instance = Instance.objects.get(name=subdomain)
job = Job.objects.create(instance=instance, job_type=JobType.PING)
gitea_uri = Infra.get_gitea_uri(instance=instance)
print(f"mocking {gitea_uri}")
w = Worker(job=job)
w.start()
time.sleep(15)
self.assertEqual(w.is_alive(), False)
w.join()
self.assertEqual(
Job.objects.filter(instance=instance, job_type=JobType.PING).exists(), True
"generate_invoice",
stdout=stdout,
stderr=stderr,
)
out = stdout.getvalue()
print("out")
print(out)
self.assertEqual(instance_name in out, True)
self.assertEqual(f"Payment not fulfilled for instance: {instance}" in out, True)

View File

@ -26,6 +26,7 @@ from django.utils.crypto import get_random_string
from django.template.loader import render_to_string
from django.core.mail import send_mail
from django.conf import settings
from payments import get_payment_model
from dash.models import Instance
@ -100,6 +101,13 @@ def create_vm_if_not_exists(instance: Instance) -> (str, str):
def delete_vm(instance: Instance):
infra = Infra()
Payment = get_payment_model()
for payment in Payment.objects.filter(
paid_by=instance.owned_by, instance_name=instance.name
):
payment.vm_deleted = True
payment.save()
infra.remove_vm(instance=instance)
if InstanceCreated.objects.filter(instance=instance).exists():
InstanceCreated.objects.get(instance=instance).delete()