parent
e14b261805
commit
470b442e4c
@ -0,0 +1,16 @@ |
||||
from typing import Dict |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
|
||||
class ICloudResourceManager(Interface): |
||||
"""Manages multiple cloud resources for each user.""" |
||||
|
||||
def purge_accounts() -> Dict: |
||||
""" |
||||
Delete cloud resources for expired CSC accounts. |
||||
A warning message will be emailed to users one week before their |
||||
cloud account is deleted. |
||||
Another message will be emailed to the users after their cloud account |
||||
has been deleted. |
||||
""" |
@ -1,42 +0,0 @@ |
||||
from typing import Dict, List |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
from .IUser import IUser |
||||
|
||||
|
||||
class ICloudService(Interface): |
||||
"""Performs operations on the CSC Cloud.""" |
||||
|
||||
def create_account(user: IUser): |
||||
""" |
||||
Activate an LDAP account in CloudStack for the given user. |
||||
""" |
||||
|
||||
def purge_accounts() -> Dict: |
||||
""" |
||||
Delete CloudStack accounts which correspond to expired CSC accounts. |
||||
A warning message will be emailed to users one week before their |
||||
cloud account is deleted. |
||||
Another message will be emailed to the users after their cloud account |
||||
has been deleted. |
||||
""" |
||||
|
||||
def create_vhost(username: str, domain: str, ip_address: str): |
||||
""" |
||||
Create a new vhost record for the given domain and IP address. |
||||
""" |
||||
|
||||
def delete_vhost(username: str, domain: str): |
||||
""" |
||||
Delete the vhost record for the given user and domain. |
||||
""" |
||||
|
||||
def get_vhosts(username: str) -> List[Dict]: |
||||
""" |
||||
Get the vhost records for the given user. Each record has the form |
||||
{ |
||||
"domain": "app.username.m.csclub.cloud", |
||||
"ip_address": "172.19.134.12" |
||||
} |
||||
""" |
@ -0,0 +1,26 @@ |
||||
from typing import Dict |
||||
|
||||
from zope.interface import Interface |
||||
|
||||
from .IUser import IUser |
||||
|
||||
|
||||
class ICloudStackService(Interface): |
||||
"""Provides a way to interact with a CloudStack management server.""" |
||||
|
||||
def create_account(user: IUser): |
||||
""" |
||||
Activate an LDAP account in CloudStack for the given user. |
||||
""" |
||||
|
||||
def get_accounts() -> Dict[str, str]: |
||||
""" |
||||
Get the users who have active CloudStack accounts. |
||||
The dict is mapping of usernames to account IDs. |
||||
""" |
||||
|
||||
def delete_account(account_id: str): |
||||
""" |
||||
Delete the given CloudStack account. |
||||
Note that a CloudStack account ID must be given, not a username. |
||||
""" |
@ -0,0 +1,111 @@ |
||||
from collections import defaultdict |
||||
import datetime |
||||
import json |
||||
import os |
||||
from typing import Dict |
||||
|
||||
from zope import component |
||||
from zope.interface import implementer |
||||
|
||||
from ceo_common.logger_factory import logger_factory |
||||
from ceo_common.interfaces import ICloudResourceManager, \ |
||||
ILDAPService, IMailService, IKubernetesService, IVHostManager, \ |
||||
ICloudStackService |
||||
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') |
||||
|
||||
def purge_accounts(self) -> Dict: |
||||
accounts_deleted = [] |
||||
accounts_to_be_deleted = [] |
||||
result = { |
||||
'accounts_deleted': accounts_deleted, |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
|
||||
current_term = Term.current() |
||||
beginning_of_term = current_term.to_datetime() |
||||
now = utils.get_current_datetime() |
||||
delta = now - beginning_of_term |
||||
if delta.days < 30: |
||||
# one-month grace period |
||||
return result |
||||
|
||||
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) |
||||
|
||||
# get a list of all cloud services each user is using |
||||
accounts = defaultdict(list) |
||||
cloudstack_accounts = cloudstack_srv.get_accounts() |
||||
# note that cloudstack_accounts is a dict, not a list |
||||
for username in cloudstack_accounts: |
||||
accounts[username].append('cloudstack') |
||||
vhost_accounts = vhost_mgr.get_accounts() |
||||
for username in vhost_accounts: |
||||
accounts[username].append('vhost') |
||||
k8s_accounts = k8s_srv.get_accounts() |
||||
for username in k8s_accounts: |
||||
accounts[username].append('k8s') |
||||
|
||||
if os.path.isfile(self.pending_deletions_file): |
||||
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 result |
||||
for username in state['accounts_to_be_deleted']: |
||||
if username not in accounts: |
||||
continue |
||||
user = ldap_srv.get_user(username) |
||||
if user.membership_is_valid(): |
||||
continue |
||||
services = accounts[username] |
||||
if 'cloudstack' in services: |
||||
account_id = cloudstack_accounts[username] |
||||
cloudstack_srv.delete_account(account_id) |
||||
if 'vhost' in services: |
||||
vhost_mgr.delete_all_vhosts_for_user(username) |
||||
if 'k8s' in services: |
||||
k8s_srv.delete_account(username) |
||||
accounts_deleted.append(username) |
||||
mail_srv.send_cloud_account_has_been_deleted_message(user) |
||||
logger.info(f'Deleted cloud resources for {username}') |
||||
os.unlink(self.pending_deletions_file) |
||||
return result |
||||
|
||||
state = { |
||||
'timestamp': int(now.timestamp()), |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
for username in accounts: |
||||
user = ldap_srv.get_user(username) |
||||
if user.membership_is_valid(): |
||||
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')) |
||||
return result |
@ -1,232 +0,0 @@ |
||||
from base64 import b64encode |
||||
import datetime |
||||
import hashlib |
||||
import hmac |
||||
import json |
||||
import os |
||||
from typing import Dict, List |
||||
from urllib.parse import quote |
||||
|
||||
import requests |
||||
from zope import component |
||||
from zope.interface import implementer |
||||
|
||||
from .VHostManager import VHostManager |
||||
from ceo_common.errors import CloudStackAPIError, InvalidDomainError, \ |
||||
InvalidIPError, RateLimitError |
||||
from ceo_common.logger_factory import logger_factory |
||||
from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \ |
||||
IMailService, IKubernetesService |
||||
from ceo_common.model import Term |
||||
import ceo_common.utils as utils |
||||
|
||||
logger = logger_factory(__name__) |
||||
|
||||
|
||||
@implementer(ICloudService) |
||||
class CloudService: |
||||
def __init__(self): |
||||
cfg = component.getUtility(IConfig) |
||||
self.api_key = cfg.get('cloudstack_api_key') |
||||
self.secret_key = cfg.get('cloudstack_secret_key') |
||||
self.base_url = cfg.get('cloudstack_base_url') |
||||
self.members_domain = 'Members' |
||||
|
||||
self.vhost_mgr = VHostManager() |
||||
self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account') |
||||
self.vhost_rate_limit_secs = cfg.get('cloud vhosts_rate_limit_seconds') |
||||
|
||||
state_dir = '/run/ceod' |
||||
if not os.path.isdir(state_dir): |
||||
os.mkdir(state_dir) |
||||
self.pending_deletions_file = os.path.join(state_dir, 'cloudstack_pending_account_deletions.json') |
||||
self.vhost_rate_limit_file = os.path.join(state_dir, 'vhost_rate_limits.json') |
||||
|
||||
def _create_url(self, params: Dict[str, str]) -> str: |
||||
# See https://docs.cloudstack.apache.org/en/latest/developersguide/dev.html#the-cloudstack-api |
||||
if 'apiKey' not in params and 'apikey' not in params: |
||||
params['apiKey'] = self.api_key |
||||
params['response'] = 'json' |
||||
request_str = '&'.join( |
||||
key + '=' + quote(val) |
||||
for key, val in params.items() |
||||
) |
||||
sig_str = '&'.join( |
||||
key.lower() + '=' + quote(val).lower() |
||||
for key, val in sorted(params.items()) |
||||
) |
||||
sig = hmac.new(self.secret_key.encode(), sig_str.encode(), hashlib.sha1).digest() |
||||
encoded_sig = b64encode(sig).decode() |
||||
url = self.base_url + '?' + request_str + '&signature=' + quote(encoded_sig) |
||||
return url |
||||
|
||||
def _get_domain_id(self, domain_name: str) -> str: |
||||
url = self._create_url({ |
||||
'command': 'listDomains', |
||||
'details': 'min', |
||||
'name': domain_name, |
||||
}) |
||||
resp = requests.get(url) |
||||
resp.raise_for_status() |
||||
d = resp.json()['listdomainsresponse'] |
||||
assert d['count'] == 1, 'there should be one domain found' |
||||
return d['domain'][0]['id'] |
||||
|
||||
def _get_all_accounts(self, domain_id: str) -> List[Dict]: |
||||
url = self._create_url({ |
||||
'command': 'listAccounts', |
||||
'domainid': domain_id, |
||||
'details': 'min', |
||||
}) |
||||
resp = requests.get(url) |
||||
resp.raise_for_status() |
||||
d = resp.json()['listaccountsresponse'] |
||||
if 'account' not in d: |
||||
# The API returns an empty dict if there are no accounts |
||||
return [] |
||||
return d['account'] |
||||
|
||||
def _delete_account(self, account_id: str): |
||||
url = self._create_url({ |
||||
'command': 'deleteAccount', |
||||
'id': account_id, |
||||
}) |
||||
resp = requests.post(url) |
||||
resp.raise_for_status() |
||||
|
||||
def create_account(self, user: IUser): |
||||
domain_id = self._get_domain_id(self.members_domain) |
||||
|
||||
url = self._create_url({ |
||||
'command': 'ldapCreateAccount', |
||||
'accounttype': '0', |
||||
'domainid': domain_id, |
||||
'username': user.uid, |
||||
}) |
||||
resp = requests.post(url) |
||||
d = resp.json()['createaccountresponse'] |
||||
if not resp.ok: |
||||
raise CloudStackAPIError(d['errortext']) |
||||
|
||||
def purge_accounts(self) -> Dict: |
||||
accounts_deleted = [] |
||||
accounts_to_be_deleted = [] |
||||
result = { |
||||
'accounts_deleted': accounts_deleted, |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
|
||||
current_term = Term.current() |
||||
beginning_of_term = current_term.to_datetime() |
||||
now = utils.get_current_datetime() |
||||
delta = now - beginning_of_term |
||||
if delta.days < 30: |
||||
# one-month grace period |
||||
return result |
||||
|
||||
ldap_srv = component.getUtility(ILDAPService) |
||||
mail_srv = component.getUtility(IMailService) |
||||
k8s_srv = component.getUtility(IKubernetesService) |
||||
domain_id = self._get_domain_id(self.members_domain) |
||||
accounts = self._get_all_accounts(domain_id) |
||||
|
||||
if os.path.isfile(self.pending_deletions_file): |
||||
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 result |
||||
username_to_account_id = { |
||||
account['name']: account['id'] |
||||
for account in accounts |
||||
} |
||||
for username in state['accounts_to_be_deleted']: |
||||
if username not in username_to_account_id: |
||||
continue |
||||
user = ldap_srv.get_user(username) |
||||
if user.membership_is_valid(): |
||||
continue |
||||
account_id = username_to_account_id[username] |
||||
|
||||
# Delete CloudStack resources |
||||
self._delete_account(account_id) |
||||
# Delete NGINX resources |
||||
self.vhost_mgr.delete_all_vhosts_for_user(username) |
||||
# Delete k8s resources |
||||
k8s_srv.delete_account(username) |
||||
|
||||
accounts_deleted.append(username) |
||||
mail_srv.send_cloud_account_has_been_deleted_message(user) |
||||
logger.info(f'Deleted cloud account for {username}') |
||||
os.unlink(self.pending_deletions_file) |
||||
return result |
||||
|
||||
state = { |
||||
'timestamp': int(now.timestamp()), |
||||
'accounts_to_be_deleted': accounts_to_be_deleted, |
||||
} |
||||
for account in accounts: |
||||
username = account['name'] |
||||
account_id = account['id'] |
||||
user = ldap_srv.get_user(username) |
||||
if user.membership_is_valid(): |
||||
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')) |
||||
return result |
||||
|
||||
def _check_rate_limit(self, username: str): |
||||
if os.path.exists(self.vhost_rate_limit_file): |
||||
d = json.load(open(self.vhost_rate_limit_file)) |
||||
else: |
||||
d = {} |
||||
now = int(utils.get_current_datetime().timestamp()) |
||||
if username not in d: |
||||
return |
||||
time_passed = now - d[username] |
||||
if time_passed < self.vhost_rate_limit_secs: |
||||
time_remaining = self.vhost_rate_limit_secs - time_passed |
||||
raise RateLimitError(f'Please wait {time_remaining} seconds') |
||||
|
||||
def _update_rate_limit_timestamp(self, username: str): |
||||
if os.path.exists(self.vhost_rate_limit_file): |
||||
d = json.load(open(self.vhost_rate_limit_file)) |
||||
else: |
||||
d = {} |
||||
now = int(utils.get_current_datetime().timestamp()) |
||||
d[username] = now |
||||
json.dump(d, open(self.vhost_rate_limit_file, 'w')) |
||||
|
||||
def create_vhost(self, username: str, domain: str, ip_address: str): |
||||
if self.vhost_mgr.get_num_vhosts(username) >= self.max_vhosts_per_account: |
||||
raise Exception(f'Only {self.max_vhosts_per_account} vhosts ' |
||||
'allowed per account') |
||||
if not self.vhost_mgr.is_valid_domain(username, domain): |
||||
raise InvalidDomainError() |
||||
if not self.vhost_mgr.is_valid_ip_address(ip_address): |
||||
raise InvalidIPError() |
||||
self._check_rate_limit(username) |
||||
# Wait for the vhost creation to succeed before updating the timestamp; |
||||
# we don't want to force people to wait if they had a typo in their |
||||
# domain or something. |
||||
self.vhost_mgr.create_vhost(username, domain, ip_address) |
||||
self._update_rate_limit_timestamp(username) |
||||
|
||||
def delete_vhost(self, username: str, domain: str): |
||||
if not self.vhost_mgr.is_valid_domain(username, domain): |
||||
raise InvalidDomainError() |
||||
self.vhost_mgr.delete_vhost(username, domain) |
||||
|
||||
def get_vhosts(self, username: str) -> List[Dict]: |
||||
return self.vhost_mgr.get_vhosts(username) |
@ -0,0 +1,100 @@ |
||||
from base64 import b64encode |
||||
import hashlib |
||||
import hmac |
||||
from typing import Dict |
||||
from urllib.parse import quote |
||||
|
||||
import requests |
||||
from zope import component |
||||
from zope.interface import implementer |
||||
|
||||
from ceo_common.errors import CloudStackAPIError |
||||
from ceo_common.logger_factory import logger_factory |
||||
from ceo_common.interfaces import ICloudStackService, IConfig, IUser |
||||
|
||||
logger = logger_factory(__name__) |
||||
|
||||
|
||||
@implementer(ICloudStackService) |
||||
class CloudStackService: |
||||
def __init__(self): |
||||
cfg = component.getUtility(IConfig) |
||||
self.api_key = cfg.get('cloudstack_api_key') |
||||
self.secret_key = cfg.get('cloudstack_secret_key') |
||||
self.base_url = cfg.get('cloudstack_base_url') |
||||
self.members_domain = 'Members' |
||||
self._cached_domain_id = None |
||||
|
||||
def _create_url(self, params: Dict[str, str]) -> str: |
||||
# See https://docs.cloudstack.apache.org/en/latest/developersguide/dev.html#the-cloudstack-api |
||||
if 'apiKey' not in params and 'apikey' not in params: |
||||
params['apiKey'] = self.api_key |
||||
params['response'] = 'json' |
||||
request_str = '&'.join( |
||||
key + '=' + quote(val) |
||||
for key, val in params.items() |
||||
) |
||||
sig_str = '&'.join( |
||||
key.lower() + '=' + quote(val).lower() |
||||
for key, val in sorted(params.items()) |
||||
) |
||||
sig = hmac.new(self.secret_key.encode(), sig_str.encode(), hashlib.sha1).digest() |
||||
encoded_sig = b64encode(sig).decode() |
||||
url = self.base_url + '?' + request_str + '&signature=' + quote(encoded_sig) |
||||
return url |
||||
|
||||
def _get_domain_id(self) -> str: |
||||
if self._cached_domain_id is not None: |
||||
return self._cached_domain_id |
||||
url = self._create_url({ |
||||
'command': 'listDomains', |
||||
'details': 'min', |
||||
'name': self.members_domain, |
||||
}) |
||||
resp = requests.get(url) |
||||
resp.raise_for_status() |
||||
d = resp.json()['listdomainsresponse'] |
||||
assert d['count'] == 1, 'there should be one domain found' |
||||
domain_id = d['domain'][0]['id'] |
||||
self._cached_domain_id = domain_id |
||||
return domain_id |
||||
|
||||
def get_accounts(self) -> Dict[str, str]: |
||||
domain_id = self._get_domain_id() |
||||
url = self._create_url({ |
||||
'command': 'listAccounts', |
||||
'domainid': domain_id, |
||||
'details': 'min', |
||||
}) |
||||
resp = requests.get(url) |
||||
resp.raise_for_status() |
||||
d = resp.json()['listaccountsresponse'] |
||||
if 'account' not in d: |
||||
# The API returns an empty dict if there are no accounts |
||||
return [] |
||||
return { |
||||
account['name']: account['id'] |
||||
for account in d['account'] |
||||
} |
||||
|
||||
def delete_account(self, account_id: str): |
||||
url = self._create_url({ |
||||
'command': 'deleteAccount', |
||||
'id': account_id, |
||||
}) |
||||
resp = requests.post(url) |
||||
resp.raise_for_status() |
||||
|
||||
def create_account(self, user: IUser): |
||||
domain_id = self._get_domain_id() |
||||
|
||||
url = self._create_url({ |
||||
'command': 'ldapCreateAccount', |
||||
'accounttype': '0', |
||||
'domainid': domain_id, |
||||
'username': user.uid, |
||||
}) |
||||
resp = requests.post(url) |
||||
d = resp.json()['createaccountresponse'] |
||||
if not resp.ok: |
||||
raise CloudStackAPIError(d['errortext']) |
@ -0,0 +1,27 @@ |
||||
import json |
||||
from unittest.mock import patch |
||||
|
||||
|
||||
def test_k8s_create_account(k8s_srv): |
||||
# The KubernetesService should really be tested against a live cluster, |
||||
# but that's hard to do in a local environment. |
||||
# So we're just going to make sure that it doesn't crash, at least. |
||||
k8s_srv.create_account('test1') |
||||
|
||||
|
||||
def test_k8s_get_accounts(k8s_srv): |
||||
class MockProcess: |
||||
def __init__(self, output: str): |
||||
self.stdout = output |
||||
|
||||
def mock_run(args, check=True, **kwargs): |
||||
return MockProcess(json.dumps({'items': [ |
||||
{'metadata': {'name': 'csc-test1'}}, |
||||
{'metadata': {'name': 'csc-test2'}}, |
||||
{'metadata': {'name': 'kube-system'}}, |
||||
]})) |
||||
|
||||
expected = ['test1', 'test2'] |
||||
with patch.object(k8s_srv, '_run') as run_method: |
||||
run_method.side_effect = mock_run |
||||
assert k8s_srv.get_accounts() == expected |
Loading…
Reference in new issue