use CloudResourceManager

This commit is contained in:
Max Erenberg 2021-12-18 01:51:06 -05:00
parent e14b261805
commit 470b442e4c
20 changed files with 544 additions and 345 deletions

View File

@ -7,14 +7,12 @@ elif [ "$1" = delete ]; then
elif [ "$1" = certificate ]; then
exit
elif [ "$1" = get ]; then
if [ "$2" = csr -a "$4" = "-o" ]; then
if [ "$5" = 'jsonpath={.status.conditions[0].type}' ]; then
echo -n Approved
exit
elif [ "$5" = 'jsonpath={.status.certificate}' ]; then
echo -n 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxekNDQWI4Q0ZHeVk0ZVpVMnAvTjMzU0pCTlptMm1vSlE5TXFNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1DZ3gKRURBT0JnTlZCQU1NQjJOMFpHRnNaV3N4RkRBU0JnTlZCQW9NQzJOell5MXRaVzFpWlhKek1CNFhEVEl4TVRJeApNekEwTWpJek4xb1hEVEl5TURFeE1qQTBNakl6TjFvd0tERVFNQTRHQTFVRUF3d0hZM1JrWVd4bGF6RVVNQklHCkExVUVDZ3dMWTNOakxXMWxiV0psY25Nd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUIKQVFEV09vaTd6ejE0c3VBZ0V2QkgrSHFHSzlCUUlQTm5QQ0llVkxXenlFRTNxUWZRV2YvcWNzeGNST2pSKzVCTgpKSXBaQlNZdjRmNE52WFZqaHlQendoWUd0bXJRYksyT3RCTDlqMDJMWjhMVHp2TnE0MW9CYVdXUFhhaVdIVys2CjkzQnlBdXFPMmdnSEt0elNkV09TcTZpeFBXMVNGUzJRMkFWaXdZUEg3b1pQYnZacUZvMzdhbVdwd1pWUHVuVi8KV2tFRUttNUVqV05DSVUzVWpPdS9HeEJOT1g0WEpqWld4bFcwQUVROVp3K2ZSazBkdU5ScVVyUDQxbDZvcG4rKwpLRkE5NFg2NUlzcUMvMlJ4OWgrNkZFRHhIcjJPcjhOcGFuMXRjZEZHQlFyMGMxV1JxRkNHTytIM0VTeUNya1BjCmdnRDlVN3c0TmdGYkQyaVU0QXc3ZkhwakFnTUJBQUV3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUY3VWUwc3YKcFhSUzN1TFl1Y0k3UkRNRGpOZnFpZ0R3NnorbzZxVmxTdGpZTGpDNjFXRyswZ0g4TDJIbm5jZVYyelhjNDkrQQp6TjFna0lWT3JlRUQvRitKbGRPUGgySUpOY1pGYTBsckFFV0dNNWRRR3pDSUM0cEtmSGxOMTZ0c0w2bGdqWTYzCmUvZlhMTFdLdktDR2lRMUlBUTh4KzYyaTVvSmU3aDBlQ1Q0aEEyM0JTRnRNelo2aEdGUURNNGxxaWhHQjEyT2UKZE5yYStsNVdLemNFR21aVFBYTXNudEZVVndPejhaNld2eGo0UW1zL1dQUElKWDdLM2NiRUo4L1RQWG1tUzJrQwowNUtueUxVQzltYnR2TGZoYldhbFZVVlJVUkYwT1RaVk5mNkt6MDJWYlRqQjRJQXdyWGZKZC9lMkMvNFpGWlJTCjVWMnlJSnBJeVJGWTdQST0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='
exit
fi
if [ "$2" = csr -a "$4" = "-o" -a "$5" = 'jsonpath={.status.certificate}' ]; then
echo -n 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMxekNDQWI4Q0ZHeVk0ZVpVMnAvTjMzU0pCTlptMm1vSlE5TXFNQTBHQ1NxR1NJYjNEUUVCQ3dVQU1DZ3gKRURBT0JnTlZCQU1NQjJOMFpHRnNaV3N4RkRBU0JnTlZCQW9NQzJOell5MXRaVzFpWlhKek1CNFhEVEl4TVRJeApNekEwTWpJek4xb1hEVEl5TURFeE1qQTBNakl6TjFvd0tERVFNQTRHQTFVRUF3d0hZM1JrWVd4bGF6RVVNQklHCkExVUVDZ3dMWTNOakxXMWxiV0psY25Nd2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUIKQVFEV09vaTd6ejE0c3VBZ0V2QkgrSHFHSzlCUUlQTm5QQ0llVkxXenlFRTNxUWZRV2YvcWNzeGNST2pSKzVCTgpKSXBaQlNZdjRmNE52WFZqaHlQendoWUd0bXJRYksyT3RCTDlqMDJMWjhMVHp2TnE0MW9CYVdXUFhhaVdIVys2CjkzQnlBdXFPMmdnSEt0elNkV09TcTZpeFBXMVNGUzJRMkFWaXdZUEg3b1pQYnZacUZvMzdhbVdwd1pWUHVuVi8KV2tFRUttNUVqV05DSVUzVWpPdS9HeEJOT1g0WEpqWld4bFcwQUVROVp3K2ZSazBkdU5ScVVyUDQxbDZvcG4rKwpLRkE5NFg2NUlzcUMvMlJ4OWgrNkZFRHhIcjJPcjhOcGFuMXRjZEZHQlFyMGMxV1JxRkNHTytIM0VTeUNya1BjCmdnRDlVN3c0TmdGYkQyaVU0QXc3ZkhwakFnTUJBQUV3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCQUY3VWUwc3YKcFhSUzN1TFl1Y0k3UkRNRGpOZnFpZ0R3NnorbzZxVmxTdGpZTGpDNjFXRyswZ0g4TDJIbm5jZVYyelhjNDkrQQp6TjFna0lWT3JlRUQvRitKbGRPUGgySUpOY1pGYTBsckFFV0dNNWRRR3pDSUM0cEtmSGxOMTZ0c0w2bGdqWTYzCmUvZlhMTFdLdktDR2lRMUlBUTh4KzYyaTVvSmU3aDBlQ1Q0aEEyM0JTRnRNelo2aEdGUURNNGxxaWhHQjEyT2UKZE5yYStsNVdLemNFR21aVFBYTXNudEZVVndPejhaNld2eGo0UW1zL1dQUElKWDdLM2NiRUo4L1RQWG1tUzJrQwowNUtueUxVQzltYnR2TGZoYldhbFZVVlJVUkYwT1RaVk5mNkt6MDJWYlRqQjRJQXdyWGZKZC9lMkMvNFpGWlJTCjVWMnlJSnBJeVJGWTdQST0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='
exit
elif [ "$2" = namespaces ]; then
echo '{"items":[{"metadata":{"name":"default"}}]}'
exit
fi
fi
echo 'Unrecognized command'

