"""
Robot to be run periodically from cron and handle RT user creation
tickets that come from the website. This semi-automates the steps
of creating the account including checking with the PI, adding the
user to various systems, emailing the new user, and updating the
ticket.
Account creation is run as a pipeline repurposing the ticket
priority number to indicate the stage of each new account request.
The stages are given in the pipeline description below:
"""
from datetime import datetime
import json
import logging
import re
import subprocess
import sys
import urllib3
from accre.account_management import (
VUNetIDValidator,
ClusterAccountCreator,
repair_affiliate_group_membership,
repair_ldap_group_membership,
repair_user_slurm_associations,
repair_user_slurm_acc_associations
)
from accre.config import get_config
from accre.database import VandyAdminDBClient
from accre.exceptions import ACCREError
from accre.logger import get_logger
from accre.rt_requests import Requestor
# Description of the creation pipeline to be added to each ticket and
# to document the pipeline stages. If the pipeline is changed make sure
# that this description matches the actual pipeline executed in the
# NewUser class below.
PIPELINE_DESCRIPTION = """\
* Priority 0 : New ticket
* Priority 1 : VUNetID verified and cluster account does not exist
* Priotity 2 : Sent PI verification message, awaiting response from PI
* Priority 3 : Ready to be added to the database
* Priority 4 : Username added to database, ready for cluster account creation
* Priority 5 : Account created in POSIX. Ready for secondary group addition
and slurm associations
* Priority 6 : Account fully created, ready to be updated in RT
* Priority 7 : Username updated in RT, ticket is ready to be closed
* Priority -1 : An error has occurred and manual intervention is required.
After fixing the priority can be set back to a positive integer and
the automated process may resume.
Note that ACCRE staff must manually change ticket priority to 3 after
verification for account creation to begin.
For priority 0 the ticket status should be 'new', for all others 'open',
otherwise the account creation bot will ignore a ticket.
"""
__doc__ += PIPELINE_DESCRIPTION
CONFIG = get_config()
LOGGER = get_logger()
[docs]def run_account_creator():
"""
Function or CLI endpoint to run the account creator, searching through
all RT tickets and performing appropriate steps in the account creation
pipeline.
To simply and rapidly process account requests, each pipeline
stage is executed as a separate task in sequence and tickets are re-read
each time.
"""
# Suppress certificate warnings from RT (TODO: fix the cert!)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
for stage in range(0, 8):
for user in retrieve_new_users(stage=stage):
try:
user.process_stage(stage)
except Exception:
LOGGER.exception(
'Failed processing user request for %s at stage %d',
user.vunetid,
stage
)
[docs]def retrieve_new_users(stage):
"""
Search all tickets for the given stage, and return a list of NewUser
objects from the ticket data matching the given pipeline stage.
:param int stage: Pipeline stage to check
:returns: list of new user requests
:rtype: list(NewUser)
"""
requestor = Requestor()
status = 'open'
owner = CONFIG['account-management']['rt-user']
queue = CONFIG['account-management']['rt-queue']
if stage == 0:
owner = 'Nobody'
status = 'new'
query = (
"Status = '{0}' AND Priority = '{1}' AND "
"Queue = '{2}' AND Owner = '{3}'"
.format(status, stage, queue, owner)
)
ticket_ids = list(requestor.search_tickets(
query=query,
filter_fun=lambda t: t[1].startswith('ACCRE New User:')
))
new_users = []
for ticket_id in ticket_ids:
try:
new_users.append(
NewUser(**requestor.ticket_account_bot_json(ticket_id))
)
except Exception:
LOGGER.exception(
"New user not extracted from ticket {0}".format(ticket_id)
)
return new_users
[docs]class NewUser:
"""
Stores information about the new user and performs each step of the
user creation pipeline
"""
def __init__(self, name, vunetid, email, group, ticket):
self.name = name
self.vunetid = vunetid
self.email = email
self.group = group
self.ticket = ticket
self.requestor = Requestor()
[docs] def process_stage(self, stage):
"""
Dispatch method for each pipeline stage
"""
LOGGER.info('Processing Stage %d account creation for %s ticket %s',
stage, self.vunetid, self.ticket
)
if stage == 0:
self.add_pipeline_description()
self.verify_account(stage)
elif stage == 1:
self.verify_pi(stage)
elif stage == 2:
self.maybe_remind_pi(stage)
elif stage == 3:
self.add_to_database(stage)
elif stage == 4:
self.create_cluster_account(stage)
elif stage == 5:
self.initial_account_repair(stage)
elif stage == 6:
self.update_rt_username(stage)
elif stage == 7:
self.close_ticket(stage)
else:
LOGGER.error('Invalid stage %d account creation for %s ticket %s',
stage, self.vunetid, self.ticket
)
[docs] def add_pipeline_description(self):
"""
Adds a comment to the ticket with a description of the creation
pipeline and instructions to the administrator.
"""
msg = (
"The account creation bot has recognized this ticket as a new "
"account request with the following user information: \n"
"name: {0}\n"
"vunetid: {1}\n"
"email: {2}\n"
"group: {3}\n\n"
"This ticket will now be processed by priority setting "
"according to the following pipeline:\n\n"
.format(self.name, self.vunetid, self.email, self.group)
)
msg += PIPELINE_DESCRIPTION
self.requestor.ticket_add_comment(self.ticket, msg)
[docs] def verify_account(self, stage):
"""
Verify that this user is not already in the database and has an active
VUNetID or robot equivalent and that the email matches
"""
with VUNetIDValidator() as client:
if not client.exists(self.vunetid):
self.fail_and_report(
'{0} is not a valid VUNetID'.format(self.vunetid)
)
return
if client.is_locked(self.vunetid):
self.fail_and_report(
'{0} is a locked VUNetID'.format(self.vunetid)
)
vunet_email = client.info(self.vunetid)['vanderbilt_email']
dbclient = VandyAdminDBClient()
if dbclient.user_exists(self.vunetid):
self.requestor.ticket_add_reply(
self.ticket,
'It appears you already have an account on the ACCRE cluster. '
'Please reply to this email if you need help logging in '
'or a password reset.'
)
self.fail_and_report(
'{0} is already an ACCRE user.'.format(self.vunetid)
)
return
msg = (
'{0} ({1}) is not a current cluster user and has an active '
'VUNetID, so you may proceed with the '
'account creation assuming this VUNetID is valid for this person.'
' The email associated with this VUNetID is {2}.'
.format(self.name, self.vunetid, vunet_email)
)
self.requestor.ticket_add_comment(self.ticket, msg)
self.requestor.ticket_change_owner(
self.ticket,
CONFIG['account-management']['rt-user']
)
self.requestor.ticket_change_priority(self.ticket, stage + 1)
self.requestor.ticket_change_status(self.ticket, "open")
[docs] def verify_pi(self, stage):
"""
Request approval from the PI
"""
LOGGER.info("Requesting approval from PI!")
self.requestor.ticket_add_reply(
self.ticket,
'Dear faculty approver for {name},\n\n '
'Please reply to this email (a simple yes will suffice) '
'confirming that {name} is approved for a new user '
'account in the {group} ACCRE group and covered under '
'the terms of your Disclosure '
'for this group.'.format(name=self.name, group=self.group)
)
LOGGER.info(
"Changing priority of ticket to {0}, "
"indicating that it is awaiting approval from the PI."
.format(stage + 1)
)
self.requestor.ticket_change_priority(self.ticket, stage + 1)
[docs] def maybe_remind_pi(self, stage):
"""
Remind the PI to decide on approving the user if the ticket
has not been updated in two days
"""
# Get days since last update
tp = self.requestor.ticket_properties(self.ticket)
last_update = tp["LastUpdated"]
start = datetime.strptime(last_update, '%a %b %d %H:%M:%S %Y')
delta = datetime.now() - start
days = delta.total_seconds() / (3600.0 * 24.0) # convert to days
# Get last ticket responder
ticket_histories = self.requestor.ticket_history(self.ticket)
last_response_string = ""
for k in ticket_histories:
for v in ticket_histories[k].splitlines():
if "Correspondence added" in v:
# keep searching to get last response
last_response_string = v
owner = CONFIG['account-management']['rt-user']
if days > 2.0 and owner in last_response_string:
LOGGER.info("Sending reminder re: {0} to PI!".format(self.ticket))
self.requestor.ticket_add_reply(
self.ticket,
'Hello, this request still awaits your approval (see below). '
'Thank you.\n\n '
'Dear faculty approver for {name},\n\n '
'Please reply "YES" to this email confirming '
'that {name} is approved for a new user account '
'in the {group} ACCRE group and covered under the terms '
'of your Disclosure for this group.'
.format(name=self.name, group=self.group)
)
[docs] def add_to_database(self, stage):
"""
Add the new user to the database along with any secondary
groups specified.
"""
LOGGER.info('Adding new user %s to the database', self.vunetid)
try:
with VUNetIDValidator() as client:
uid = client.info(self.vunetid)['uid']
dbclient = VandyAdminDBClient()
dbclient.add_user(
vunetid=self.vunetid,
user_id=uid,
group=self.group,
email=self.email,
first_name=self.name.split()[0],
last_name=self.name.split()[-1]
)
except Exception as e:
LOGGER.exception('Exception adding %s to the DB', self.vunetid)
self.fail_and_report(
'Failed to add {0} to the database: {1}'
.format(self.vunetid, str(e))
)
return
msg = 'Created database account for new user'
self.requestor.ticket_add_comment(self.ticket, msg)
self.requestor.ticket_change_priority(self.ticket, stage + 1)
[docs] def create_cluster_account(self, stage):
"""
Initial creation of the POSIX account
"""
LOGGER.info('Creating POSIX account for %s', self.vunetid)
try:
cac = ClusterAccountCreator(
vunetid=self.vunetid,
ticket=self.ticket
)
result = cac.create_cluster_account()
if result['error']:
self.fail_and_report(
'Problem creating POSIX account for {0}: {1}'
.format(self.vunetid, result)
)
return
except Exception as e:
LOGGER.exception(
'Failure creating POSIX account for %s',
self.vunetid
)
self.fail_and_report(
'Failure creating POSIX account for {0}: {1}'
.format(self.vunetid, str(e))
)
return
msg = 'Created POSIX account for new user'
self.requestor.ticket_add_comment(self.ticket, msg)
self.requestor.ticket_change_priority(self.ticket, stage + 1)
[docs] def initial_account_repair(self, stage):
"""
Perform initial account repairs, i.e. add secondary groups and
required slurm associations.
"""
LOGGER.info(
'Adding secondary groups to DB from affiliates for %s',
self.vunetid
)
try:
result = repair_affiliate_group_membership(self.vunetid)
msg = 'Repaired affiliate groups: {0}'.format(result)
self.requestor.ticket_add_comment(self.ticket, msg)
except Exception as e:
LOGGER.exception('Failed to add affiliate groups')
self.fail_and_report('Failed to add affiliate groups')
return
LOGGER.info('Adding secondary groups to LDAP for %s', self.vunetid)
try:
result = repair_ldap_group_membership(self.vunetid)
msg = 'Repaired secondary groups: {0}'.format(result)
self.requestor.ticket_add_comment(self.ticket, msg)
except Exception as e:
LOGGER.exception('Failed to repair secondary groups')
self.fail_and_report('Failed to repair secondary groups')
return
LOGGER.info("Adding slurm associations for %s", self.vunetid)
try:
result = repair_user_slurm_associations(self.vunetid)
msg = 'Repaired slurm associations: {0}'.format(result)
self.requestor.ticket_add_comment(self.ticket, msg)
except Exception as e:
LOGGER.exception('Failed to repair slurm associations')
self.fail_and_report('Failed to repair slurm associations')
return
LOGGER.info("Adding accel slurm associations for %s", self.vunetid)
try:
result = repair_user_slurm_acc_associations(self.vunetid)
msg = 'Repaired accel slurm associations: {0}'.format(result)
self.requestor.ticket_add_comment(self.ticket, msg)
except Exception as e:
LOGGER.exception('Failed to repair accel slurm associations')
self.fail_and_report('Failed to repair accel slurm associations')
return
self.requestor.ticket_change_priority(self.ticket, stage + 1)
[docs] def update_rt_username(self, stage):
"""
Change new user's RT name from their email to their vunetid
"""
LOGGER.info("Updating username in RT: {0}".format(self.ticket))
try:
response = self.requestor.update_username(self.email, self.vunetid)
except Exception as e:
msg = 'Failed to connect to RT for username change'
LOGGER.exception(msg)
self.fail_and_report(msg)
return
if '200 Ok' not in response:
self.fail_and_report(
'Failed to update username, RT response: {0}'.format(response)
)
return
fmt_response = "Output from RT username update:\n{0}".format(response)
self.requestor.ticket_add_comment(self.ticket, fmt_response)
self.requestor.ticket_change_priority(self.ticket, stage + 1)
[docs] def close_ticket(self, stage, threshold=1.0):
"""
Close the new user ticket if threshold days have passed with no
ticket activity
"""
# Get days since last update
tp = self.requestor.ticket_properties(self.ticket)
last_update = tp["LastUpdated"]
start = datetime.strptime(last_update, '%a %b %d %H:%M:%S %Y')
delta = datetime.now() - start
days = delta.total_seconds() / (3600.0 * 24.0) # convert to days
if days > threshold:
msg = (
"No reply on ticket {0} for at least {1} days. Closing!"
.format(self.ticket, threshold)
)
self.requestor.ticket_change_status(
self.ticket,
'resolved'
)
LOGGER.info(msg)
[docs] def fail_and_report(self, msg):
"""
Log failure messge, add to ticket, set ticket priority to -1 to stop
pipeline
"""
LOGGER.error('Account creation error for %s: %s', self.vunetid, msg)
msg += (
'\n\nPlease manually diagnose and fix this problem, '
'then reset ticket priority to continue the creation pipeline.'
)
self.requestor.ticket_add_comment(self.ticket, msg)
self.requestor.ticket_change_priority(self.ticket, -1)