First version of Mors - Lease Manager

What works:
- All the add/delete/update APIs integrated with UI and tested end to
  end
- Basic unit tests that test the above metioned APIs.

What (may) not be working or in other words is not fully tested
- Actual deletes of the VM, it used to work, but code has gone through
  major changes so need to test again.
- Cases:
-- Making sure Override of the lease works.
-- Cases where VM changes tenants or is deleted before the lease expiry
-- Removal of the tenant (not tested at all)

Next steps:
- Better unit test cases - better verification
- Deployment scripts (Ansible playbooks)

Adding a manage script for managing database upgrade script

Adding manage.py
This commit is contained in:
Roopak Parikh 2016-03-22 21:52:08 +00:00 committed by Roopak Parikh
parent d09213ac9e
commit e62bf60096
37 changed files with 1391 additions and 0 deletions

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

6
.reviewboardrc Normal file
View File

@ -0,0 +1,6 @@
REVIEWBOARD_URL = 'https://rbcommons.com/s/platform9/'
REPOSITORY = 'pf9-mors'
GUESS_DESCRIPTION = True
GUESS_SUMMARY = True
TARGET_GROUPS = 'platform9'
TRACKING_BRANCH = 'origin/atherton'

View File

@ -1,2 +1,7 @@
# pf9-mors
Mors is the Roman God of death. Mors helps us implement leases.
The functionality is described here in details:
https://platform9.atlassian.net/wiki/pages/viewpage.action?pageId=58490897

6
build.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
set -e
source ../pf9-version/pf9-version.rc
export ROOT_DIR=`pwd`
cd $ROOT_DIR/support/
make all

100
etc/init.d/pf9-mors Executable file
View File

@ -0,0 +1,100 @@
#!/bin/sh
#
# chkconfig: - 98 02
# description: Platform9 Lease Manager (code -named Mors)
### BEGIN INIT INFO
# Provides: pf9-mors
# Required-Start: $remote_fs $network $syslog
# Required-Stop: $remote_fs $syslog
# Default-Stop: 0 1 6
# Short-Description: Mors Server
# Description: Platform9 Lease Manager (code -named Mors)
### END INIT INFO
. /etc/rc.d/init.d/functions
name=pf9-mors
prog=pf9-mors
bindir=/opt/pf9/$name/bin
python=$bindir/python
exec="$python $bindir/pf9_mors.py"
pidfile="/var/run/$name.pid"
log_stdout="/var/log/pf9/$name-out.log"
[ -e /etc/sysconfig/$prog ] && . /etc/sysconfig/$prog
lockfile=/var/lock/subsys/$prog
start() {
[ -x $python ] || exit 5
echo -n $"Starting $prog: "
daemon --pidfile $pidfile "$exec >> $log_stdout 2>&1 & echo \$! > $pidfile"
retval=$?
echo
[ $retval -eq 0 ] && touch $lockfile
return $retval
}
stop() {
echo -n $"Stopping $prog: "
killproc -p $pidfile $prog
retval=$?
echo
[ $retval -eq 0 ] && rm -f $lockfile
return $retval
}
restart() {
stop
start
}
reload() {
restart
}
force_reload() {
restart
}
rh_status() {
status -p $pidfile $prog
}
rh_status_q() {
rh_status >/dev/null 2>&1
}
case "$1" in
start)
rh_status_q && exit 0
$1
;;
stop)
rh_status_q || exit 0
$1
;;
restart)
$1
;;
reload)
rh_status_q || exit 7
$1
;;
force-reload)
force_reload
;;
status)
rh_status
;;
condrestart|try-restart)
rh_status_q || exit 0
restart
;;
*)
echo $"Usage: $0 {start|stop|status|restart|condrestart|try-restart|reload|force-reload}"
exit 2
esac
exit $?

View File

@ -0,0 +1,4 @@
location /mors/ {
proxy_pass http://127.0.0.1:8989/;
include /etc/nginx/conf.d/pf9/cors.conf;
}

View File

@ -0,0 +1,14 @@
[app:myService]
paste.app_factory = mors.mors_wsgi:app_factory
[pipeline:main]
pipeline = authtoken myService
[filter:authtoken]
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
auth_host = 127.0.0.1
auth_port = 35357
auth_protocol = http
admin_token = RzvUwrEgiOaQFTXV
auth_uri = http://127.0.0.1:8080/keystone
identity_uri = http://127.0.0.1:8080/keystone_admin

16
etc/pf9/pf9-mors.ini Normal file
View File

@ -0,0 +1,16 @@
[DEFAULT]
db_conn=
context_factory=
lease_handler=
listen_port=8989
sleep_seconds=300
paste-ini=/etc/pf9/pf9-mors-api-paste.ini
log_file=/var/log/pf9/pf9-mors.log
repo=/opt/pf9/pf9-mors/lib/python2.7/site-packages/mors_repo
[nova]
user_name=
password=
version=2
auth_url=
region_name=

0
mors/__init__.py Normal file
View File

69
mors/context_util.py Normal file
View File