View File

@ -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.
"""

View File

@ -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"
}
"""

View File

@ -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.
"""

View File

@ -1,3 +1,5 @@
from typing import List
from zope.interface import Interface
@ -15,3 +17,8 @@ class IKubernetesService(Interface):
"""
Delete the k8s namespace for the given user.
"""
def get_accounts() -> List[str]:
"""
Get a list of users who have k8s namespaces.
"""

View File

@ -30,7 +30,7 @@ class IVHostManager(Interface):
"""
Get the vhost records for the given user. Each record has the form
{
"domain": "app.username.m.csclub.cloud",
"domain": "app.username.csclub.cloud",
"ip_address": "172.19.134.12"
}
"""
@ -46,3 +46,8 @@ class IVHostManager(Interface):
Returns true iff ip_address is a valid proxy_pass target
in NGINX and falls within the acceptable IP range.
"""
def get_accounts() -> List[str]:
"""
Get a list of users who have at least one vhost record.
"""

View File

@ -1,4 +1,5 @@
from .ICloudService import ICloudService
from .ICloudResourceManager import ICloudResourceManager
from .ICloudStackService import ICloudStackService
from .IKerberosService import IKerberosService
from .IConfig import IConfig
from .IUser import IUser

View File

@ -8,11 +8,12 @@ from zope import component
from .error_handlers import register_error_handlers
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, IFileService, \
IMailmanService, IMailService, IUWLDAPService, IHTTPClient, IDatabaseService, \
ICloudService, IKubernetesService
ICloudStackService, ICloudResourceManager, IKubernetesService, IVHostManager
from ceo_common.model import Config, HTTPClient, RemoteMailmanService
from ceod.api.spnego import init_spnego
from ceod.model import KerberosService, LDAPService, FileService, \
MailmanService, MailService, UWLDAPService, CloudService, KubernetesService
MailmanService, MailService, UWLDAPService, CloudStackService, \
CloudResourceManager, KubernetesService, VHostManager
from ceod.db import MySQLService, PostgreSQLService
@ -114,20 +115,24 @@ def register_services(app):
uwldap_srv = UWLDAPService()
component.provideUtility(uwldap_srv, IUWLDAPService)
# MySQLService
# MySQLService, PostgreSQLService
if hostname == cfg.get('ceod_database_host'):
mysql_srv = MySQLService()
component.provideUtility(mysql_srv, IDatabaseService, 'mysql')
# PostgreSQLService
if hostname == cfg.get('ceod_database_host'):
psql_srv = PostgreSQLService()
component.provideUtility(psql_srv, IDatabaseService, 'postgresql')
# CloudService, KubernetesService
# CloudStackService, CloudResourceManager, VHostManager, KubernetesService
if hostname == cfg.get('ceod_cloud_host'):
cloud_srv = CloudService()
component.provideUtility(cloud_srv, ICloudService)
cloudstack_srv = CloudStackService()
component.provideUtility(cloudstack_srv, ICloudStackService)
cloud_mgr = CloudResourceManager()
component.provideUtility(cloud_mgr, ICloudResourceManager)
vhost_mgr = VHostManager()
component.provideUtility(vhost_mgr, IVHostManager)
k8s_srv = KubernetesService()
component.provideUtility(k8s_srv, IKubernetesService)

