Mini Shell
Direktori : /opt/sharedrads/ |
|
Current File : //opt/sharedrads/move_generator.py |
#!/opt/imh-python/bin/python3
"""Disk Move Generator - generates disk move tickets
according to arguments and exclusions"""
from operator import itemgetter
from platform import node
import datetime
import argparse
import sys
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Union
import arrow
import yaml
from tabulate import tabulate
import rads
EXCLUSION_LIST = Path('/var/log/disk_exclude')
TIMER = 30 # days before a user is removed from the exclusion list
def get_args():
"""Parse arguments"""
parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group()
parser.add_argument(
'-a',
'--add',
type=str,
dest='add_user',
nargs='+',
default=[],
help="Add user to exclusion list. Entries survive 30 days",
)
parser.add_argument(
'-m',
'--min',
type=int,
default=8,
help="Minimum size of account to migrate in GB, default 8",
)
parser.add_argument(
'-x', '--max', type=int, help="Maximum size of account to migrate in GB"
)
parser.add_argument(
'-t',
'--total',
type=int,
help="Lists several eligible accounts whose size totals up to X GB",
)
group.add_argument(
'-e',
'--exclude',
type=str,
dest='exc_user',
nargs='+',
default=[],
help="List of users to exclude alongside exclusion list",
)
group.add_argument(
'-n',
'--noexclude',
action="store_true",
help="Do not use exclusion list",
)
parser.add_argument(
'-l',
'--listaccount',
action="store_true",
help="Print list of eligible accounts",
)
parser.add_argument(
'-d',
'--ticket',
action="store_true",
help="Email eligible accounts to the disk moves queue",
)
args = parser.parse_args()
# one of l, d, or a must be picked
if args.ticket is False and args.listaccount is False and not args.add_user:
print(
"--ticket (-d), --listaccount (-l),",
"or --add (-a) [user] is required",
)
sys.exit(1)
return args
def get_user_owners():
"""Parse /etc/trueuserowners"""
with open('/etc/trueuserowners', encoding='utf-8') as handle:
user_owners = yaml.load(handle, rads.DumbYamlLoader)
if user_owners is None:
return {}
return user_owners
def main():
args = get_args()
refresh_exclusion_list()
# If user to be added to exclusion list
if args.add_user:
# and list or email also selected
if args.listaccount or args.ticket:
print("Adding to exclusion list first...")
for user in args.add_user:
if not rads.is_cpuser(user):
print(f"{user} is not a valid cpanel user")
args.add_user.remove(user)
add_excluded_users(args.add_user)
if args.listaccount or args.ticket:
# collect a list of lists containing the eligible users
# and the total size
accounts = collect_accounts(
args.min, args.max, args.total, args.noexclude, args.exc_user
)
if args.listaccount:
list_accounts(accounts)
if args.ticket:
email_accounts(accounts)
return args
def get_exclusion_list() -> dict:
'''Read from the exclusion list and return it as a dict'''
data = {}
try:
with open(EXCLUSION_LIST, encoding='ascii') as exclusionlist:
data: dict = yaml.load(exclusionlist)
if not isinstance(data, dict):
print("Error in exclusion list, rebuilding")
data = {}
write_exclusion_list(data)
except (yaml.YAMLError, OSError) as exc:
print(type(exc).__name__, exc, sep=': ')
print('Recreating', EXCLUSION_LIST)
write_exclusion_list(data)
return data
def add_excluded_users(users: list[str]):
'''Format user information and timestamp for the exclusion list'''
for user in users:
exclusion_list = get_exclusion_list()
exclusion_list[user] = arrow.now().int_timestamp
write_exclusion_list(exclusion_list)
print(f"{user} added to exclusion list")
def write_exclusion_list(exclusion_list: dict[str, int]) -> None:
'''Write to the exclusion list'''
try:
with open(EXCLUSION_LIST, 'w', encoding='ascii') as outfile:
yaml.dump(exclusion_list, outfile, indent=4)
except Exception:
pass
def refresh_exclusion_list() -> None:
'''If a timeout has expired, remove the user'''
try:
timeouts = get_exclusion_list()
new_dict = {}
for user in timeouts:
if arrow.now().int_timestamp - timeouts[user] < int(
datetime.timedelta(days=TIMER).total_seconds()
):
new_dict[user] = timeouts[user]
write_exclusion_list(new_dict)
except Exception:
pass
def initial_disqualify(
user: str,
*,
min_size: int,
max_size: int,
noexclude: bool,
exclusion_list: list[str],
) -> tuple[str, Union[float, None]]:
'''Run the user through the first gamut to determine if
eligible for a move'''
try:
# knock out ineligible accounts
if rads.cpuser_safe(user):
return user, None
if Path('/var/cpanel/suspended', user).is_file():
return None
if not noexclude and user in exclusion_list:
return user, None
# get size
size_gb: float = rads.QuotaCtl().getquota(user) / 2**30
# check for eligibility based on size
if size_gb < min_size:
return user, None
if max_size and size_gb > max_size:
return user, None
# whatever's left after that, add to accounts list
return user, size_gb
except KeyboardInterrupt:
# drop child proc if killed
return user, None
def collect_accounts(
min_size: int,
max_size: int,
total_gb: int,
noexclude: bool,
exclude: list[str],
) -> list[tuple[str, str, float]]:
'''Get a list of users, and then eliminate them based on suspension
status, size, and eligibility based on options provided'''
# initializing everything
size_total = 0
accounts = []
eligible_accounts = []
final_list = []
# gather exclusion lists
try:
exclusion_list = list(get_exclusion_list().keys())
except Exception as exc:
print(f"Skipping exception file - {type(exc).__name__}: {exc}")
exclusion_list = []
exclusion_list += exclude
# create child processes to run through the eligibility checks
kwargs = dict(
min_size=min_size,
max_size=max_size,
noexclude=noexclude,
exclusion_list=exclusion_list,
)
accounts = []
user_owners = get_user_owners()
with ThreadPoolExecutor(max_workers=4) as pool:
try:
jobs = []
for user, owner in user_owners.items():
jobs.append(pool.submit(initial_disqualify, user, **kwargs))
for future in as_completed(jobs):
user, size_gb = future.result()
if size_gb is not None:
owner = user_owners[user]
accounts.append((user, owner, size_gb))
except KeyboardInterrupt:
print("Caught KeyboardInterrupt.")
pool.shutdown(wait=False, cancel_futures=True)
return []
if not accounts:
return final_list
# if anything survived those criteria...
accounts.sort(key=itemgetter(2), reverse=True) # sort by size, descending
# get a list of accounts of size > total
if total_gb:
size_total = 0
for account in accounts:
if len(eligible_accounts) < 3 or size_total < total_gb:
eligible_accounts.append(account)
size_total += account[2]
else:
break
accounts = eligible_accounts
final_list = accounts[:25]
return final_list
def list_accounts(accounts):
'''Print the list of eligible accounts in a pretty table'''
if not accounts:
print("No accounts match criteria.")
return
print(
tabulate(
reversed(accounts),
headers=["User", "Owner", "Size (GB)"],
floatfmt=".1f",
)
)
if total := sum(x[2] for x in accounts):
print(f"Total size of matching accounts: {total:.2f} GB")
def email_accounts(accounts: list[tuple[str, str, float]]):
'''Send an email for each user in accounts'''
server = node().split(".")[0]
exclude = []
for user, _, size_gb in accounts:
mail_disk_move(user, server, size_gb)
exclude.append(user)
add_excluded_users(exclude)
def mail_disk_move(username: str, server: str, size_gb: float):
'''Sends email to the disk moves queue'''
to_addr = "moves@imhadmin.net"
subject = f"DISK MOVE: {username} @ {server}"
body = f"""
A new server disk move is required for {username} @ {server}
Move Username: {username}
Account Size: {size_gb} GiB
Please review the account to determine if they are eligible for a migration:
* hasn't been moved recently (e.g. in the past year/no current move scheduled)
* is not storing ToS content
* is not large to the point of absurdity
* other reasons left to the discretion of the administrator
If the account is not eligible for a migration, please add them to the
exception list to prevent further tickets being generated for their account:
move_generator.py -a {username}
If the account is not eligible for reasons of ToS content, please respond
to this ticket with the relevant information and leave it open to address
further. For convenience, you may also update the subject line with the
date on which this should be addressed again and/or notice count.
"""
if rads.send_email(to_addr, subject, body):
print(f"Disk move tickets sent for {username}.")
else:
print(
"Sending of disk_move ticket failed. You can use the -l option to",
"view eligible accounts to move",
)
if __name__ == "__main__":
main()
Zerion Mini Shell 1.0