@ -0,0 +1,69 @@
# Copyright (c) 2016 Platform9 Systems Inc.
# All Rights reserved
from flask import request, jsonify
import functools, os
def get_context():
return Context(request.headers['X-User-Id'],
request.headers['X-User'],
request.headers['X-Roles'],
request.headers['X-Tenant-Id'])
def error_handler(func):
from sqlalchemy.exc import IntegrityError
import traceback,sys
@functools.wraps(func)
def inner(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValueError as exc:
traceback.print_exc(file=sys.stdout)
return jsonify({'error': 'Invalid input'}), 422, {'ContentType': 'application/json'}
except IntegrityError as exc:
traceback.print_exc(file=sys.stdout)
return jsonify({'error': 'Already exists'}), 409, {'ContentType': 'application/json'}
return inner
def enforce(required=[]):
"""
Generates a decorator that checks permissions before calling the
contained pecan handler function.
:param list[str] required: Roles require to run function.
"""
def _enforce(fun):
@functools.wraps(fun)
def newfun(self, *args, **kwargs):
if not (required):
return fun(*args, **kwargs)
else:
roles_hdr = request.headers('X-Roles')
if roles_hdr:
roles = roles_hdr.split(',')
else:
roles = []
if set(roles) & set(required):
return fun( *args, **kwargs)
else:
return jsonify({'error': 'Unauthorized'}), 403, {'ContentType': 'application/json'}
return newfun
return _enforce
class Context:
def __init__(self, user_id, user_name, roles_str, tenant_id):
self.user_id = user_id
self.user_name = user_name
self.roles = roles_str.split(',')
self.tenant_id = tenant_id

186
mors/lease_manager.py Normal file
View File

@ -0,0 +1,186 @@
# Copyright Platform9 Systems Inc. 2016
from datetime import datetime, timedelta
from leasehandler import get_lease_handler
from persistence import DbPersistence
from eventlet.greenthread import spawn_after
import logging
from leasehandler.constants import SUCCESS_OK, ERR_UNKNOWN, ERR_NOT_FOUND
logger = logging.getLogger(__name__)
def get_tenant_lease_data(data):
"""
Simple function to transform tenant database proxy object into an externally
consumable dictionary.
:param data: database row object
"""
return {'vm_lease_policy': {'tenant_uuid': data['tenant_uuid'],
'expiry_days': data['expiry_days'],
'created_at': data['created_at'],
'created_by': data['created_by'],
'updated_at': data['updated_at'],
'updated_by': data['updated_by']}}
def get_vm_lease_data(data):
"""
Simple function to transform instance database proxy object into an externally
consumable dictionary.
:param data: database row object
"""
return {'instance_uuid': data['instance_uuid'],
'tenant_uuid': data['tenant_uuid'],
'expiry': data['expiry'],
'created_at': data['created_at'],
'created_by': data['created_by'],
'updated_at': data['updated_at'],
'updated_by': data['updated_by']}
class LeaseManager:
"""
Lease Manager is the main class for mors dealing with CRUD operations for the REST API
as well as the actual deletion of the Instances. Instance deletion and discovery is achieved
through an object 'leasehandler'.
"""
def __init__(self, conf):
self.domain_mgr = DbPersistence(conf.get("DEFAULT", "db_conn"))
self.lease_handler = get_lease_handler(conf)
self.sleep_seconds = conf.getint("DEFAULT", "sleep_seconds")
def add_tenant_lease(self, context, tenant_obj):
logger.info("Adding tenant lease %s", tenant_obj)
self.domain_mgr.add_tenant_lease(
tenant_obj['tenant_uuid'],
tenant_obj['expiry_days'],
context.user_id,
datetime.utcnow())
def update_tenant_lease(self, context, tenant_obj):
logger.info("Update tenant lease %s", tenant_obj)
self.domain_mgr.update_tenant_lease(
tenant_obj['tenant_uuid'],
tenant_obj['expiry_days'],
context.user_id,
datetime.utcnow())
def delete_tenant_lease(self, context, tenant_id):
logger.info("Delete tenant lease %s", tenant_id)
return self.domain_mgr.delete_tenant_lease(tenant_id)
def get_tenant_leases(self, context):
logger.debug("Getting all tenant lease")
all_tenants = self.domain_mgr.get_all_tenant_leases()
all_tenants = map(lambda x: get_tenant_lease_data(x), all_tenants)
logger.debug("Getting all tenant lease %s", all_tenants)
return all_tenants
def get_tenant_lease(self, context, tenant_id):
data = self.domain_mgr.get_tenant_lease(tenant_id)
logger.debug("Getting tenant lease %s", data)
if data:
return get_tenant_lease_data(data)
return {}
def get_tenant_and_associated_instance_leases(self, context, tenant_uuid):
logger.debug("Getting tenant and instances leases %s", tenant_uuid)
return {
'tenant_lease': self.get_tenant_lease(context, tenant_uuid),
'all_vms':
map(lambda x: get_vm_lease_data(x), self.domain_mgr.get_instance_leases_by_tenant(tenant_uuid))
}
# To Be Implemented
def check_instance_lease_violation(self, instance_lease, tenant_lease):
return True
def get_instance_lease(self, context, instance_id):
data = self.domain_mgr.get_instance_lease(instance_id)
if data:
data = get_vm_lease_data(data)
logger.debug("Get instance lease %s %s", instance_id, data)
return data
def add_instance_lease(self, context, tenant_uuid, instance_lease_obj):
logger.info("Add instance lease %s", instance_lease_obj)
tenant_lease = self.domain_mgr.get_tenant_lease(tenant_uuid)
self.check_instance_lease_violation(instance_lease_obj, tenant_lease)
self.domain_mgr.add_instance_lease(instance_lease_obj['instance_uuid'],
tenant_uuid,
instance_lease_obj['expiry'],
context.user_id,
datetime.utcnow())
def update_instance_lease(self, context, tenant_uuid, instance_lease_obj):
logger.info("Update instance lease %s", instance_lease_obj)
self.domain_mgr.update_instance_lease(instance_lease_obj['instance_uuid'],
tenant_uuid,
instance_lease_obj['expiry'],
context.user_id,
datetime.utcnow())
def delete_instance_lease(self, context, instance_uuid):
logger.info("Delete instance lease %s", instance_uuid)
self.domain_mgr.delete_instance_leases([instance_uuid])
def start(self):
spawn_after(self.sleep_seconds, self.run)
# Could have used a generator here, would save memory but wonder if it is a good idea given the error conditions
# This is a simple implementation which goes and deletes VMs one by one
def _get_vms_to_delete_for_tenant(self, tenant_uuid, expiry_days):
vms_to_delete = []
vm_ids_to_delete = set()
now = datetime.utcnow()
add_days = timedelta(days=expiry_days)
instance_leases = self.get_tenant_and_associated_instance_leases(None, tenant_uuid)['all_vms']
for i_lease in instance_leases:
if now > i_lease['expiry']:
logger.info("Explicit lease for %s queueing for deletion", i_lease['instance_uuid'])
vms_to_delete.append(i_lease)
vm_ids_to_delete.add(i_lease['instance_uuid'])
else:
logger.debug("Ignoring vm, vm not expired yet %s", i_lease['instance_uuid'])
tenant_vms = self.lease_handler.get_all_vms(tenant_uuid)
for vm in tenant_vms:
expiry_date = vm['created_at'] + add_days
if now > expiry_date and not (vm['instance_uuid'] in vm_ids_to_delete):
logger.info("Instance %s queued up for deletion creation date %s", vm['instance_uuid'],
vm['created_at'])
vms_to_delete.append(vm)
else:
logger.debug("Ignoring vm, vm not expired yet or already deleted %s, %s", vm['instance_uuid'],
vm['created_at'])
return vms_to_delete
def _delete_vms_for_tenant(self, t_lease):
tenant_vms_to_delete = self._get_vms_to_delete_for_tenant(t_lease['tenant_uuid'], t_lease['expiry_days'])
# Keep it simple and delete them serially
result = self.lease_handler.delete_vms(tenant_vms_to_delete)
remove_from_db = []
for vm_result in result.items():
# If either the VM has been successfully deleted or has already been deleted
# Remove from our database
if vm_result[1] == SUCCESS_OK or vm_result[1] == ERR_NOT_FOUND:
remove_from_db.append(vm_result[0])
if len(remove_from_db) > 0:
logger.info("Removing vms %s from db", remove_from_db)
self.domain_mgr.delete_instance_leases(remove_from_db)
def run(self):
# Delete the cleanup
tenant_leases = self.domain_mgr.get_all_tenant_leases()
for t_lease in tenant_leases:
self._delete_vms_for_tenant(t_lease)
# Sleep again for sleep_seconds
spawn_after(self.sleep_seconds, self.run)

View File

@ -0,0 +1,12 @@
# Copyright 2016 Platform9 Systems Inc.
from nova_lease_handler import NovaLeaseHandler
from fake_lease_handler import FakeLeaseHandler
import constants
def get_lease_handler(conf):
if conf.get("DEFAULT", "lease_handler") == "test":
return FakeLeaseHandler(conf)
else:
return NovaLeaseHandler(conf)

View File

@ -0,0 +1,5 @@
# Copyright Platform9 Systems Inc. 2016
SUCCESS_OK = 0
ERR_NOT_FOUND = 1
ERR_UNKNOWN = 2

View File

@ -0,0 +1,38 @@
# Copyright Platform9 Systems Inc. 2016
import constants
import logging
from datetime import datetime
# @TODO: Need to move this to a test folder
class FakeLeaseHandler:
# Singleton tenants
tenants = {}
def __init__(self,conf):
self.logger = logging.getLogger("test-lease-handler")
pass
def add_tenant_data(self, tenant_id, instances):
FakeLeaseHandler.tenants[tenant_id] = instances
print FakeLeaseHandler.tenants
def get_tenant_data(self, tenant_id):
return FakeLeaseHandler.tenants[tenant_id]
def get_all_vms(self, tenant_uuid):
return FakeLeaseHandler.tenants[tenant_uuid]
def delete_vm(self, tenant_uuid, vm_id):
vms = FakeLeaseHandler.tenants[tenant_uuid]
new_vm_data = filter(lambda x: x['instance_uuid'] != vm_id, vms)
FakeLeaseHandler.tenants[tenant_uuid] = new_vm_data
def delete_vms(self, vms):
result = {}
for vm in vms:
self.logger.info("Deleting VM vm %s", vm)
self.delete_vm(vm['tenant_uuid'], vm['instance_uuid'])
result[vm['instance_uuid']] = constants.SUCCESS_OK
return result

View File

@ -0,0 +1,70 @@
# Copyright 2016 Platform9 Systems Inc.
from novaclient import client
import logging
import novaclient
from datetime import datetime
from constants import SUCCESS_OK, ERR_NOT_FOUND, ERR_UNKNOWN
logger = logging.getLogger(__name__)
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
def get_vm_data(data):
return {'instance_uuid': data.id,
'tenant_uuid': data.tenant_id,
'created_at': datetime.strptime(data.created)}
class NovaLeaseHandler:
def __init__(self, conf):
self.conf = conf
def _get_nova_client(self):
return client.Client(self.conf.get("nova", "version"),
username=self.conf.get("nova", "user_name"),
region_name=self.conf.get("nova", "region_name"),
tenant_id=self.conf.get("nova", "tenant_uuid"),
api_key=self.conf.get("nova", "password"),
auth_url=self.conf.get("nova", "auth_url"),
connection_pool=False)
def get_all_vms(self, tenant_uuid):
"""
Get all vms for a given tenant
:param tenant_uuid:
:return: an iteratble that returns a set of vms (each vm has a UUID and a created_at field)
"""
try:
with self._get_nova_client() as nova:
vms = nova.servers.list(search_opts={'all_tenants':1, 'tenant_id':tenant_uuid})
return map(lambda x: get_vm_data(x), vms)
except Exception as e:
logger.exception("Error getting list of vms for tenant %s", tenant_uuid)
return []
def _delete_vm(self, nova, vm_uuid):
try:
logger.info("Deleting VM %s", vm_uuid)
nova.server.delete(vm_uuid)
return SUCCESS_OK
except novaclient.exceptions.NotFound:
return ERR_NOT_FOUND
except Exception as e:
logger.exception("Error deleting vm %s", vm_uuid)
return ERR_UNKNOWN
def delete_vms(self, vms):
"""
Delete a VM on a given tenant
:param tenant_uuid:
:param vm_uuid:
:return: dictionary of vm_id to result
"""
result = {}
try:
with self._get_nova_client() as nova:
for vm in vms:
result[vm['instance_uuid']] = self._delete_vm(nova, vm['instance_uuid'])
return result
except Exception as e:
logger.exception("Error deleting vm %s", vms)
return result

130
mors/mors_wsgi.py Normal file
View File

@ -0,0 +1,130 @@
# Copyright (c) 2016 Platform9 Systems Inc.
# All Rights reserved
from flask import Flask, request, jsonify
from lease_manager import LeaseManager
from context_util import enforce, get_context, error_handler
from flask.json import JSONEncoder
from datetime import datetime
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
APP_NAME = "MORS"
class CustomJSONEncoder(JSONEncoder):
def default(self, obj):
try:
if isinstance(obj, datetime):
return obj.strftime(DATE_FORMAT)
iterable = iter(obj)
except TypeError:
pass
else:
return list(iterable)
return JSONEncoder.default(self, obj)
app = Flask(__name__)
app.debug = True
app.json_encoder = CustomJSONEncoder
lease_manager = None
@enforce(required=['admin'])
@app.route("/v1/tenant", methods=['GET'])
@app.route("/v1/tenant/", methods=['GET'])
@error_handler
def get_all_tenants():
all_tenants = lease_manager.get_tenant_leases(get_context())
if all_tenants:
return jsonify({"all_tenants":all_tenants})
else:
return jsonify({}), 200, {'ContentType': 'application/json'}
@enforce(required=['_member_'])
@app.route("/v1/tenant/<tenant_id>", methods=['GET'])
@error_handler
def get_tenant(tenant_id):
tenant_lease = lease_manager.get_tenant_lease(get_context(), tenant_id)
if not tenant_lease:
return jsonify({'success': False}), 404, {'ContentType': 'application/json'}
return jsonify(tenant_lease)
@enforce(required=['admin'])
@app.route("/v1/tenant/<tenant_id>", methods=['PUT', 'POST'])
@error_handler
def add_update_tenant(tenant_id):
tenant_lease = request.get_json()["vm_lease_policy"]
if request.method == "POST":
lease_manager.add_tenant_lease(get_context(), tenant_lease)
else:
lease_manager.update_tenant_lease(get_context(), tenant_lease)
return jsonify({'success': True}), 200, {'ContentType': 'application/json'}
@enforce(required=['admin'])
@app.route("/v1/tenant/<tenant_id>", methods=['DELETE'])
@error_handler
def delete_tenant_lease(tenant_id):
lease_manager.delete_tenant_lease(get_context(), tenant_id)
return jsonify({'success': True}), 200, {'ContentType': 'application/json'}
#-- Instnace - tenant related
@enforce(required=['_member_'])
@app.route("/v1/tenant/<tenant_id>/instances/", methods=['GET'])
@error_handler
def get_tenant_and_instances(tenant_id):
instances = lease_manager.get_tenant_and_associated_instance_leases(get_context(), tenant_id)
if not instances:
return jsonify({'success': False}), 404, {'ContentType': 'application/json'}
return jsonify(instances)
# --- Instance related ---
@enforce(required=['_member_'])
@app.route("/v1/tenant/<tenant_id>/instance/<instance_id>", methods=['GET'])
@error_handler
def get_vm_lease(tenant_id, instance_id):
lease_info = lease_manager.get_instance_lease(get_context(), instance_id)
if lease_info:
return jsonify(lease_info), 200, {'ContentType': 'application/json'}
else:
return jsonify({'error': 'Not found'}), 404, {'ContentType': 'application/json'}
@enforce(required=['_member_'])
@app.route("/v1/tenant/<tenant_id>/instance/<instance_id>", methods=['DELETE'])
@error_handler
def delete_vm_lease(tenant_id, instance_id):
lease_manager.delete_instance_lease(get_context(), instance_id)
return jsonify({'success': True}), 200, {'ContentType': 'application/json'}
@enforce(required=['_member_'])
@app.route("/v1/tenant/<tenant_id>/instance/<instance_id>", methods=['PUT', 'POST'])
@error_handler
def add_update_vm_lease(tenant_id, instance_id):
lease_obj = request.get_json()
# ds = '2012-03-01T10:00:00Z' # or any date sting of differing formats.
date = datetime.strptime(lease_obj['expiry'], DATE_FORMAT)
lease_obj['expiry'] = date
if request.method == "POST":
lease_manager.add_instance_lease(get_context(), tenant_id, lease_obj)
else:
lease_manager.update_tenant_lease(get_context(), tenant_id, lease_obj)
return jsonify({'success': True}), 200, {'ContentType': 'application/json'}
def start_server(conf):
global lease_manager
lease_manager = LeaseManager(conf)
lease_manager.start()
def shutdown_server():
func = request.environ.get('werkzeug.server.shutdown')
if func is None:
raise RuntimeError('Not running with the Werkzeug Server')
func()
def app_factory(global_config, **local_conf):
return app

108
mors/persistence.py Normal file
View File

@ -0,0 +1,108 @@
# Copyright Platform9 Systems Inc. 2016
from sqlalchemy.pool import QueuePool
from sqlalchemy import create_engine, text
from sqlalchemy import Table, Column, Integer, String, MetaData, DateTime
import logging, functools
logger = logging.getLogger(__name__)
def db_connect(transaction=False):
"""
Generates a decorator that get connection from a pool and returns
it to the pool when the internal function is done
:param transaction bool: should this function create and end transaction.
"""
def _db_connect(fun):
if hasattr(fun, '__name__'):
fun.__name__ = 'method_decorator(%s)' % fun.__name__
else:
fun.__name__ = 'method_decorator(%s)' % fun.__class__.__name__
@functools.wraps(fun)
def newfun(self, *args, **kwargs):
conn = self.engine.connect()
if transaction:
trans = conn.begin()
try:
ret = fun(self, conn, *args, **kwargs)
if transaction:
trans.commit()
return ret
except Exception as e:
if transaction:
trans.rollback()
logger.exception("Error during transaction ")
raise
finally:
conn.close()
return newfun
return _db_connect
class DbPersistence:
def __init__(self, db_conn_string):
self.engine = create_engine(db_conn_string, poolclass=QueuePool)
self.metadata = MetaData(bind=self.engine)
self.tenant_lease = Table('tenant_lease', self.metadata, autoload=True)
self.instance_lease = Table('instance_lease', self.metadata, autoload=True)
@db_connect(transaction=False)
def get_all_tenant_leases(self, conn):
return conn.execute(self.tenant_lease.select()).fetchall()
@db_connect(transaction=False)
def get_tenant_lease(self, conn, tenant_uuid):
return conn.execute(self.tenant_lease.select(tenant_uuid == tenant_uuid)).first()
@db_connect(transaction=True)
def add_tenant_lease(self, conn, tenant_uuid, expiry_days, created_by, created_at):
logger.debug("Adding tenant lease %s %d %s %s", tenant_uuid, expiry_days, str(created_at), created_by)
conn.execute(self.tenant_lease.insert(), tenant_uuid=tenant_uuid, expiry_days=expiry_days,
created_at=created_at, created_by=created_by)
@db_connect(transaction=True)
def update_tenant_lease(self, conn, tenant_uuid, expiry_days, updated_by, updated_at):
logger.debug("Updating tenant lease %s %d %s %s", tenant_uuid, expiry_days, str(updated_at), updated_by)
conn.execute(self.tenant_lease.update(tenant_uuid == tenant_uuid), expiry_days=expiry_days,
updated_at=updated_at, updated_by=updated_by)
@db_connect(transaction=True)
def delete_tenant_lease(self, conn, tenant_uuid):
# Should we just soft delete ?
logger.debug("Deleting tenant lease %s", tenant_uuid)
conn.execute(self.tenant_lease.delete().where(tenant_uuid == tenant_uuid))
conn.execute(self.instance_lease.delete().where(tenant_uuid == tenant_uuid))
@db_connect(transaction=False)
def get_instance_leases_by_tenant(self, conn, tenant_uuid):
return conn.execute(self.instance_lease.select(tenant_uuid == tenant_uuid)).fetchall()
@db_connect(transaction=False)
def get_instance_lease(self, conn, instance_uuid):
return conn.execute(
self.instance_lease.select((instance_uuid == instance_uuid))).first()
@db_connect(transaction=True)
def add_instance_lease(self, conn, instance_uuid, tenant_uuid, expiry, created_by, created_at):
logger.debug("Adding instance lease %s %s %s %s", instance_uuid, tenant_uuid, expiry, created_by)
conn.execute(self.instance_lease.insert(), instance_uuid=instance_uuid, tenant_uuid=tenant_uuid,
expiry=expiry,
created_at=created_at, created_by=created_by)
@db_connect(transaction=True)
def update_instance_lease(self, conn, instance_uuid, tenant_uuid, expiry, updated_by, updated_at):
logger.debug("Updating instance lease %s %s %s %s", instance_uuid, tenant_uuid, expiry, updated_by)
conn.execute(self.instance_lease.update(instance_uuid == instance_uuid), tenant_uuid=tenant_uuid,
expiry=expiry,
updated_at=updated_at, updated_by=updated_by)
@db_connect(transaction=True)
def delete_instance_leases(self, conn, instance_uuids):
# Delete 10 at a time, should we soft delete
logger.debug("Deleting instance leases %s", str(instance_uuids))
conn.execute(self.instance_lease.delete().where(self.instance_lease.c.instance_uuid.in_(instance_uuids)))

27
mors_manage.py Normal file
View File

@ -0,0 +1,27 @@
#!/opt/pf9/pf9-mors/bin/python
# Copyright (c) 2016 Platform9 Systems Inc.
# All Rights reserved
import argparse, logging
import ConfigParser
from migrate.versioning.api import upgrade, create, version_control
def _get_arg_parser():
parser = argparse.ArgumentParser(description="Lease Manager for VirtualMachines")
parser.add_argument('--config-file', dest='config_file', default='/etc/pf9/pf9-mors.ini')
parser.add_argument('--command', dest='command', default='db_sync')
return parser.parse_args()
if __name__ == '__main__':
parser = _get_arg_parser()
conf = ConfigParser.ConfigParser()
conf.readfp(open(parser.config_file))
if 'db_sync' == parser.command:
version_control(conf.get("DEFAULT", "db_conn"), conf.get("DEFAULT", "repo"))
upgrade(conf.get("DEFAULT", "db_conn"), , conf.get("DEFAULT", "repo"))
exit(0)
else:
print 'Unknown command'
exit(1)

4
mors_repo/README Normal file
View File

@ -0,0 +1,4 @@
This is a database migration repository.
More information at
http://code.google.com/p/sqlalchemy-migrate/

0
mors_repo/__init__.py Normal file
View File

BIN
mors_repo/__init__.pyc Normal file

Binary file not shown.

5
mors_repo/manage.py Normal file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env python
from migrate.versioning.shell import main
if __name__ == '__main__':
main(debug='False')

25
mors_repo/migrate.cfg Normal file
View File

@ -0,0 +1,25 @@
[db_settings]
# Used to identify which repository this database is versioned under.
# You can use the name of your project.
repository_id=Mors
# The name of the database table used to track the schema version.
# This name shouldn't already be used by your project.
# If this is changed once a database is under version control, you'll need to
# change the table name in each database too.
version_table=migrate_version
# When committing a change script, Migrate will attempt to generate the
# sql for all supported databases; normally, if one of them fails - probably
# because you don't have that database installed - it is ignored and the
# commit continues, perhaps ending successfully.
# Databases in this list MUST compile successfully during a commit, or the
# entire commit will fail. List the databases your application will actually
# be using to ensure your updates to that database work properly.
# This must be a list; example: ['postgres','sqlite']
required_dbs=[]
# When creating new change scripts, Migrate will stamp the new script with
# a version number. By default this is latest_version + 1. You can set this
# to 'true' to tell Migrate to use the UTC timestamp instead.
use_timestamp_numbering=False

View File

@ -0,0 +1,36 @@
# Copyright Platform9 Systems Inc. 2016
from sqlalchemy import Table, Column, Integer, String, MetaData, DateTime
meta = MetaData()
tenant_lease = Table(
'tenant_lease', meta,
Column('tenant_uuid', String(40), primary_key=True),
Column('expiry_days', Integer),
Column('created_at', DateTime),
Column('updated_at', DateTime),
Column('created_by', String(40)),
Column('updated_by', String(40))
)
vm_lease = Table(
'instance_lease', meta,
Column('instance_uuid', String(40), primary_key=True),
Column('tenant_uuid', String(40)),
Column('expiry', DateTime),
Column('created_at', DateTime),
Column('updated_at', DateTime),
Column('created_by', String(40)),
Column('updated_by', String(40))
)
def upgrade(migrate_engine):
meta.bind = migrate_engine
tenant_lease.create()
vm_lease.create()
def downgrade(migrate_engine):
meta.bind = migrate_engine
vm_lease.drop()
tenant_lease.drop()

View File

Binary file not shown.

48
pf9_mors.py Normal file
View File

@ -0,0 +1,48 @@
#!/opt/pf9/pf9-mors/bin/python
# Copyright (c) 2016 Platform9 Systems Inc.
# All Rights reserved
from paste.deploy import loadapp
from eventlet import wsgi
import eventlet
import argparse, logging
import logging.handlers
import ConfigParser, os
from mors import mors_wsgi
eventlet.monkey_patch()
def _get_arg_parser():
parser = argparse.ArgumentParser(description="Lease Manager for VirtualMachines")
parser.add_argument('--config-file', dest='config_file', default='/etc/pf9/pf9-mors.ini')
parser.add_argument('--paste-ini', dest='paste_file')
return parser.parse_args()
def _configure_logging(conf):
log_filename = conf.get("DEFAULT", "log_file")
logging.basicConfig(filename=log_filename,
level=logging.DEBUG,
format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
datefmt='%m-%d %H:%M')
handler = logging.handlers.RotatingFileHandler(
log_filename, maxBytes=1024 * 1024 * 5, backupCount=5)
logging.root.addHandler(handler)
def start_server(conf, paste_ini):
_configure_logging(conf)
paste_file = None
if paste_ini:
paste_file = paste_ini
else:
paste_file = conf.get("DEFAULT", "paste-ini")
wsgi_app = loadapp('config:%s' % paste_file, 'main')
mors_wsgi.start_server(conf)
wsgi.server(eventlet.listen(('', conf.getint("DEFAULT", "listen_port"))), wsgi_app)
if __name__ == '__main__':
parser = _get_arg_parser()
conf = ConfigParser.ConfigParser()
conf.readfp(open(parser.config_file))
start_server(conf, parser.pate_file)

36
setup.py Normal file
View File

@ -0,0 +1,36 @@
#!/usr/bin/env python
from setuptools import setup
setup(name='pf9-mors',
version='0.1',
description='Platform9 Mors (lease manager)',
author='Roopak Parikh',
author_email='rparikh@platform9.net',
url='https://github.com/platform9/pf9-mors',
packages=['mors',
'mors/leasehandler',
'mors_repo',
'mors_repo/versions'],
install_requires=[
'pbr==0.11.0',
'pytz==2015.7',
'keystoneauth1==2.3.0',
'oslo.i18n==3.4.0',
'oslo.serialization==2.4.0',
'oslo.utils==3.7.0',
'keystonemiddleware==4.3.0',
'Paste==1.7.5.1',
'PasteDeploy==1.5.2',
'pip==1.5.2',
'python-novaclient==3.2.0',
'flask==0.10.0',
'SQLAlchemy==0.9.8',
'sqlalchemy-migrate==0.9.5',
'PyMySQL',
'eventlet==0.18.4',
'nose',
'proboscis'
],
scripts=['pf9_mors.py', 'mors_manage.py']
)

65
support/Makefile Normal file
View File

@ -0,0 +1,65 @@
#! vim noexpandtab
# Copyright (C) 2016 Platform 9 Systems, Inc.
TOP_DIR := $(abspath ../)
SRC_DIR := $(TOP_DIR)
BUILD_DIR := $(TOP_DIR)/build
NPM := npm
APP_NAME :=pf9-mors
APP_DESC :="Platform9 mors (lease manager)"
APP_BUILD_DIR := $(BUILD_DIR)
PF9_VERSION ?=2.0.0
BUILD_NUMBER ?= 0
GIT_HASH := $(shell git rev-parse --short HEAD)
FULL_VERSION := $(PF9_VERSION)-$(BUILD_NUMBER)
APP_DESC :="Platform9 mors(lease manager) git hash $(GIT_HASH)"
APP_RPM_DIR := $(APP_BUILD_DIR)/rpmbuild
APP_RPM_STAGE_DIR := $(APP_BUILD_DIR)/stage
APP_RPM_VENV := $(APP_RPM_STAGE_DIR)/opt/pf9/$(APP_NAME)
APP_ARCHITECTURE := noarch
APP_RPM := $(APP_RPM_DIR)/$(APP_NAME)-$(FULL_VERSION).noarch.rpm
APP_SPEC_FILE := $(APP_BUILD_DIR)/$(APP_NAME)-rpm.spec
############################################################
${APP_RPM_DIR}:
mkdir -p $@
${APP_RPM_STAGE_DIR}:
mkdir -p $@
${APP_RPM_VENV}:
mkdir -p $@
virtualenv $@
$@/bin/pip install ${SRC_DIR}
stage: $(APP_RPM_DIR) $(APP_RPM_STAGE_DIR) $(APP_RPM_VENV)
cp -r $(SRC_DIR)/etc/ $(APP_RPM_STAGE_DIR)/
${APP_RPM}: stage
echo "RPM build "
fpm -t rpm \
-s dir \
-n $(APP_NAME) \
--description $(APP_DESC) \
--version $(PF9_VERSION) \
--iteration $(BUILD_NUMBER) \
--provides $(APP_NAME) \
--provides pf9app \
--license "Commercial" \
--architecture $(APP_ARCHITECTURE) \
--url "http://www.platform9.net" \
--vendor Platform9 \
-p $@ \
-C $(APP_RPM_STAGE_DIR) . && \
$(SRC_DIR)/support/sign_packages.sh ${APP_RPM}
clean:
rm -rf $(BUILD_DIR)
all: clean $(APP_RPM)

9
support/mors.expect Normal file
View File

@ -0,0 +1,9 @@
#!/usr/bin/expect
set timeout 15
spawn bash -c "rpm --resign $argv"
match_max 100000
expect -exact "Enter pass phrase: "
send -- "\r"
expect "Pass phrase is good."
expect eof

5
support/sign_packages.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/sh
if [ "x${SIGN_PACKAGES}" = "x1" ]; then
expect $(dirname $0)/mors.expect $@
fi

19
test.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Copyright (c) Platform9 systems. All rights reserved
output_dir=./build
log_filter=-paramiko.transport
setup_venv() {
virtualenv ${output_dir}/venv
source ${output_dir}/venv/bin/activate
pip install -e .
}
run_tests() {
python ./test/run_tests.py --verbose --with-xunit --xunit-file=${output_dir}/test_output.xml \
--logging-clear-handlers ${exclude} ${nocapture} --logging-filter=${log_filter} ${module} \
--logging-format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s'
}
setup_venv
run_tests

14
test/api-paste.ini Normal file
View File

@ -0,0 +1,14 @@
[app:myService]
paste.app_factory = mors.mors_wsgi:app_factory
[pipeline:main]
pipeline = myService
[filter:authtoken]
paste.filter_factory = keystonemiddleware.auth_token:filter_factory
auth_host = 127.0.0.1
auth_port = 35357
auth_protocol = http
admin_token = RzvUwrEgiOaQFTXV
auth_uri = http://127.0.0.1:8080/keystone
identity_uri = http://127.0.0.1:8080/keystone_admin

16
test/pf9-mors.ini Normal file
View File

@ -0,0 +1,16 @@
[DEFAULT]
db_conn=sqlite+pysqlite:///test/test.db
context_factory=test
lease_handler=test
listen_port=8989
sleep_seconds=3
paste-ini=test/api-paste.ini
log_file=build/test.log
[nova]
user_name=rparikh@platform9.net
password=asdsdsadf
version=2
auth_url=https://pf9.platform9.net/v2/keystone
region_name=RegionOne

15
test/run_tests.py Normal file
View File

@ -0,0 +1,15 @@
# Copyright (c) 2016 Platform9 Systems Inc.
# All Rights reserved
def run_tests():
from proboscis import TestProgram
import test_api
# Run Proboscis and exit.
print "Starting tests ---"
TestProgram().run_and_exit()
print "Tests done ---"
if __name__ == '__main__':
print "Run tests"
run_tests()

236
test/test_api.py Normal file
View File

@ -0,0 +1,236 @@
# Copyright (c) 2016 Platform9 Systems Inc.
# All Rights reserved
from migrate.versioning.api import upgrade, create, version_control
import ConfigParser, os
import requests
import eventlet
from pf9_mors import start_server
from mors.mors_wsgi import DATE_FORMAT
import logging, sys
from datetime import datetime, timedelta
from proboscis.asserts import assert_equal
from proboscis.asserts import assert_false
from proboscis.asserts import assert_raises
from proboscis.asserts import assert_true
from proboscis import SkipTest
from proboscis import test
import shutil
from mors.leasehandler.fake_lease_handler import FakeLeaseHandler
try:
import http.client as http_client
except ImportError:
# Python 2
import httplib as http_client
http_client.HTTPConnection.debuglevel = 1
root = logging.getLogger()
root.setLevel(logging.DEBUG)
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(logging.DEBUG)
root.addHandler(ch)
logger = logging.getLogger(__name__)
eventlet.monkey_patch()
conf = None
headers = {
'X-User-Id': 'asdfsd-asdf-sdadf',
'X-User': 'roopak@pf9.com',
'X-roles': 'admin,_member_',
'X-Tenant-Id': 'poioio-oio-oioo'
}
tenant_id1 = "tenantid-1"
tenant_id2 = "tenantid-2"
instance_id1 = "instanceid-1-t-1"
instance_id2 = "instanceid-2-t-1"
instance_id3 = "instanceid-3-t-2"
expiry_day1 = 4
port = 8080
def _setup_lease_handler():
fakeLeaseHandler = FakeLeaseHandler(conf)
now = datetime.now()
dt = timedelta(days=3)
creation_time = now - dt
t1_vms = [{'instance_uuid': 'instance-123-t1', 'tenant_uuid': tenant_id1, 'created_at': creation_time},
{'instance_uuid': 'instance-456-t1', 'tenant_uuid': tenant_id1, 'created_at': now},
{'instance_uuid': instance_id1, 'tenant_uuid': tenant_id1, 'created_at': now},
{'instance_uuid': instance_id2, 'tenant_uuid': tenant_id1, 'created_at': now}]
fakeLeaseHandler.add_tenant_data(tenant_id1, t1_vms)
t2_vms = [{'instance_uuid': 'instance-123-t2', 'tenant_uuid': tenant_id2, 'created_at': creation_time},
{'instance_uuid': 'instance-456-t2', 'tenant_uuid': tenant_id2, 'created_at': now},
{'instance_uuid': instance_id3, 'tenant_uuid': tenant_id2, 'created_at': now}]
fakeLeaseHandler.add_tenant_data(tenant_id2, t2_vms)
@test
def initialize():
global conf
global port
if os.path.exists("./sqlite+pysqlite:"):
shutil.rmtree("./sqlite+pysqlite:")
if os.path.exists("./test/test.db"):
os.remove("./test/test.db")
conf = ConfigParser.ConfigParser()
conf.readfp(open("test/pf9-mors.ini"))
#create(conf.get("DEFAULT", "db_conn"), "./mors_repo")
version_control(conf.get("DEFAULT", "db_conn"), "./mors_repo")
upgrade(conf.get("DEFAULT", "db_conn"), "./mors_repo")
port = conf.get("DEFAULT", "listen_port")
_setup_lease_handler()
api_paste_file = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'api-paste.ini')
eventlet.greenthread.spawn(start_server, conf, api_paste_file)
eventlet.greenthread.sleep(5)
@test(depends_on=[initialize])
def test_create_tenant():
r = requests.post('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1,
json={"vm_lease_policy": {"tenant_uuid": tenant_id1, "expiry_days": expiry_day1}},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_create_tenant])
def test_update_tenant():
r = requests.put('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1,
json={"vm_lease_policy": {"tenant_uuid": tenant_id1, "expiry_days": 3}}, headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_update_tenant])
def test_get_all_tenants():
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/',
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_get_all_tenants])
def test_get_tenant():
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1, headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_get_tenant])
def test_create_tenant_neg():
# Try creating again and it should result in error
r = requests.post('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1,
json={"vm_lease_policy": {"tenant_uuid": tenant_id1, "expiry_days": expiry_day1}},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 409)
@test(depends_on=[test_create_tenant_neg])
def test_create_instance():
# Now test the instance manipulation
expiry = datetime.utcnow()
expiry_str = datetime.strftime(expiry, DATE_FORMAT)
r = requests.post('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1 + '/instance/' + instance_id1,
json={"instance_uuid": instance_id1, "expiry": expiry_str},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_create_instance])
def test_get_instance():
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1 + '/instance/' + instance_id1,
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_get_instance])
def test_deleted_instance():
eventlet.greenthread.sleep(50)
# The instance lease should be deleted by now
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1 + '/instance/' + instance_id1,
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 404)
@test(depends_on=[test_deleted_instance])
def test_create_instance2():
# Now test the instance manipulation
expiry = datetime.utcnow()
expiry_str = datetime.strftime(expiry, DATE_FORMAT)
r = requests.post('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1 + '/instance/' + instance_id2,
json={"instance_uuid": instance_id2, "expiry": expiry_str},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_create_instance2])
def test_delete_instance_lease():
r = requests.delete('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id1 + '/instance/' + instance_id2,
json={"tenant_uuid": tenant_id1, "instance_uuid": instance_id2},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_deleted_instance])
def test_create_tenant2():
r = requests.post('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id2,
json={"vm_lease_policy": {"tenant_uuid": tenant_id2, "expiry_days": expiry_day1}},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_create_tenant2])
def test_create_instance3():
# Now test the instance manipulation
expiry = datetime.utcnow()
expiry_str = datetime.strftime(expiry, DATE_FORMAT)
r = requests.post('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id2 + '/instance/' + instance_id3,
json={"tenant_uuid": tenant_id2, "instance_uuid": instance_id3, "expiry": expiry_str},
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_create_instance3])
def test_delete_tenant2():
r = requests.delete('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id2,
json={"tenant_uuid": tenant_id2}, headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)
@test(depends_on=[test_delete_tenant2])
def test_get_tenant2():
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id2, headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 404)
@test(depends_on=[test_delete_tenant2])
def test_get_instance3():
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id2 + '/instance/' + instance_id3,
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 404)
@test(depends_on=[test_get_instance3])
def test_get_all_instances_for_tenant2():
r = requests.get('http://127.0.0.1:' + port + '/v1/tenant/' + tenant_id2 + '/instances/',
headers=headers)
logger.debug(r.text)
assert_equal(r.status_code, 200)