View File

@ -3,7 +3,8 @@ from zope import component
from .utils import requires_authentication_no_realm, authz_restrict_to_syscom, \
get_valid_member_or_throw
from ceo_common.interfaces import ICloudService, IKubernetesService
from ceo_common.interfaces import ICloudStackService, IVHostManager, \
IKubernetesService, ICloudResourceManager
bp = Blueprint('cloud', __name__)
@ -12,42 +13,42 @@ bp = Blueprint('cloud', __name__)
@requires_authentication_no_realm
def create_account(auth_user: str):
user = get_valid_member_or_throw(auth_user)
cloud_srv = component.getUtility(ICloudService)
cloud_srv.create_account(user)
cloudstack_srv = component.getUtility(ICloudStackService)
cloudstack_srv.create_account(user)
return {'status': 'OK'}
@bp.route('/accounts/purge', methods=['POST'])
@authz_restrict_to_syscom
def purge_accounts():
cloud_srv = component.getUtility(ICloudService)
return cloud_srv.purge_accounts()
cloud_mgr = component.getUtility(ICloudResourceManager)
return cloud_mgr.purge_accounts()
@bp.route('/vhosts/<domain>', methods=['PUT'])
@requires_authentication_no_realm
def create_vhost(auth_user: str, domain: str):
get_valid_member_or_throw(auth_user)
cloud_srv = component.getUtility(ICloudService)
vhost_mgr = component.getUtility(IVHostManager)
body = request.get_json(force=True)
ip_address = body['ip_address']
cloud_srv.create_vhost(auth_user, domain, ip_address)
vhost_mgr.create_vhost(auth_user, domain, ip_address)
return {'status': 'OK'}
@bp.route('/vhosts/<domain>', methods=['DELETE'])
@requires_authentication_no_realm
def delete_vhost(auth_user: str, domain: str):
cloud_srv = component.getUtility(ICloudService)
cloud_srv.delete_vhost(auth_user, domain)
vhost_mgr = component.getUtility(IVHostManager)
vhost_mgr.delete_vhost(auth_user, domain)
return {'status': 'OK'}
@bp.route('/vhosts', methods=['GET'])
@requires_authentication_no_realm
def get_vhosts(auth_user: str):
cloud_srv = component.getUtility(ICloudService)
vhosts = cloud_srv.get_vhosts(auth_user)
vhost_mgr = component.getUtility(IVHostManager)
vhosts = vhost_mgr.get_vhosts(auth_user)
return {'vhosts': vhosts}

