parent
7a8751fd8f
commit
08a3faaefc
@ -0,0 +1 @@ |
||||
from .entrypoint import cli |
@ -0,0 +1,107 @@ |
||||
import json |
||||
import sys |
||||
from typing import List, Tuple, Dict |
||||
|
||||
import click |
||||
import requests |
||||
|
||||
from ..operation_strings import descriptions as op_desc |
||||
|
||||
|
||||
class Abort(click.ClickException): |
||||
"""Abort silently.""" |
||||
|
||||
def __init__(self, exit_code=1): |
||||
super().__init__('') |
||||
self.exit_code = exit_code |
||||
|
||||
def show(self): |
||||
pass |
||||
|
||||
|
||||
def print_colon_kv(pairs: List[Tuple[str, str]]): |
||||
""" |
||||
Pretty-print a list of key-value pairs such that the key and value |
||||
columns align. |
||||
Example: |
||||
key1: value1 |
||||
key1000: value2 |
||||
""" |
||||
maxlen = max(len(key) for key, val in pairs) |
||||
for key, val in pairs: |
||||
click.echo(key + ': ', nl=False) |
||||
extra_space = ' ' * (maxlen - len(key)) |
||||
click.echo(extra_space, nl=False) |
||||
click.echo(val) |
||||
|
||||
|
||||
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]: |
||||
""" |
||||
Print output to the console while operations are being streamed |
||||
from the server over HTTP. |
||||
Returns the parsed JSON data streamed from the server. |
||||
""" |
||||
if resp.status_code != 200: |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text.rstrip()) |
||||
raise Abort() |
||||
click.echo(op_desc[operations[0]] + '... ', nl=False) |
||||
idx = 0 |
||||
data = [] |
||||
for line in resp.iter_lines(decode_unicode=True, chunk_size=8): |
||||
d = json.loads(line) |
||||
data.append(d) |
||||
if d['status'] == 'aborted': |
||||
click.echo(click.style('ABORTED', fg='red')) |
||||
click.echo('The transaction was rolled back.') |
||||
click.echo('The error was: ' + d['error']) |
||||
click.echo('Please check the ceod logs.') |
||||
sys.exit(1) |
||||
elif d['status'] == 'completed': |
||||
click.echo('Transaction successfully completed.') |
||||
return data |
||||
|
||||
operation = d['operation'] |
||||
oper_failed = False |
||||
err_msg = None |
||||
prefix = 'failed_to_' |
||||
if operation.startswith(prefix): |
||||
operation = operation[len(prefix):] |
||||
oper_failed = True |
||||
# sometimes the operation looks like |
||||
# "failed_to_do_something: error message" |
||||
if ':' in operation: |
||||
operation, err_msg = operation.split(': ', 1) |
||||
|
||||
while idx < len(operations) and operations[idx] != operation: |
||||
click.echo('Skipped') |
||||
idx += 1 |
||||
if idx == len(operations): |
||||
break |
||||
click.echo(op_desc[operations[idx]] + '... ', nl=False) |
||||
if idx == len(operations): |
||||
click.echo('Unrecognized operation: ' + operation) |
||||
continue |
||||
if oper_failed: |
||||
click.echo(click.style('Failed', fg='red')) |
||||
if err_msg is not None: |
||||
click.echo(' Error message: ' + err_msg) |
||||
else: |
||||
click.echo(click.style('Done', fg='green')) |
||||
idx += 1 |
||||
if idx < len(operations): |
||||
click.echo(op_desc[operations[idx]] + '... ', nl=False) |
||||
|
||||
raise Exception('server response ended abruptly') |
||||
|
||||
|
||||
def handle_sync_response(resp: requests.Response): |
||||
""" |
||||
Exit the program if the request was not successful. |
||||
Returns the parsed JSON response. |
||||
""" |
||||
if resp.status_code != 200: |
||||
click.echo('An error occurred:') |
||||
click.echo(resp.text.rstrip()) |
||||
raise Abort() |
||||
return resp.json() |
@ -0,0 +1,67 @@ |
||||
import datetime |
||||
|
||||
|
||||
class Term: |
||||
"""A representation of a term in the CSC LDAP, e.g. 's2021'.""" |
||||
|
||||
seasons = ['w', 's', 'f'] |
||||
|
||||
def __init__(self, s_term: str): |
||||
assert len(s_term) == 5 and s_term[0] in self.seasons and \ |
||||
s_term[1:].isdigit() |
||||
self.s_term = s_term |
||||
|
||||
def __repr__(self): |
||||
return self.s_term |
||||
|
||||
@staticmethod |
||||
def current(): |
||||
"""Get a Term object for the current date.""" |
||||
dt = datetime.datetime.now() |
||||
c = 'w' |
||||
if 5 <= dt.month <= 8: |
||||
c = 's' |
||||
elif 9 <= dt.month: |
||||
c = 'f' |
||||
s_term = c + str(dt.year) |
||||
return Term(s_term) |
||||
|
||||
def __add__(self, other): |
||||
assert type(other) is int and other >= 0 |
||||
c = self.s_term[0] |
||||
season_idx = self.seasons.index(c) |
||||
year = int(self.s_term[1:]) |
||||
year += other // 3 |
||||
season_idx += other % 3 |
||||
if season_idx >= 3: |
||||
year += 1 |
||||
season_idx -= 3 |
||||
s_term = self.seasons[season_idx] + str(year) |
||||
return Term(s_term) |
||||
|
||||
def __eq__(self, other): |
||||
return isinstance(other, Term) and self.s_term == other.s_term |
||||
|
||||
def __lt__(self, other): |
||||
if not isinstance(other, Term): |
||||
return NotImplemented |
||||
c1, c2 = self.s_term[0], other.s_term[0] |
||||
year1, year2 = int(self.s_term[1:]), int(other.s_term[1:]) |
||||
return year1 < year2 or ( |
||||
year1 == year2 and self.seasons.index(c1) < self.seasons.index(c2) |
||||
) |
||||
|
||||
def __gt__(self, other): |
||||
if not isinstance(other, Term): |
||||
return NotImplemented |
||||
c1, c2 = self.s_term[0], other.s_term[0] |
||||
year1, year2 = int(self.s_term[1:]), int(other.s_term[1:]) |
||||
return year1 > year2 or ( |
||||
year1 == year2 and self.seasons.index(c1) > self.seasons.index(c2) |
||||
) |
||||
|
||||
def __ge__(self, other): |
||||
return self > other or self == other |
||||
|
||||
def __le__(self, other): |
||||
return self < other or self == other |
@ -1,3 +1,4 @@ |
||||
from .Config import Config |
||||
from .HTTPClient import HTTPClient |
||||
from .RemoteMailmanService import RemoteMailmanService |
||||
from .Term import Term |
||||
|
@ -1,40 +0,0 @@ |
||||
import datetime |
||||
from typing import List |
||||
|
||||
|
||||
def get_current_term() -> str: |
||||
""" |
||||
Get the current term as formatted in the CSC LDAP (e.g. 's2021'). |
||||
""" |
||||
dt = datetime.datetime.now() |
||||
c = 'w' |
||||
if 5 <= dt.month <= 8: |
||||
c = 's' |
||||
elif 9 <= dt.month: |
||||
c = 'f' |
||||
return c + str(dt.year) |
||||
|
||||
|
||||
def add_term(term: str) -> str: |
||||
""" |
||||
Add one term to the given term and return the string. |
||||
Example: add_term('s2021') -> 'f2021' |
||||
""" |
||||
c = term[0] |
||||
s_year = term[1:] |
||||
if c == 'w': |
||||
return 's' + s_year |
||||
elif c == 's': |
||||
return 'f' + s_year |
||||
year = int(s_year) |
||||
return 'w' + str(year + 1) |
||||
|
||||
|
||||
def get_max_term(terms: List[str]) -> str: |
||||
"""Get the maximum (latest) term.""" |
||||
max_year = max(term[1:] for term in terms) |
||||
if 'f' + max_year in terms: |
||||
return 'f' + max_year |
||||
elif 's' + max_year in terms: |
||||
return 's' + max_year |
||||
return 'w' + max_year |
@ -0,0 +1,136 @@ |
||||
import os |
||||
import re |
||||
import shutil |
||||
|
||||
from click.testing import CliRunner |
||||
|
||||
from ceo.cli import cli |
||||
from ceo_common.model import Term |
||||
|
||||
|
||||
def test_members_get(cli_setup, ldap_user): |
||||
runner = CliRunner() |
||||
result = runner.invoke(cli, ['members', 'get', ldap_user.uid]) |
||||
expected = ( |
||||
f"uid: {ldap_user.uid}\n" |
||||
f"cn: {ldap_user.cn}\n" |
||||
f"program: {ldap_user.program}\n" |
||||
f"UID number: {ldap_user.uid_number}\n" |
||||
f"GID number: {ldap_user.gid_number}\n" |
||||
f"login shell: {ldap_user.login_shell}\n" |
||||
f"home directory: {ldap_user.home_directory}\n" |
||||
f"is a club: {ldap_user.is_club()}\n" |
||||
"forwarding addresses: \n" |
||||
f"terms: {','.join(ldap_user.terms)}\n" |
||||
) |
||||
assert result.exit_code == 0 |
||||
assert result.output == expected |
||||
|
||||
|
||||
def test_members_add(cli_setup): |
||||
runner = CliRunner() |
||||
result = runner.invoke(cli, [ |
||||
'members', 'add', 'test_1', '--cn', 'Test One', '--program', 'Math', |
||||
'--terms', '1', |
||||
], input='y\n') |
||||
expected_pat = re.compile(( |
||||
"^The following user will be created:\n" |
||||
"uid: test_1\n" |
||||
"cn: Test One\n" |
||||
"program: Math\n" |
||||
"member terms: [sfw]\\d{4}\n" |
||||
"forwarding address: test_1@uwaterloo.internal\n" |
||||
"Do you want to continue\\? \\[y/N\\]: y\n" |
||||
"Add user to LDAP... Done\n" |
||||
"Add group to LDAP... Done\n" |
||||
"Add user to Kerberos... Done\n" |
||||
"Create home directory... Done\n" |
||||
"Set forwarding addresses... Done\n" |
||||
"Send welcome message... Done\n" |
||||
"Subscribe to mailing list... Done\n" |
||||
"Announce new user to mailing list... Done\n" |
||||
"Transaction successfully completed.\n" |
||||
"uid: test_1\n" |
||||
"cn: Test One\n" |
||||
"program: Math\n" |
||||
"UID number: \\d{5}\n" |
||||
"GID number: \\d{5}\n" |
||||
"login shell: /bin/bash\n" |
||||
"home directory: [a-z0-9/_-]+/test_1\n" |
||||
"is a club: False\n" |
||||
"forwarding addresses: test_1@uwaterloo.internal\n" |
||||
"terms: [sfw]\\d{4}\n" |
||||
"password: \\S+\n$" |
||||
), re.MULTILINE) |
||||
assert result.exit_code == 0 |
||||
assert expected_pat.match(result.output) is not None |
||||
|
||||
result = runner.invoke(cli, ['members', 'delete', 'test_1'], input='y\n') |
||||
assert result.exit_code == 0 |
||||
|
||||
|
||||
def test_members_modify(cli_setup, ldap_user): |
||||
# The homedir needs to exist so the API can write to ~/.forward |
||||
os.makedirs(ldap_user.home_directory) |
||||
try: |
||||
runner = CliRunner() |
||||
result = runner.invoke(cli, [ |
||||
'members', 'modify', ldap_user.uid, '--login-shell', '/bin/sh', |
||||
'--forwarding-addresses', 'jdoe@test1.internal,jdoe@test2.internal', |
||||
], input='y\n') |
||||
expected = ( |
||||
"Login shell will be set to: /bin/sh\n" |
||||
"~/.forward will be set to: jdoe@test1.internal\n" |
||||
" jdoe@test2.internal\n" |
||||
"Do you want to continue? [y/N]: y\n" |
||||
"Replace login shell... Done\n" |
||||
"Replace forwarding addresses... Done\n" |
||||
"Transaction successfully completed.\n" |
||||
) |
||||
assert result.exit_code == 0 |
||||
assert result.output == expected |
||||
finally: |
||||
shutil.rmtree(ldap_user.home_directory) |
||||
|
||||
|
||||
def test_members_renew(cli_setup, ldap_user, g_admin_ctx): |
||||
# set the user's last term to something really old |
||||
with g_admin_ctx(), ldap_user.ldap_srv.entry_ctx_for_user(ldap_user) as entry: |
||||
entry.term = ['s1999', 'f1999'] |
||||
current_term = Term.current() |
||||
|
||||
runner = CliRunner() |
||||
result = runner.invoke(cli, [ |
||||
'members', 'renew', ldap_user.uid, '--terms', '1', |
||||
], input='y\n') |
||||
expected = ( |
||||
f"The following member terms will be added: {current_term}\n" |
||||
"Do you want to continue? [y/N]: y\n" |
||||
"Done.\n" |
||||
) |
||||
assert result.exit_code == 0 |
||||
assert result.output == expected |
||||
|
||||
runner = CliRunner() |
||||
result = runner.invoke(cli, [ |
||||
'members', 'renew', ldap_user.uid, '--terms', '2', |
||||
], input='y\n') |
||||
expected = ( |
||||
f"The following member terms will be added: {current_term+1},{current_term+2}\n" |
||||
"Do you want to continue? [y/N]: y\n" |
||||
"Done.\n" |
||||
) |
||||
assert result.exit_code == 0 |
||||
assert result.output == expected |
||||
|
||||
|
||||
def test_members_pwreset(cli_setup, ldap_user, krb_user): |
||||
runner = CliRunner() |
||||
result = runner.invoke( |
||||
cli, ['members', 'pwreset', ldap_user.uid], input='y\n') |
||||
expected_pat = re.compile(( |
||||
f"^Are you sure you want to reset {ldap_user.uid}'s password\\? \\[y/N\\]: y\n" |
||||
"New password: \\S+\n$" |
||||
), re.MULTILINE) |
||||
assert result.exit_code == 0 |
||||
assert expected_pat.match(result.output) is not None |
@ -1,42 +1,12 @@ |
||||
from multiprocessing import Process |
||||
import socket |
||||
import sys |
||||
import time |
||||
|
||||
import requests |
||||
|
||||
from ceo_common.model import RemoteMailmanService |
||||
|
||||
|
||||
def test_remote_mailman(cfg, http_client, app, mock_mailman_server, g_syscom): |
||||
port = cfg.get('ceod_port') |
||||
hostname = socket.gethostname() |
||||
|
||||
def server_start(): |
||||
sys.stdout = open('/dev/null', 'w') |
||||
sys.stderr = sys.stdout |
||||
app.run(debug=False, host='0.0.0.0', port=port) |
||||
|
||||
proc = Process(target=server_start) |
||||
proc.start() |
||||
|
||||
for _ in range(5): |
||||
try: |
||||
http_client.get(hostname, '/ping') |
||||
except requests.exceptions.ConnectionError: |
||||
time.sleep(1) |
||||
continue |
||||
break |
||||
|
||||
try: |
||||
mailman_srv = RemoteMailmanService() |
||||
assert mock_mailman_server.subscriptions['csc-general'] == [] |
||||
# RemoteMailmanService -> app -> MailmanService -> MockMailmanServer |
||||
address = 'test_1@csclub.internal' |
||||
mailman_srv.subscribe(address, 'csc-general') |
||||
assert mock_mailman_server.subscriptions['csc-general'] == [address] |
||||
mailman_srv.unsubscribe(address, 'csc-general') |
||||
assert mock_mailman_server.subscriptions['csc-general'] == [] |
||||
finally: |
||||
proc.terminate() |
||||
proc.join() |
||||
def test_remote_mailman(app_process, mock_mailman_server, g_syscom): |
||||
mailman_srv = RemoteMailmanService() |
||||
assert mock_mailman_server.subscriptions['csc-general'] == [] |
||||
# RemoteMailmanService -> app -> MailmanService -> MockMailmanServer |
||||
address = 'test_1@csclub.internal' |
||||
mailman_srv.subscribe(address, 'csc-general') |
||||
assert mock_mailman_server.subscriptions['csc-general'] == [address] |
||||
mailman_srv.unsubscribe(address, 'csc-general') |
||||
assert mock_mailman_server.subscriptions['csc-general'] == [] |
||||
|
@ -0,0 +1,18 @@ |
||||
import os |
||||
|
||||
import pytest |
||||
|
||||
from .utils import krb5ccname_ctx |
||||
|
||||
|
||||
@pytest.fixture(scope='module') |
||||
def cli_setup(app_process): |
||||
# This tells the CLI entrypoint not to register additional zope services. |
||||
os.environ['PYTEST'] = '1' |
||||
|
||||
# Running the client and the server in the same process would be very |
||||
# messy because they would be sharing the same environment variables, |
||||
# Kerberos cache, and registered utilities (via zope). So we're just |
||||
# going to start the app in a child process intead. |
||||
with krb5ccname_ctx('ctdalek'): |
||||
yield |
@ -0,0 +1,34 @@ |
||||
import contextlib |
||||
import os |
||||
import subprocess |
||||
from subprocess import DEVNULL |
||||
import tempfile |
||||
|
||||
|
||||
# map principals to files storing credentials |
||||
_ccaches = {} |
||||
|
||||
|
||||
@contextlib.contextmanager |
||||
def krb5ccname_ctx(principal: str): |
||||
""" |
||||
Temporarily set KRB5CCNAME to a ccache storing credentials |
||||
for the specified user. |
||||
""" |
||||
old_krb5ccname = os.environ['KRB5CCNAME'] |
||||
try: |
||||
if principal not in _ccaches: |
||||
f = tempfile.NamedTemporaryFile() |
||||
os.environ['KRB5CCNAME'] = 'FILE:' + f.name |
||||
args = ['kinit', principal] |
||||
if principal == 'ceod/admin': |
||||
args = ['kinit', '-k', principal] |
||||
subprocess.run( |
||||
args, stdout=DEVNULL, text=True, input='krb5', |
||||
check=True) |
||||
_ccaches[principal] = f |
||||
else: |
||||
os.environ['KRB5CCNAME'] = 'FILE:' + _ccaches[principal].name |
||||
yield |
||||
finally: |
||||
os.environ['KRB5CCNAME'] = old_krb5ccname |
Loading…
Reference in new issue