Add cloud vhost API #35
|
@ -6,6 +6,9 @@ sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > /
|
||||||
cp /tmp/resolv.conf /etc/resolv.conf
|
cp /tmp/resolv.conf /etc/resolv.conf
|
||||||
rm /tmp/resolv.conf
|
rm /tmp/resolv.conf
|
||||||
|
|
||||||
|
# mock out systemctl
|
||||||
|
ln -s /bin/true /usr/local/bin/systemctl
|
||||||
|
|
||||||
get_ip_addr() {
|
get_ip_addr() {
|
||||||
getent hosts $1 | cut -d' ' -f1
|
getent hosts $1 | cut -d' ' -f1
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,5 +18,8 @@ apt update
|
||||||
apt install -y netcat-openbsd
|
apt install -y netcat-openbsd
|
||||||
auth_setup mail
|
auth_setup mail
|
||||||
|
|
||||||
|
# for the VHostManager
|
||||||
|
mkdir -p /run/ceod/member-vhosts
|
||||||
|
|
||||||
# sync with phosphoric-acid
|
# sync with phosphoric-acid
|
||||||
nc -l 0.0.0.0 9000 &
|
nc -l 0.0.0.0 9000 &
|
||||||
|
|
|
@ -3,8 +3,8 @@ from zope import component
|
||||||
|
|
||||||
from ceo_common.interfaces import IConfig
|
from ceo_common.interfaces import IConfig
|
||||||
|
|
||||||
from ..utils import http_post
|
from ..utils import http_post, http_put, http_get, http_delete
|
||||||
from .utils import handle_sync_response
|
from .utils import Abort, handle_sync_response, print_colon_kv
|
||||||
|
|
||||||
|
|
||||||
@click.group(short_help='Perform operations on the CSC cloud')
|
@click.group(short_help='Perform operations on the CSC cloud')
|
||||||
|
@ -44,3 +44,42 @@ def purge():
|
||||||
result = handle_sync_response(resp)
|
result = handle_sync_response(resp)
|
||||||
click.echo('Accounts to be deleted: ' + ','.join(result['accounts_to_be_deleted']))
|
click.echo('Accounts to be deleted: ' + ','.join(result['accounts_to_be_deleted']))
|
||||||
click.echo('Accounts which were deleted: ' + ','.join(result['accounts_deleted']))
|
click.echo('Accounts which were deleted: ' + ','.join(result['accounts_deleted']))
|
||||||
|
|
||||||
|
|
||||||
|
@cloud.group(short_help='Manage your virtual hosts')
|
||||||
|
def vhosts():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@vhosts.command(name='add', short_help='Add a virtual host')
|
||||||
|
@click.argument('domain')
|
||||||
|
@click.argument('ip_address')
|
||||||
|
def add_vhost(domain, ip_address):
|
||||||
|
body = {'ip_address': ip_address}
|
||||||
|
if '/' in domain:
|
||||||
|
raise Abort('invalid domain name')
|
||||||
|
resp = http_put('/api/cloud/vhosts/' + domain, json=body)
|
||||||
|
handle_sync_response(resp)
|
||||||
|
click.echo('Done.')
|
||||||
|
|
||||||
|
|
||||||
|
@vhosts.command(name='delete', short_help='Delete a virtual host')
|
||||||
|
@click.argument('domain')
|
||||||
|
def delete_vhost(domain):
|
||||||
|
if '/' in domain:
|
||||||
|
raise Abort('invalid domain name')
|
||||||
|
resp = http_delete('/api/cloud/vhosts/' + domain)
|
||||||
|
handle_sync_response(resp)
|
||||||
|
click.echo('Done.')
|
||||||
|
|
||||||
|
|
||||||
|
@vhosts.command(name='list', short_help='List virtual hosts')
|
||||||
|
def list_vhosts():
|
||||||
|
resp = http_get('/api/cloud/vhosts')
|
||||||
|
result = handle_sync_response(resp)
|
||||||
|
vhosts = result['vhosts']
|
||||||
|
if not vhosts:
|
||||||
|
click.echo('No vhosts found.')
|
||||||
|
return
|
||||||
|
pairs = [(d['domain'], d['ip_address']) for d in vhosts]
|
||||||
|
print_colon_kv(pairs)
|
||||||
|
|
|
@ -42,6 +42,10 @@ def http_delete(path: str, **kwargs) -> requests.Response:
|
||||||
return http_request('DELETE', path, **kwargs)
|
return http_request('DELETE', path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def http_put(path: str, **kwargs) -> requests.Response:
|
||||||
|
return http_request('PUT', path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_failed_operations(data: List[Dict]) -> List[str]:
|
def get_failed_operations(data: List[Dict]) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get a list of the failed operations using the JSON objects
|
Get a list of the failed operations using the JSON objects
|
||||||
|
|
|
@ -73,3 +73,13 @@ class InvalidMembershipError(Exception):
|
||||||
|
|
||||||
class CloudStackAPIError(Exception):
|
class CloudStackAPIError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidDomainError(Exception):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('domain is invalid')
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidIPError(Exception):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('IP address is invalid')
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from typing import Dict
|
from typing import Dict, List
|
||||||
|
|
||||||
from zope.interface import Interface
|
from zope.interface import Interface
|
||||||
|
|
||||||
|
@ -21,3 +21,22 @@ class ICloudService(Interface):
|
||||||
Another message will be emailed to the users after their cloud account
|
Another message will be emailed to the users after their cloud account
|
||||||
has been deleted.
|
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,36 @@
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
from zope.interface import Interface
|
||||||
|
|
||||||
|
|
||||||
|
class IVHostManager(Interface):
|
||||||
|
"""Performs operations on the CSC Cloud."""
|
||||||
|
|
||||||
|
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 delete_all_vhosts_for_user(username: str):
|
||||||
|
"""
|
||||||
|
Delete all vhost records for the given user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_num_vhosts(username: str) -> int:
|
||||||
|
"""
|
||||||
|
Get the number of vhost records for the given user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
"""
|
|
@ -10,3 +10,4 @@ from .IMailService import IMailService
|
||||||
from .IMailmanService import IMailmanService
|
from .IMailmanService import IMailmanService
|
||||||
from .IHTTPClient import IHTTPClient
|
from .IHTTPClient import IHTTPClient
|
||||||
from .IDatabaseService import IDatabaseService
|
from .IDatabaseService import IDatabaseService
|
||||||
|
from .IVHostManager import IVHostManager
|
||||||
|
|
|
@ -27,8 +27,8 @@ class HTTPClient:
|
||||||
host = host + '.' + self.base_domain
|
host = host + '.' + self.base_domain
|
||||||
|
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
# This is the only GET endpoint which requires auth
|
need_auth = path.startswith('/api/members') or \
|
||||||
need_auth = path.startswith('/api/members')
|
path.startswith('/api/cloud')
|
||||||
delegate = False
|
delegate = False
|
||||||
else:
|
else:
|
||||||
need_auth = True
|
need_auth = True
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from flask import Blueprint
|
from flask import Blueprint, request
|
||||||
from zope import component
|
from zope import component
|
||||||
|
|
||||||
from .utils import requires_authentication_no_realm, authz_restrict_to_syscom
|
from .utils import requires_authentication_no_realm, authz_restrict_to_syscom, \
|
||||||
from ceo_common.interfaces import ICloudService, ILDAPService
|
get_valid_member_or_throw
|
||||||
|
from ceo_common.interfaces import ICloudService
|
||||||
|
|
||||||
bp = Blueprint('cloud', __name__)
|
bp = Blueprint('cloud', __name__)
|
||||||
|
|
||||||
|
@ -10,9 +11,8 @@ bp = Blueprint('cloud', __name__)
|
||||||
@bp.route('/accounts/create', methods=['POST'])
|
@bp.route('/accounts/create', methods=['POST'])
|
||||||
@requires_authentication_no_realm
|
@requires_authentication_no_realm
|
||||||
def create_account(auth_user: str):
|
def create_account(auth_user: str):
|
||||||
|
user = get_valid_member_or_throw(auth_user)
|
||||||
cloud_srv = component.getUtility(ICloudService)
|
cloud_srv = component.getUtility(ICloudService)
|
||||||
ldap_srv = component.getUtility(ILDAPService)
|
|
||||||
user = ldap_srv.get_user(auth_user)
|
|
||||||
cloud_srv.create_account(user)
|
cloud_srv.create_account(user)
|
||||||
return {'status': 'OK'}
|
return {'status': 'OK'}
|
||||||
|
|
||||||
|
@ -22,3 +22,30 @@ def create_account(auth_user: str):
|
||||||
def purge_accounts():
|
def purge_accounts():
|
||||||
cloud_srv = component.getUtility(ICloudService)
|
cloud_srv = component.getUtility(ICloudService)
|
||||||
return cloud_srv.purge_accounts()
|
return cloud_srv.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)
|
||||||
|
body = request.get_json(force=True)
|
||||||
|
ip_address = body['ip_address']
|
||||||
|
cloud_srv.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)
|
||||||
|
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)
|
||||||
|
return {'vhosts': vhosts}
|
||||||
|
|
|
@ -8,7 +8,7 @@ from werkzeug.exceptions import HTTPException
|
||||||
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
|
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
|
||||||
UserAlreadyExistsError, GroupAlreadyExistsError, BadRequest, \
|
UserAlreadyExistsError, GroupAlreadyExistsError, BadRequest, \
|
||||||
UserAlreadySubscribedError, InvalidMembershipError, \
|
UserAlreadySubscribedError, InvalidMembershipError, \
|
||||||
CloudStackAPIError
|
CloudStackAPIError, InvalidDomainError, InvalidIPError
|
||||||
from ceo_common.logger_factory import logger_factory
|
from ceo_common.logger_factory import logger_factory
|
||||||
|
|
||||||
__all__ = ['register_error_handlers']
|
__all__ = ['register_error_handlers']
|
||||||
|
@ -24,7 +24,9 @@ def generic_error_handler(err: Exception):
|
||||||
"""Return JSON for all errors."""
|
"""Return JSON for all errors."""
|
||||||
if isinstance(err, HTTPException):
|
if isinstance(err, HTTPException):
|
||||||
status_code = err.code
|
status_code = err.code
|
||||||
elif isinstance(err, BadRequest):
|
elif any(isinstance(err, cls) for cls in [
|
||||||
|
BadRequest, InvalidDomainError, InvalidIPError
|
||||||
|
]):
|
||||||
status_code = 400
|
status_code = 400
|
||||||
elif isinstance(err, ldap3.core.exceptions.LDAPStrongerAuthRequiredResult) \
|
elif isinstance(err, ldap3.core.exceptions.LDAPStrongerAuthRequiredResult) \
|
||||||
or isinstance(err, InvalidMembershipError):
|
or isinstance(err, InvalidMembershipError):
|
||||||
|
|
|
@ -7,14 +7,25 @@ import traceback
|
||||||
from typing import Callable, List
|
from typing import Callable, List
|
||||||
|
|
||||||
from flask import current_app, stream_with_context
|
from flask import current_app, stream_with_context
|
||||||
|
from zope import component
|
||||||
|
|
||||||
from .spnego import requires_authentication
|
from .spnego import requires_authentication
|
||||||
|
from ceo_common.errors import InvalidMembershipError
|
||||||
|
from ceo_common.interfaces import IUser, ILDAPService
|
||||||
from ceo_common.logger_factory import logger_factory
|
from ceo_common.logger_factory import logger_factory
|
||||||
from ceod.transactions import AbstractTransaction
|
from ceod.transactions import AbstractTransaction
|
||||||
|
|
||||||
logger = logger_factory(__name__)
|
logger = logger_factory(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid_member_or_throw(username: str) -> IUser:
|
||||||
|
ldap_srv = component.getUtility(ILDAPService)
|
||||||
|
user = ldap_srv.get_user(username)
|
||||||
|
if not user.membership_is_valid():
|
||||||
|
raise InvalidMembershipError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def requires_authentication_no_realm(f: Callable) -> Callable:
|
def requires_authentication_no_realm(f: Callable) -> Callable:
|
||||||
"""
|
"""
|
||||||
Like requires_authentication, but strips the realm out of the principal string.
|
Like requires_authentication, but strips the realm out of the principal string.
|
||||||
|
|
|
@ -2,8 +2,10 @@ from base64 import b64encode
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
@ -11,7 +13,8 @@ import requests
|
||||||
from zope import component
|
from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
from ceo_common.errors import InvalidMembershipError, CloudStackAPIError
|
from .VHostManager import VHostManager
|
||||||
|
from ceo_common.errors import CloudStackAPIError, InvalidDomainError, InvalidIPError
|
||||||
from ceo_common.logger_factory import logger_factory
|
from ceo_common.logger_factory import logger_factory
|
||||||
from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \
|
from ceo_common.interfaces import ICloudService, IConfig, IUser, ILDAPService, \
|
||||||
IMailService
|
IMailService
|
||||||
|
@ -23,12 +26,23 @@ logger = logger_factory(__name__)
|
||||||
|
|
||||||
@implementer(ICloudService)
|
@implementer(ICloudService)
|
||||||
class CloudService:
|
class CloudService:
|
||||||
|
VALID_DOMAIN_RE = re.compile(r'^(?:[0-9a-z-]+\.)+[a-z]+$')
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
cfg = component.getUtility(IConfig)
|
cfg = component.getUtility(IConfig)
|
||||||
self.api_key = cfg.get('cloudstack_api_key')
|
self.api_key = cfg.get('cloudstack_api_key')
|
||||||
self.secret_key = cfg.get('cloudstack_secret_key')
|
self.secret_key = cfg.get('cloudstack_secret_key')
|
||||||
self.base_url = cfg.get('cloudstack_base_url')
|
self.base_url = cfg.get('cloudstack_base_url')
|
||||||
self.members_domain = 'Members'
|
self.members_domain = 'Members'
|
||||||
|
self.vhost_mgr = VHostManager(
|
||||||
|
vhost_dir=cfg.get('cloud vhosts_config_dir'),
|
||||||
|
ssl_cert_path=cfg.get('cloud vhosts_ssl_cert_path'),
|
||||||
|
ssl_key_path=cfg.get('cloud vhosts_ssl_key_path'),
|
||||||
|
)
|
||||||
|
self.max_vhosts_per_account = cfg.get('cloud vhosts_max_vhosts_per_account')
|
||||||
|
self.vhost_domain = cfg.get('cloud vhosts_members_domain')
|
||||||
|
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'))
|
||||||
|
|
||||||
state_dir = '/run/ceod'
|
state_dir = '/run/ceod'
|
||||||
if not os.path.isdir(state_dir):
|
if not os.path.isdir(state_dir):
|
||||||
|
@ -88,8 +102,6 @@ class CloudService:
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
def create_account(self, user: IUser):
|
def create_account(self, user: IUser):
|
||||||
if not user.membership_is_valid():
|
|
||||||
raise InvalidMembershipError()
|
|
||||||
domain_id = self._get_domain_id(self.members_domain)
|
domain_id = self._get_domain_id(self.members_domain)
|
||||||
|
|
||||||
url = self._create_url({
|
url = self._create_url({
|
||||||
|
@ -146,7 +158,10 @@ class CloudService:
|
||||||
if user.membership_is_valid():
|
if user.membership_is_valid():
|
||||||
continue
|
continue
|
||||||
account_id = username_to_account_id[username]
|
account_id = username_to_account_id[username]
|
||||||
|
|
||||||
self._delete_account(account_id)
|
self._delete_account(account_id)
|
||||||
|
self.vhost_mgr.delete_all_vhosts_for_user(username)
|
||||||
|
|
||||||
accounts_deleted.append(username)
|
accounts_deleted.append(username)
|
||||||
mail_srv.send_cloud_account_has_been_deleted_message(user)
|
mail_srv.send_cloud_account_has_been_deleted_message(user)
|
||||||
logger.info(f'Deleted cloud account for {username}')
|
logger.info(f'Deleted cloud account for {username}')
|
||||||
|
@ -172,3 +187,38 @@ class CloudService:
|
||||||
if accounts_to_be_deleted:
|
if accounts_to_be_deleted:
|
||||||
json.dump(state, open(self.pending_deletions_file, 'w'))
|
json.dump(state, open(self.pending_deletions_file, 'w'))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _is_valid_domain(self, username: str, domain: str) -> bool:
|
||||||
|
subdomain = username + '.' + self.vhost_domain
|
||||||
|
if not (domain == subdomain or domain.endswith('.' + subdomain)):
|
||||||
|
return False
|
||||||
|
if self.VALID_DOMAIN_RE.match(domain) is None:
|
||||||
|
return False
|
||||||
|
if len(domain) > 80:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _is_valid_ip_address(self, ip_address: str) -> bool:
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip_address)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return self.vhost_ip_min <= addr <= self.vhost_ip_max
|
||||||
|
|
||||||
|
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._is_valid_domain(username, domain):
|
||||||
|
raise InvalidDomainError()
|
||||||
|
if not self._is_valid_ip_address(ip_address):
|
||||||
|
raise InvalidIPError()
|
||||||
|
self.vhost_mgr.create_vhost(username, domain, ip_address)
|
||||||
|
|
||||||
|
def delete_vhost(self, username: str, domain: str):
|
||||||
|
if not self._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,97 @@
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
from ceo_common.logger_factory import logger_factory
|
||||||
|
from ceo_common.interfaces import IVHostManager
|
||||||
|
|
||||||
|
PROXY_PASS_IP_RE = re.compile(r'^\s+proxy_pass\s+http://(?P<ip_address>[\d.]+);$')
|
||||||
|
VHOST_FILENAME_RE = re.compile(r'^member_(?P<username>[0-9a-z-]+)_(?P<domain>[0-9a-z.-]+)$')
|
||||||
|
logger = logger_factory(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@implementer(IVHostManager)
|
||||||
|
class VHostManager:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
vhost_dir: str,
|
||||||
|
ssl_cert_path: str,
|
||||||
|
ssl_key_path: str,
|
||||||
|
):
|
||||||
|
self.vhost_dir = vhost_dir
|
||||||
|
self.ssl_cert_path = ssl_cert_path
|
||||||
|
self.ssl_key_path = ssl_key_path
|
||||||
|
self.jinja_env = jinja2.Environment(
|
||||||
|
loader=jinja2.PackageLoader('ceod.model'),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _vhost_filename(username: str, domain: str) -> str:
|
||||||
|
"""Generate a filename for the vhost record"""
|
||||||
|
# sanity check...
|
||||||
|
assert '..' not in domain and '/' not in domain
|
||||||
|
return 'member' + '_' + username + '_' + domain
|
||||||
|
|
||||||
|
def _vhost_filepath(self, username: str, domain: str) -> str:
|
||||||
|
"""Generate an absolute path for the vhost record"""
|
||||||
|
return os.path.join(self.vhost_dir, self._vhost_filename(username, domain))
|
||||||
|
|
||||||
|
def _vhost_files(self, username: str) -> List[str]:
|
||||||
|
"""Return a list of all vhost files for this user."""
|
||||||
|
return glob.glob(os.path.join(self.vhost_dir, 'member_' + username + '_*'))
|
||||||
|
|
||||||
|
def _reload_web_server(self):
|
||||||
|
logger.debug('Reloading nginx')
|
||||||
|
subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
|
||||||
|
|
||||||
|
def create_vhost(self, username: str, domain: str, ip_address: str):
|
||||||
|
template = self.jinja_env.get_template('nginx_cloud_vhost_config.j2')
|
||||||
|
body = template.render(
|
||||||
|
username=username, domain=domain, ip_address=ip_address,
|
||||||
|
ssl_cert_path=self.ssl_cert_path, ssl_key_path=self.ssl_key_path)
|
||||||
|
filepath = self._vhost_filepath(username, domain)
|
||||||
|
logger.info(f'Writing a new vhost ({domain} -> {ip_address}) to {filepath}')
|
||||||
|
with open(filepath, 'w') as fo:
|
||||||
|
fo.write(body)
|
||||||
|
self._reload_web_server()
|
||||||
|
|
||||||
|
def delete_vhost(self, username: str, domain: str):
|
||||||
|
filepath = self._vhost_filepath(username, domain)
|
||||||
|
logger.info(f'Deleting {filepath}')
|
||||||
|
os.unlink(filepath)
|
||||||
|
self._reload_web_server()
|
||||||
|
|
||||||
|
def get_num_vhosts(self, username: str) -> int:
|
||||||
|
return len(self._vhost_files(username))
|
||||||
|
|
||||||
|
def get_vhosts(self, username: str) -> List[Dict]:
|
||||||
|
vhosts = []
|
||||||
|
for filepath in self._vhost_files(username):
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
match = VHOST_FILENAME_RE.match(filename)
|
||||||
|
assert match is not None, f"'{filename}' does not match expected pattern"
|
||||||
|
domain = match.group('domain')
|
||||||
|
ip_address = None
|
||||||
|
for line in open(filepath):
|
||||||
|
match = PROXY_PASS_IP_RE.match(line)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
ip_address = match.group('ip_address')
|
||||||
|
break
|
||||||
|
assert ip_address is not None, f"Could not find IP address in {filename}"
|
||||||
|
vhosts.append({'domain': domain, 'ip_address': ip_address})
|
||||||
|
return vhosts
|
||||||
|
|
||||||
|
def delete_all_vhosts_for_user(self, username: str):
|
||||||
|
filepaths = self._vhost_files(username)
|
||||||
|
if not filepaths:
|
||||||
|
return
|
||||||
|
for filepath in filepaths:
|
||||||
|
logger.info(f'Deleting {filepath}')
|
||||||
|
os.unlink(filepath)
|
||||||
|
self._reload_web_server()
|
|
@ -9,3 +9,4 @@ from .FileService import FileService
|
||||||
from .SudoRole import SudoRole
|
from .SudoRole import SudoRole
|
||||||
from .MailService import MailService
|
from .MailService import MailService
|
||||||
from .MailmanService import MailmanService
|
from .MailmanService import MailmanService
|
||||||
|
from .VHostManager import VHostManager
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
# This file is automatically managed by ceod.
|
||||||
|
# DO NOT EDIT THIS FILE MANUALLY.
|
||||||
|
# If you want to modify it, please move it to another directory.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name {{ domain }};
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name {{ domain }};
|
||||||
|
ssl_certificate {{ ssl_cert_path }};
|
||||||
|
ssl_certificate_key {{ ssl_key_path }};
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://{{ ip_address }};
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /var/log/nginx/member-{{ username }}-access.log;
|
||||||
|
error_log /var/log/nginx/member-{{ username }}-error.log;
|
||||||
|
}
|
|
@ -713,16 +713,7 @@ paths:
|
||||||
description: Activate a cloud account for the calling user
|
description: Activate a cloud account for the calling user
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Success
|
"$ref": "#/components/responses/SimpleSuccessResponse"
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
status:
|
|
||||||
type: string
|
|
||||||
description: '"OK"'
|
|
||||||
example: {"status": "OK"}
|
|
||||||
"403":
|
"403":
|
||||||
"$ref": "#/components/responses/InvalidMembershipErrorResponse"
|
"$ref": "#/components/responses/InvalidMembershipErrorResponse"
|
||||||
/cloud/accounts/purge:
|
/cloud/accounts/purge:
|
||||||
|
@ -757,6 +748,79 @@ paths:
|
||||||
description: usernames of accounts which were deleted
|
description: usernames of accounts which were deleted
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
/cloud/vhosts/{domain}:
|
||||||
|
put:
|
||||||
|
tags: ['cloud']
|
||||||
|
servers:
|
||||||
|
- url: https://biloba.csclub.uwaterloo.ca:9987/api
|
||||||
|
summary: Create a vhost
|
||||||
|
description: Add a new virtual host configuration.
|
||||||
|
parameters:
|
||||||
|
- name: domain
|
||||||
|
in: path
|
||||||
|
description: domain name of the virtual host
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
ip_address:
|
||||||
|
type: string
|
||||||
|
description: IP address of the virtual host
|
||||||
|
example: {"ip_address": "172.19.134.11"}
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
"$ref": "#/components/responses/SimpleSuccessResponse"
|
||||||
|
"403":
|
||||||
|
"$ref": "#/components/responses/InvalidMembershipErrorResponse"
|
||||||
|
delete:
|
||||||
|
tags: ['cloud']
|
||||||
|
servers:
|
||||||
|
- url: https://biloba.csclub.uwaterloo.ca:9987/api
|
||||||
|
summary: Delete a vhost
|
||||||
|
description: Delete a virtual host configuration.
|
||||||
|
parameters:
|
||||||
|
- name: domain
|
||||||
|
in: path
|
||||||
|
description: domain name of the virtual host
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
"$ref": "#/components/responses/SimpleSuccessResponse"
|
||||||
|
/cloud/vhosts:
|
||||||
|
get:
|
||||||
|
tags: ['cloud']
|
||||||
|
servers:
|
||||||
|
- url: https://biloba.csclub.uwaterloo.ca:9987/api
|
||||||
|
summary: List all vhosts
|
||||||
|
description: List all virtual host configurations for the calling user.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
vhosts:
|
||||||
|
type: array
|
||||||
|
description: virtual hosts
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
type: string
|
||||||
|
description: domain name of the virtual host
|
||||||
|
ip_address:
|
||||||
|
type: string
|
||||||
|
description: IP address of the virtual host
|
||||||
|
example: {"vhosts": [{"domain": "ctdalek.m.csclub.cloud", "ip_address": "172.19.134.11"}]}
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
GSSAPIAuth:
|
GSSAPIAuth:
|
||||||
|
@ -932,3 +996,14 @@ components:
|
||||||
InvalidMembershipErrorResponse:
|
InvalidMembershipErrorResponse:
|
||||||
<<: *ErrorResponse
|
<<: *ErrorResponse
|
||||||
description: Membership is invalid or expired
|
description: Membership is invalid or expired
|
||||||
|
SimpleSuccessResponse:
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
description: '"OK"'
|
||||||
|
example: {"status": "OK"}
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -79,3 +79,12 @@ host = localhost
|
||||||
api_key = REPLACE_ME
|
api_key = REPLACE_ME
|
||||||
secret_key = REPLACE_ME
|
secret_key = REPLACE_ME
|
||||||
base_url = http://localhost:8080/client/api
|
base_url = http://localhost:8080/client/api
|
||||||
|
|
||||||
|
[cloud vhosts]
|
||||||
|
config_dir = /etc/nginx/ceod-member-vhosts
|
||||||
|
ssl_cert_path = /etc/ssl/private/csclub.cloud.chain
|
||||||
|
ssl_key_path = /etc/ssl/private/csclub.cloud.key
|
||||||
|
max_vhosts_per_account = 10
|
||||||
|
members_domain = m.csclub.cloud
|
||||||
|
ip_range_min = 172.19.134.10
|
||||||
|
ip_range_max = 172.19.134.160
|
||||||
|
|
|
@ -26,3 +26,29 @@ def test_cloud_accounts_purge(cli_setup, mock_cloud_server):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ['cloud', 'accounts', 'purge'])
|
result = runner.invoke(cli, ['cloud', 'accounts', 'purge'])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_vhosts(cli_setup, new_user, cfg):
|
||||||
|
members_domain = cfg.get('cloud vhosts_members_domain')
|
||||||
|
uid = new_user.uid
|
||||||
|
domain1 = uid + '.' + members_domain
|
||||||
|
ip1 = '172.19.134.11'
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
with gssapi_token_ctx(uid):
|
||||||
|
result = runner.invoke(cli, ['cloud', 'vhosts', 'add', domain1, ip1])
|
||||||
|
expected = 'Done.\n'
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output == expected
|
||||||
|
|
||||||
|
with gssapi_token_ctx(uid):
|
||||||
|
result = runner.invoke(cli, ['cloud', 'vhosts', 'list'])
|
||||||
|
expected = domain1 + ': ' + ip1 + '\n'
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output == expected
|
||||||
|
|
||||||
|
with gssapi_token_ctx(uid):
|
||||||
|
result = runner.invoke(cli, ['cloud', 'vhosts', 'delete', domain1])
|
||||||
|
expected = 'Done.\n'
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output == expected
|
||||||
|
|
|
@ -84,3 +84,103 @@ def test_purge_accounts(
|
||||||
assert new_user.uid not in mock_cloud_server.users_by_username
|
assert new_user.uid not in mock_cloud_server.users_by_username
|
||||||
assert len(mock_mail_server.messages) == 2
|
assert len(mock_mail_server.messages) == 2
|
||||||
mock_mail_server.messages.clear()
|
mock_mail_server.messages.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_vhosts(cfg, client, new_user, ldap_conn):
|
||||||
|
members_domain = cfg.get('cloud vhosts_members_domain')
|
||||||
|
max_vhosts = cfg.get('cloud vhosts_max_vhosts_per_account')
|
||||||
|
uid = new_user.uid
|
||||||
|
|
||||||
|
domain1 = uid + '.' + members_domain
|
||||||
|
ip1 = '172.19.134.11'
|
||||||
|
status, _ = client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain1}', json={'ip_address': ip1},
|
||||||
|
principal=uid)
|
||||||
|
assert status == 200
|
||||||
|
status, data = client.get('/api/cloud/vhosts', principal=uid)
|
||||||
|
assert status == 200
|
||||||
|
assert data == {'vhosts': [{'domain': domain1, 'ip_address': ip1}]}
|
||||||
|
|
||||||
|
# invalid domain name
|
||||||
|
domain2 = uid + 'm.cloud.' + cfg.get('base_domain')
|
||||||
|
ip2 = ip1
|
||||||
|
status, _ = client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain2}', json={'ip_address': ip2},
|
||||||
|
principal=uid)
|
||||||
|
assert status == 400
|
||||||
|
|
||||||
|
# invalid IP address
|
||||||
|
domain3 = domain1
|
||||||
|
ip3 = '129.97.134.10'
|
||||||
|
status, _ = client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain3}', json={'ip_address': ip3},
|
||||||
|
principal=uid)
|
||||||
|
assert status == 400
|
||||||
|
|
||||||
|
# new vhost with same domain should replace old one
|
||||||
|
domain4 = domain1
|
||||||
|
ip4 = '172.19.134.14'
|
||||||
|
status, _ = client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain4}', json={'ip_address': ip4},
|
||||||
|
principal=uid)
|
||||||
|
assert status == 200
|
||||||
|
status, data = client.get('/api/cloud/vhosts', principal=uid)
|
||||||
|
assert status == 200
|
||||||
|
assert data == {'vhosts': [{'domain': domain4, 'ip_address': ip4}]}
|
||||||
|
|
||||||
|
# maximum number of vhosts
|
||||||
|
for i in range(max_vhosts):
|
||||||
|
domain = 'app' + str(i + 1) + '.' + uid + '.' + members_domain
|
||||||
|
status, _ = client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain}', json={'ip_address': ip1},
|
||||||
|
principal=uid)
|
||||||
|
if i < max_vhosts - 1:
|
||||||
|
assert status == 200
|
||||||
|
else:
|
||||||
|
assert status != 200
|
||||||
|
|
||||||
|
# delete a vhost
|
||||||
|
status, _ = client.delete(f'/api/cloud/vhosts/{domain1}', principal=uid)
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
# expired members may not create vhosts
|
||||||
|
expire_member(new_user, ldap_conn)
|
||||||
|
status, _ = client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain1}', json={'ip_address': ip1},
|
||||||
|
principal=uid)
|
||||||
|
assert status == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_cloud_vhosts_purged_account(
|
||||||
|
cfg, client, mock_cloud_server, mock_mail_server, cloud_srv, new_user,
|
||||||
|
ldap_conn,
|
||||||
|
):
|
||||||
|
uid = new_user.uid
|
||||||
|
members_domain = cfg.get('cloud vhosts_members_domain')
|
||||||
|
mock_cloud_server.clear()
|
||||||
|
current_term = Term.current()
|
||||||
|
beginning_of_term = current_term.to_datetime()
|
||||||
|
domain1 = uid + '.' + members_domain
|
||||||
|
ip1 = '172.19.134.11'
|
||||||
|
|
||||||
|
client.post('/api/cloud/accounts/create', principal=uid)
|
||||||
|
client.put(
|
||||||
|
f'/api/cloud/vhosts/{domain1}', json={'ip_address': ip1},
|
||||||
|
principal=uid)
|
||||||
|
|
||||||
|
expire_member(new_user, ldap_conn)
|
||||||
|
with patch.object(ceo_common_utils, 'get_current_datetime') as now_mock:
|
||||||
|
# grace period has passed - user should be sent a warning
|
||||||
|
now_mock.return_value = beginning_of_term + datetime.timedelta(days=31)
|
||||||
|
client.post('/api/cloud/accounts/purge')
|
||||||
|
|
||||||
|
# one week has passed - the account can now be deleted
|
||||||
|
now_mock.return_value += datetime.timedelta(days=8)
|
||||||
|
client.post('/api/cloud/accounts/purge')
|
||||||
|
|
||||||
|
# vhosts should have been deleted
|
||||||
|
status, data = client.get('/api/cloud/vhosts', principal=uid)
|
||||||
|
assert status == 200
|
||||||
|
assert data == {'vhosts': []}
|
||||||
|
|
||||||
|
mock_mail_server.messages.clear()
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def test_vhost_mgr(cloud_srv):
|
||||||
|
vhost_mgr = cloud_srv.vhost_mgr
|
||||||
|
username = 'test1'
|
||||||
|
domain = username + '.m.csclub.cloud'
|
||||||
|
filename = f'member_{username}_{domain}'
|
||||||
|
ip_address = '172.19.134.11'
|
||||||
|
vhost_mgr.create_vhost(username, domain, ip_address)
|
||||||
|
path = os.path.join(vhost_mgr.vhost_dir, filename)
|
||||||
|
assert os.path.isfile(path)
|
||||||
|
|
||||||
|
assert vhost_mgr.get_num_vhosts(username) == 1
|
||||||
|
|
||||||
|
assert vhost_mgr.get_vhosts(username) == [{
|
||||||
|
'domain': domain, 'ip_address': ip_address,
|
||||||
|
}]
|
||||||
|
|
||||||
|
domain2 = 'app.' + domain
|
||||||
|
vhost_mgr.create_vhost(username, domain2, ip_address)
|
||||||
|
assert vhost_mgr.get_num_vhosts(username) == 2
|
||||||
|
|
||||||
|
vhost_mgr.delete_vhost(username, domain)
|
||||||
|
assert vhost_mgr.get_num_vhosts(username) == 1
|
||||||
|
|
||||||
|
vhost_mgr.delete_all_vhosts_for_user(username)
|
||||||
|
assert vhost_mgr.get_num_vhosts(username) == 0
|
|
@ -73,3 +73,12 @@ host = localhost
|
||||||
api_key = REPLACE_ME
|
api_key = REPLACE_ME
|
||||||
secret_key = REPLACE_ME
|
secret_key = REPLACE_ME
|
||||||
base_url = http://localhost:8080/client/api
|
base_url = http://localhost:8080/client/api
|
||||||
|
|
||||||
|
[cloud vhosts]
|
||||||
|
config_dir = /run/ceod/member-vhosts
|
||||||
|
ssl_cert_path = /etc/ssl/private/csclub.cloud.chain
|
||||||
|
ssl_key_path = /etc/ssl/private/csclub.cloud.key
|
||||||
|
max_vhosts_per_account = 10
|
||||||
|
members_domain = m.csclub.cloud
|
||||||
|
ip_range_min = 172.19.134.10
|
||||||
|
ip_range_max = 172.19.134.160
|
||||||
|
|
|
@ -72,3 +72,12 @@ host = coffee
|
||||||
api_key = REPLACE_ME
|
api_key = REPLACE_ME
|
||||||
secret_key = REPLACE_ME
|
secret_key = REPLACE_ME
|
||||||
base_url = http://localhost:8080/client/api
|
base_url = http://localhost:8080/client/api
|
||||||
|
|
||||||
|
[cloud vhosts]
|
||||||
|
config_dir = /run/ceod/member-vhosts
|
||||||
|
ssl_cert_path = /etc/ssl/private/csclub.cloud.chain
|
||||||
|
ssl_key_path = /etc/ssl/private/csclub.cloud.key
|
||||||
|
max_vhosts_per_account = 10
|
||||||
|
members_domain = m.csclub.cloud
|
||||||
|
ip_range_min = 172.19.134.10
|
||||||
|
ip_range_max = 172.19.134.160
|
||||||
|
|
|
@ -59,7 +59,7 @@ def cfg(_drone_hostname_mock):
|
||||||
|
|
||||||
def delete_test_princs(krb_srv):
|
def delete_test_princs(krb_srv):
|
||||||
proc = subprocess.run([
|
proc = subprocess.run([
|
||||||
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test_*',
|
'kadmin', '-k', '-p', krb_srv.admin_principal, 'listprincs', 'test*',
|
||||||
], text=True, capture_output=True, check=True)
|
], text=True, capture_output=True, check=True)
|
||||||
princs = [line.strip() for line in proc.stdout.splitlines()]
|
princs = [line.strip() for line in proc.stdout.splitlines()]
|
||||||
for princ in princs:
|
for princ in princs:
|
||||||
|
@ -268,7 +268,17 @@ def postgresql_srv(cfg):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session')
|
@pytest.fixture(scope='session')
|
||||||
def cloud_srv(cfg):
|
def vhost_dir_setup(cfg):
|
||||||
|
vhost_dir = cfg.get('cloud vhosts_config_dir')
|
||||||
|
if os.path.isdir(vhost_dir):
|
||||||
|
shutil.rmtree(vhost_dir)
|
||||||
|
os.makedirs(vhost_dir)
|
||||||
|
yield
|
||||||
|
shutil.rmtree(vhost_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session')
|
||||||
|
def cloud_srv(cfg, vhost_dir_setup):
|
||||||
_cloud_srv = CloudService()
|
_cloud_srv = CloudService()
|
||||||
component.getGlobalSiteManager().registerUtility(_cloud_srv, ICloudService)
|
component.getGlobalSiteManager().registerUtility(_cloud_srv, ICloudService)
|
||||||
return _cloud_srv
|
return _cloud_srv
|
||||||
|
@ -350,7 +360,7 @@ _new_user_id_counter = 10001
|
||||||
@pytest.fixture # noqa: E302
|
@pytest.fixture # noqa: E302
|
||||||
def new_user(client, g_admin_ctx, ldap_srv_session): # noqa: F811
|
def new_user(client, g_admin_ctx, ldap_srv_session): # noqa: F811
|
||||||
global _new_user_id_counter
|
global _new_user_id_counter
|
||||||
uid = 'test_' + str(_new_user_id_counter)
|
uid = 'test' + str(_new_user_id_counter)
|
||||||
_new_user_id_counter += 1
|
_new_user_id_counter += 1
|
||||||
status, data = client.post('/api/members', json={
|
status, data = client.post('/api/members', json={
|
||||||
'uid': uid,
|
'uid': uid,
|
||||||
|
|
|
@ -73,3 +73,6 @@ class CeodTestClient:
|
||||||
|
|
||||||
def delete(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
def delete(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||||
return self.request('DELETE', path, principal, need_auth, delegate, **kwargs)
|
return self.request('DELETE', path, principal, need_auth, delegate, **kwargs)
|
||||||
|
|
||||||
|
def put(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||||
|
return self.request('PUT', path, principal, need_auth, delegate, **kwargs)
|
||||||
|
|
Loading…
Reference in New Issue