View File

@ -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

View File

@ -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)

View File

@ -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'])

View File

@ -1,4 +1,5 @@
import base64
import json
import os
import subprocess
import tempfile
@ -14,6 +15,8 @@ from ceo_common.interfaces import IConfig, IKubernetesService
@implementer(IKubernetesService)
class KubernetesService:
namespace_prefix = 'csc-'
def __init__(self):
cfg = component.getUtility(IConfig)
self.members_clusterrole = cfg.get('k8s_members_clusterrole')
@ -30,9 +33,14 @@ class KubernetesService:
def _apply_manifest(self, manifest: str):
self._run(['kubectl', 'apply', '-f', '-'], input=manifest)
@staticmethod
def _get_namespace(username: str) -> str:
return 'csc-' + username
@classmethod
def _get_namespace(cls, username: str) -> str:
return cls.namespace_prefix + username
@classmethod
def _get_username_from_namespace(cls, namespace: str) -> str:
assert namespace.startswith(cls.namespace_prefix)
return namespace[len(cls.namespace_prefix):]
def create_account(self, username: str) -> str:
with tempfile.TemporaryDirectory() as tempdir:
@ -53,23 +61,19 @@ class KubernetesService:
# Approve the CSR
self._run(['kubectl', 'certificate', 'approve', csr_name])
# Wait until the certificate is issued
encoded_cert = ''
max_tries = 5
for i in range(max_tries):
proc = self._run([
'kubectl', 'get', 'csr', csr_name,
'-o', 'jsonpath={.status.conditions[0].type}',
'-o', 'jsonpath={.status.certificate}',
], capture_output=True)
if proc.stdout == 'Approved':
encoded_cert = proc.stdout
if encoded_cert != '':
break
time.sleep(1)
if i == max_tries:
if encoded_cert == '':
raise Exception('Timed out waiting for certificate to get issued')
# Retrieve the certificate
proc = self._run([
'kubectl', 'get', 'csr', csr_name,
'-o', 'jsonpath={.status.certificate}',
], capture_output=True)
encoded_cert = proc.stdout
# Delete the CSR
self._run(['kubectl', 'delete', 'csr', csr_name])
@ -98,3 +102,14 @@ class KubernetesService:
namespace = self._get_namespace(username)
# don't check exit code because namespace might not exist
self._run(['kubectl', 'delete', 'namespace', namespace], check=False)
def get_accounts(self) -> List[str]:
proc = self._run(['kubectl', 'get', 'namespaces', '-o', 'json'],
capture_output=True)
items = json.loads(proc.stdout)['items']
namespaces = [item['metadata']['name'] for item in items]
return [
self._get_username_from_namespace(namespace)
for namespace in namespaces
if namespace.startswith(self.namespace_prefix)
]

View File