56
test/test_persistence.py Normal file
View File

@ -0,0 +1,56 @@
from mors.persistence import DbPersistence
import pytest
from migrate.versioning.api import upgrade,create,version_control
from datetime import datetime
import os
db_persistence = None
TEST_DB="test/test_db11"
def setup_module(mod):
global db_persistence
DB_URL = "sqlite:///"+TEST_DB
create(DB_URL, "./mors_repo")
version_control(DB_URL, "./mors_repo")
upgrade(DB_URL,"./mors_repo")
db_persistence = DbPersistence(DB_URL)
return db_persistence
def teardown_module(mod):
os.unlink(TEST_DB)
def test_apis():
tenant_id = "aasdsadfsadf"
tenant_user1 = "a@xyz.com"
expiry_day1 = 3
tenant_created_date = datetime.utcnow()
db_persistence.add_tenant_lease(tenant_id, expiry_day1, tenant_user1, tenant_created_date)
t_lease = db_persistence.get_tenant_lease(tenant_id)
assert (t_lease.tenant_uuid == tenant_id)
assert (t_lease.created_by == tenant_user1)
assert (t_lease.created_at == tenant_created_date)
# Now try update
tenant_user2 = "b@xyz.com"
tenant_updated_date = datetime.utcnow()
db_persistence.update_tenant_lease(tenant_id, expiry_day1, tenant_user2, tenant_updated_date)
t_lease = db_persistence.get_tenant_lease(tenant_id)
assert (t_lease.tenant_uuid == tenant_id)
assert (t_lease.created_by == tenant_user1)
assert (t_lease.updated_by == tenant_user2)
assert (t_lease.created_at == tenant_created_date)
assert (t_lease.updated_at == tenant_updated_date)
# Instance lease now
instance_uuid = "asdf2-2342-23423"
now = datetime.utcnow()
db_persistence.add_instance_lease(instance_uuid, tenant_id, now, tenant_user1, now)
i_lease = db_persistence.get_instance_lease(instance_uuid, tenant_id)
assert (i_lease.instance_uuid == instance_uuid)
assert (i_lease.tenant_uuid == tenant_id)
assert (i_lease.expiry == now)
assert (i_lease.created_by == tenant_user1)