from collections import defaultdict import datetime import json import os from typing import Dict, List from zope import component from zope.interface import implementer from ceo_common.errors import UserNotFoundError from ceo_common.logger_factory import logger_factory from ceo_common.interfaces import ICloudResourceManager, IUser, \ ILDAPService, IMailService, IKubernetesService, IVHostManager, \ ICloudStackService, IContainerRegistryService from ceo_common.model import Term import ceo_common.utils as utils logger = logger_factory(__name__) @implementer(ICloudResourceManager) class CloudResourceManager: def __init__(self): state_dir = '/run/ceod' if not os.path.isdir(state_dir): os.mkdir(state_dir) self.pending_deletions_file = \ os.path.join(state_dir, 'pending_account_deletions.json') @staticmethod def _should_not_have_resources_deleted(user: IUser) -> bool: return not user.is_member() or user.membership_is_valid() def _get_resources_for_each_user(self) -> Dict[str, Dict]: """ Get a list of cloud resources each user is using. The returned dict looks like { "ctdalek": { "resources": ["cloudstack", "k8s", ...], "cloudstack_account_id": "3452345-2453245-23453..." }, ... } The "cloudstack_account_id" key will only be present if the user has a CloudStack account. """ k8s_srv = component.getUtility(IKubernetesService) vhost_mgr = component.getUtility(IVHostManager) cloudstack_srv = component.getUtility(ICloudStackService) reg_srv = component.getUtility(IContainerRegistryService) accounts = defaultdict(lambda: {'resources': []}) cloudstack_accounts = cloudstack_srv.get_accounts() # note that cloudstack_accounts is a dict, not a list for username, account_id in cloudstack_accounts.items(): accounts[username]['resources'].append('cloudstack') accounts[username]['cloudstack_account_id'] = account_id vhost_accounts = vhost_mgr.get_accounts() for username in vhost_accounts: accounts[username]['resources'].append('vhost') k8s_accounts = k8s_srv.get_accounts() for username in k8s_accounts: accounts[username]['resources'].append('k8s') reg_accounts = reg_srv.get_accounts() for username in reg_accounts: accounts[username]['resources'].append('registry') return accounts def _perform_deletions( self, state: Dict, accounts: Dict[str, Dict], accounts_deleted: List[str], ): ldap_srv = component.getUtility(ILDAPService) mail_srv = component.getUtility(IMailService) k8s_srv = component.getUtility(IKubernetesService) vhost_mgr = component.getUtility(IVHostManager) cloudstack_srv = component.getUtility(ICloudStackService) reg_srv = component.getUtility(IContainerRegistryService) for username in state['accounts_to_be_deleted']: if username not in accounts: continue try: user = ldap_srv.get_user(username) except UserNotFoundError: continue if self._should_not_have_resources_deleted(user): continue resources = accounts[username]['resources'] if 'cloudstack' in resources: account_id = accounts[username]['cloudstack_account_id'] cloudstack_srv.delete_account(username, account_id) if 'vhost' in resources: vhost_mgr.delete_all_vhosts_for_user(username) if 'k8s' in resources: k8s_srv.delete_account(username) if 'registry' in resources: reg_srv.delete_project_for_user(username) accounts_deleted.append(username) mail_srv.send_cloud_account_has_been_deleted_message(user) logger.info(f'Deleted cloud resources for {username}') def _perform_deletions_if_warning_period_passed( self, now: datetime.datetime, accounts: Dict[str, Dict], accounts_deleted: List[str], accounts_to_be_deleted: List[str], ): state = json.load(open(self.pending_deletions_file)) last_check = datetime.datetime.fromtimestamp(state['timestamp']) delta = now - last_check if delta.days < 7: logger.debug( 'Skipping account purge because less than one week has ' 'passed since the warning emails were sent out' ) accounts_to_be_deleted.extend(state['accounts_to_be_deleted']) return self._perform_deletions(state, accounts, accounts_deleted) os.unlink(self.pending_deletions_file) def _in_grace_period(self, now: datetime.datetime) -> bool: current_term = Term.current() beginning_of_term = current_term.to_datetime() delta = now - beginning_of_term # one-month grace period return delta.days < 30 def _send_out_warning_emails( self, now: datetime.datetime, accounts: Dict[str, dict], accounts_to_be_deleted: List[str], ): ldap_srv = component.getUtility(ILDAPService) mail_srv = component.getUtility(IMailService) state = { 'timestamp': int(now.timestamp()), 'accounts_to_be_deleted': accounts_to_be_deleted, } for username in accounts: try: user = ldap_srv.get_user(username) except UserNotFoundError: logger.warning(f'User {username} not found') continue if self._should_not_have_resources_deleted(user): continue accounts_to_be_deleted.append(username) mail_srv.send_cloud_account_will_be_deleted_message(user) logger.info( f'A warning email was sent to {username} because their ' 'cloud account will be deleted' ) if accounts_to_be_deleted: json.dump(state, open(self.pending_deletions_file, 'w')) def _warning_emails_were_sent_out(self) -> bool: return os.path.isfile(self.pending_deletions_file) def purge_accounts(self) -> Dict: accounts_deleted = [] accounts_to_be_deleted = [] result = { 'accounts_deleted': accounts_deleted, 'accounts_to_be_deleted': accounts_to_be_deleted, } now = utils.get_current_datetime() if self._in_grace_period(now): return result # get a list of all cloud services each user is using accounts = self._get_resources_for_each_user() if self._warning_emails_were_sent_out(): self._perform_deletions_if_warning_period_passed( now, accounts, accounts_deleted, accounts_to_be_deleted) return result self._send_out_warning_emails(now, accounts, accounts_to_be_deleted) return result