@ -10,6 +10,8 @@ import jinja2
from zope import component
from zope.interface import implementer
from .utils import rate_limit
from ceo_common.errors import InvalidDomainError, InvalidIPError
from ceo_common.logger_factory import logger_factory
from ceo_common.interfaces import IVHostManager, IConfig
@ -48,6 +50,7 @@ class VHostManager:
self.k8s_ssl_cert = cfg.get('cloud vhosts_k8s_ssl_cert')
self.k8s_ssl_key = cfg.get('cloud vhosts_k8s_ssl_key')
self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account')
self.vhost_ip_min = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_min'))
self.vhost_ip_max = ipaddress.ip_address(cfg.get('cloud vhosts_ip_range_max'))
@ -59,6 +62,10 @@ class VHostManager:
loader=jinja2.PackageLoader('ceod.model'),
)
rate_limit_secs = cfg.get('cloud vhosts_rate_limit_seconds')
self.create_vhost = \
rate_limit('create_vhost', rate_limit_secs)(self.create_vhost)
@staticmethod
def _vhost_filename(username: str, domain: str) -> str:
"""Generate a filename for the vhost record"""
@ -82,20 +89,25 @@ class VHostManager:
self._run(['systemctl', 'reload', 'nginx'])
def is_valid_domain(self, username: str, domain: str) -> bool:
subdomain = username + '.' + self.vhost_domain
if domain == subdomain:
return True
# Members may only create domains of the form *.username.csclub.cloud
# or *-username.csclub.cloud
if not domain.endswith('.' + subdomain) and \
not domain.endswith('-' + subdomain):
return False
# Make sure that the domain doesn't have invalid characters
if VALID_DOMAIN_RE.match(domain) is None:
return False
if len(domain) > 80:
return False
return True
if domain.endswith('.' + self.k8s_vhost_domain):
prefix = domain[:len(domain) - len(self.k8s_vhost_domain) - 1]
elif domain.endswith('.' + self.vhost_domain):
prefix = domain[:len(domain) - len(self.vhost_domain) - 1]
else:
return False
last_part = prefix.split('.')[-1]
if last_part == username:
return True
if last_part.endswith('-' + username):
return True
return False
def is_valid_ip_address(self, ip_address: str) -> bool:
if ip_address == 'k8s':
@ -149,6 +161,14 @@ class VHostManager:
os.unlink(key_path)
def create_vhost(self, username: str, domain: str, ip_address: str):
if self.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.is_valid_domain(username, domain):
raise InvalidDomainError()
if not self.is_valid_ip_address(ip_address):
raise InvalidIPError()
cert_path, key_path = self._get_cert_and_key_path(domain)
if not (os.path.exists(cert_path) and os.path.exists(key_path)):
self._acquire_new_cert(domain, cert_path, key_path)
@ -164,6 +184,9 @@ class VHostManager:
self._reload_web_server()
def delete_vhost(self, username: str, domain: str):
if not self.is_valid_domain(username, domain):
raise InvalidDomainError()
cert_path, key_path = self._get_cert_and_key_path(domain)
if cert_path not in [self.default_ssl_cert, self.k8s_ssl_cert] \
and cert_path.startswith(self.ssl_dir) \
@ -204,3 +227,12 @@ class VHostManager:
logger.info(f'Deleting {filepath}')
os.unlink(filepath)
self._reload_web_server()
def get_accounts(self) -> List[str]:
vhost_files = os.listdir(self.vhost_dir)
usernames = list({
filename.split('_', 1)[0]
for filename in vhost_files
if '_' in filename
})
return usernames

View File

@ -1,4 +1,5 @@
from .CloudService import CloudService
from .CloudResourceManager import CloudResourceManager
from .CloudStackService import CloudStackService
from .KerberosService import KerberosService
from .LDAPService import LDAPService, UserNotFoundError, GroupNotFoundError
from .User import User

View File

@ -1,6 +1,11 @@
from typing import Dict, List, Union
import functools
import json
import os
from typing import Dict, List, Union, Callable
from ceo_common.errors import RateLimitError
from ceo_common.model import Term
import ceo_common.utils
def bytes_to_strings(data: Dict[str, List[bytes]]) -> Dict[str, List[str]]:
@ -40,3 +45,56 @@ def should_be_club_rep(terms: Union[None, List[str]],
return True
# decide using the most recent term (member or non-member)
return max(map(Term, non_member_terms)) > max(map(Term, terms))
def rate_limit(api_name: str, limit_secs: int):
"""
Returns a function which returns a decorator to rate limit
an API call. Since the rate limit is per-user, the first
argument to the function being rate limited must be a username.
"""
state_dir = '/run/ceod'
if not os.path.isdir(state_dir):
os.mkdir(state_dir)
rate_limit_file = os.path.join(state_dir, 'rate_limit.json')
def _check_rate_limit(username: str):
if not os.path.exists(rate_limit_file):
return
rate_limits_by_api = json.load(open(rate_limit_file))
if api_name not in rate_limits_by_api:
return
d = rate_limits_by_api[api_name]
if username not in d:
return
now = int(ceo_common.utils.get_current_datetime().timestamp())
time_passed = now - d[username]
if time_passed < limit_secs:
time_remaining = limit_secs - time_passed
raise RateLimitError(f'Please wait {time_remaining} seconds')
def _update_rate_limit_timestamp(username: str):
if os.path.exists(rate_limit_file):
rate_limits_by_api = json.load(open(rate_limit_file))
else:
rate_limits_by_api = {}
if api_name in rate_limits_by_api:
d = rate_limits_by_api[api_name]
else:
d = {}
rate_limits_by_api[api_name] = d
now = int(ceo_common.utils.get_current_datetime().timestamp())
d[username] = now
json.dump(rate_limits_by_api, open(rate_limit_file, 'w'))
def decorator_gen(func: Callable):
@functools.wraps(func)
def decorator(username: str, *args, **kwargs):
# sanity check
assert isinstance(username, str)
_check_rate_limit(username)
func(username, *args, **kwargs)
_update_rate_limit_timestamp(username)
return decorator
return decorator_gen

View File

@ -35,7 +35,7 @@ def test_create_account(client, mock_cloud_server, new_user, ldap_conn):
def test_purge_accounts(
client, mock_cloud_server, cloud_srv, mock_mail_server, new_user,
client, mock_cloud_server, cloud_mgr, mock_mail_server, new_user,
ldap_conn,
):
uid = new_user.uid
@ -43,8 +43,8 @@ def test_purge_accounts(
mock_mail_server.messages.clear()
accounts_deleted = []
accounts_to_be_deleted = []
if os.path.isfile(cloud_srv.pending_deletions_file):
os.unlink(cloud_srv.pending_deletions_file)
if os.path.isfile(cloud_mgr.pending_deletions_file):
os.unlink(cloud_mgr.pending_deletions_file)
expected = {
'accounts_deleted': accounts_deleted,
'accounts_to_be_deleted': accounts_to_be_deleted,
@ -66,7 +66,7 @@ def test_purge_accounts(
status, data = client.post('/api/cloud/accounts/purge')
assert status == 200
assert data == expected
assert os.path.isfile(cloud_srv.pending_deletions_file)
assert os.path.isfile(cloud_mgr.pending_deletions_file)
assert len(mock_mail_server.messages) == 1
# user still has one week left to renew their membership
@ -164,8 +164,7 @@ def test_cloud_vhosts(cfg, client, new_user, ldap_conn):
def test_cloud_vhosts_purged_account(
cfg, client, mock_cloud_server, mock_mail_server, cloud_srv, new_user,
ldap_conn,
cfg, client, mock_cloud_server, mock_mail_server, new_user, ldap_conn,
):
uid = new_user.uid
members_domain = cfg.get('cloud vhosts_members_domain')
@ -196,3 +195,11 @@ def test_cloud_vhosts_purged_account(
assert data == {'vhosts': []}
mock_mail_server.messages.clear()
def test_k8s_create_account(client, new_user, ldap_conn):
uid = new_user.uid
status, data = client.post('/api/cloud/k8s/accounts/create', principal=uid)
assert status == 200
assert data['status'] == 'OK'
assert 'kubeconfig' in data

View File

@ -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

View File

@ -1,8 +1,12 @@
import json
import os
import pytest
def test_vhost_mgr(cloud_srv):
vhost_mgr = cloud_srv.vhost_mgr
def test_vhost_mgr(vhost_mgr):
rate_limit_file = '/run/ceod/rate_limit.json'
if os.path.exists(rate_limit_file):
os.unlink(rate_limit_file)
username = 'test1'
domain = username + '.csclub.cloud'
filename = f'{username}_{domain}'
@ -17,6 +21,10 @@ def test_vhost_mgr(cloud_srv):
'domain': domain, 'ip_address': ip_address,
}]
d = json.load(open(rate_limit_file))
assert username in d['create_vhost']
os.unlink(rate_limit_file)
domain2 = 'app.' + domain
vhost_mgr.create_vhost(username, domain2, ip_address)
assert vhost_mgr.get_num_vhosts(username) == 2
@ -26,3 +34,41 @@ def test_vhost_mgr(cloud_srv):
vhost_mgr.delete_all_vhosts_for_user(username)
assert vhost_mgr.get_num_vhosts(username) == 0
os.unlink(rate_limit_file)
@pytest.mark.parametrize('suffix', ['csclub.cloud', 'k8s.csclub.cloud'])
@pytest.mark.parametrize('prefix,is_valid', [
('ctdalek', True),
('ctdalek1', False),
('1ctdalek', False),
('app_ctdalek', False),
('app.ctdalek', True),
('ctdalek.app', False),
('app-ctdalek', True),
('ctdalek-app', False),
('abc.def.ctdalek', True),
('abc.def-ctdalek', True),
])
def test_vhost_domain_validation(suffix, prefix, is_valid, vhost_mgr):
username = 'ctdalek'
domain = prefix + '.' + suffix
assert vhost_mgr.is_valid_domain(username, domain) == is_valid
def test_vhost_domain_validation_2(vhost_mgr):
assert not vhost_mgr.is_valid_domain('ctdalek', 'ctdalek.csclub.internal')
@pytest.mark.parametrize('ip_address,is_valid', [
('8.8.8.8', False),
('172.19.134.11', True),
('172.19.134.11:8000', True),
('172.19.134.1', False),
('172.19.134.254', False),
('172.19.134.254:8000', False),
('k8s', True),
])
def test_vhost_ip_validation(ip_address, is_valid, vhost_mgr):
assert vhost_mgr.is_valid_ip_address(ip_address) == is_valid

View File

@ -25,13 +25,14 @@ from .utils import ( # noqa: F401
)
from ceo_common.interfaces import IConfig, IKerberosService, ILDAPService, \
IFileService, IMailmanService, IHTTPClient, IUWLDAPService, IMailService, \
IDatabaseService, ICloudService, IKubernetesService
IDatabaseService, ICloudStackService, IKubernetesService, IVHostManager, \
ICloudResourceManager
from ceo_common.model import Config, HTTPClient, Term
from ceod.api import create_app
from ceod.db import MySQLService, PostgreSQLService
from ceod.model import KerberosService, LDAPService, FileService, User, \
MailmanService, Group, UWLDAPService, UWLDAPRecord, MailService, \
CloudService, KubernetesService
CloudStackService, KubernetesService, VHostManager, CloudResourceManager
from .MockSMTPServer import MockSMTPServer
from .MockMailmanServer import MockMailmanServer
from .MockCloudStackServer import MockCloudStackServer
@ -280,17 +281,31 @@ def vhost_dir_setup(cfg):
@pytest.fixture(scope='session')
def cloud_srv(cfg, vhost_dir_setup):
_cloud_srv = CloudService()
component.getGlobalSiteManager().registerUtility(_cloud_srv, ICloudService)
return _cloud_srv
def vhost_mgr(cfg, vhost_dir_setup):
mgr = VHostManager()
component.getGlobalSiteManager().registerUtility(mgr, IVHostManager)
return mgr
@pytest.fixture(scope='session')
def cloudstack_srv(cfg):
srv = CloudStackService()
component.getGlobalSiteManager().registerUtility(srv, ICloudStackService)
return srv
@pytest.fixture(scope='session')
def k8s_srv(cfg, vhost_dir_setup):
_k8s_srv = KubernetesService()
component.getGlobalSiteManager().registerUtility(_k8s_srv, IKubernetesService)
return _k8s_srv
srv = KubernetesService()
component.getGlobalSiteManager().registerUtility(srv, IKubernetesService)
return srv
@pytest.fixture(scope='session')
def cloud_mgr(cfg):
mgr = CloudResourceManager()
component.getGlobalSiteManager().registerUtility(mgr, ICloudResourceManager)
return mgr
@pytest.fixture(autouse=True, scope='session')
@ -304,8 +319,10 @@ def app(
mail_srv,
mysql_srv,
postgresql_srv,
cloud_srv,
cloudstack_srv,
vhost_mgr,
k8s_srv,
cloud_mgr,
):
app = create_app({'TESTING': True})
return app