merge upstream
continuous-integration/drone/pr Build is passing
Details
continuous-integration/drone/pr Build is passing
Details
This commit is contained in:
commit
b63c5b4487
|
@ -0,0 +1,43 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class StreamResponseHandler(ABC):
|
||||||
|
"""
|
||||||
|
An abstract class to handle stream responses from the server.
|
||||||
|
The CLI and TUI should implement a child class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_non_200(self, resp: requests.Response):
|
||||||
|
"""Handle a non-200 response."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def begin(self):
|
||||||
|
"""Begin the transaction."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_aborted(self, err_msg: str):
|
||||||
|
"""Handle an aborted transaction."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_completed(self):
|
||||||
|
"""Handle a completed transaction."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_successful_operation(self):
|
||||||
|
"""Handle a successful operation."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_failed_operation(self, err_msg: Union[str, None]):
|
||||||
|
"""Handle a failed operation."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_skipped_operation(self):
|
||||||
|
"""Handle a skipped operation."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def handle_unrecognized_operation(self, operation: str):
|
||||||
|
"""Handle an unrecognized operation."""
|
|
@ -1,4 +1,41 @@
|
||||||
|
import importlib.resources
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from zope import component
|
||||||
|
|
||||||
from .cli import cli
|
from .cli import cli
|
||||||
|
from .krb_check import krb_check
|
||||||
|
from .tui.start import main as tui_main
|
||||||
|
from ceo_common.interfaces import IConfig, IHTTPClient
|
||||||
|
from ceo_common.model import Config, HTTPClient
|
||||||
|
|
||||||
|
|
||||||
|
def register_services():
|
||||||
|
# Config
|
||||||
|
# This is a hack to determine if we're in the dev env or not
|
||||||
|
if socket.getfqdn().endswith('.csclub.internal'):
|
||||||
|
with importlib.resources.path('tests', 'ceo_dev.ini') as p:
|
||||||
|
config_file = p.__fspath__()
|
||||||
|
else:
|
||||||
|
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini')
|
||||||
|
cfg = Config(config_file)
|
||||||
|
component.provideUtility(cfg, IConfig)
|
||||||
|
|
||||||
|
# HTTPService
|
||||||
|
http_client = HTTPClient()
|
||||||
|
component.provideUtility(http_client, IHTTPClient)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
krb_check()
|
||||||
|
register_services()
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
cli(obj={})
|
||||||
|
else:
|
||||||
|
tui_main()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
cli(obj={})
|
main()
|
||||||
|
|
|
@ -0,0 +1,67 @@
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
import click
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..StreamResponseHandler import StreamResponseHandler
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class CLIStreamResponseHandler(StreamResponseHandler):
|
||||||
|
def __init__(self, operations: List[str]):
|
||||||
|
self.operations = operations
|
||||||
|
self.idx = 0
|
||||||
|
|
||||||
|
def handle_non_200(self, resp: requests.Response):
|
||||||
|
click.echo('An error occurred:')
|
||||||
|
click.echo(resp.text.rstrip())
|
||||||
|
raise Abort()
|
||||||
|
|
||||||
|
def begin(self):
|
||||||
|
click.echo(op_desc[self.operations[0]] + '... ', nl=False)
|
||||||
|
|
||||||
|
def handle_aborted(self, err_msg: str):
|
||||||
|
click.echo(click.style('ABORTED', fg='red'))
|
||||||
|
click.echo('The transaction was rolled back.')
|
||||||
|
click.echo('The error was: ' + err_msg)
|
||||||
|
click.echo('Please check the ceod logs.')
|
||||||
|
|
||||||
|
def handle_completed(self):
|
||||||
|
click.echo('Transaction successfully completed.')
|
||||||
|
|
||||||
|
def _go_to_next_op(self):
|
||||||
|
"""
|
||||||
|
Increment the operation index and print the next operation, if
|
||||||
|
there is one.
|
||||||
|
"""
|
||||||
|
self.idx += 1
|
||||||
|
if self.idx < len(self.operations):
|
||||||
|
click.echo(op_desc[self.operations[self.idx]] + '... ', nl=False)
|
||||||
|
|
||||||
|
def handle_successful_operation(self):
|
||||||
|
click.echo(click.style('Done', fg='green'))
|
||||||
|
self._go_to_next_op()
|
||||||
|
|
||||||
|
def handle_failed_operation(self, err_msg: Union[str, None]):
|
||||||
|
click.echo(click.style('Failed', fg='red'))
|
||||||
|
if err_msg is not None:
|
||||||
|
click.echo(' Error message: ' + err_msg)
|
||||||
|
self._go_to_next_op()
|
||||||
|
|
||||||
|
def handle_skipped_operation(self):
|
||||||
|
click.echo('Skipped')
|
||||||
|
self._go_to_next_op()
|
||||||
|
|
||||||
|
def handle_unrecognized_operation(self, operation: str):
|
||||||
|
click.echo('Unrecognized operation: ' + operation)
|
|
@ -1,32 +1,15 @@
|
||||||
import importlib.resources
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from zope import component
|
|
||||||
|
|
||||||
from ..krb_check import krb_check
|
|
||||||
from .members import members
|
from .members import members
|
||||||
from .groups import groups
|
from .groups import groups
|
||||||
from .updateprograms import updateprograms
|
from .updateprograms import updateprograms
|
||||||
from .mysql import mysql
|
from .mysql import mysql
|
||||||
from .postgresql import postgresql
|
from .postgresql import postgresql
|
||||||
from ceo_common.interfaces import IConfig, IHTTPClient
|
|
||||||
from ceo_common.model import Config, HTTPClient
|
|
||||||
|
|
||||||
|
|
||||||
@click.group()
|
@click.group()
|
||||||
@click.pass_context
|
def cli():
|
||||||
def cli(ctx):
|
pass
|
||||||
# ensure ctx exists and is a dict
|
|
||||||
ctx.ensure_object(dict)
|
|
||||||
|
|
||||||
princ = krb_check()
|
|
||||||
user = princ[:princ.index('@')]
|
|
||||||
ctx.obj['user'] = user
|
|
||||||
|
|
||||||
if os.environ.get('PYTEST') != '1':
|
|
||||||
register_services()
|
|
||||||
|
|
||||||
|
|
||||||
cli.add_command(members)
|
cli.add_command(members)
|
||||||
|
@ -34,19 +17,3 @@ cli.add_command(groups)
|
||||||
cli.add_command(updateprograms)
|
cli.add_command(updateprograms)
|
||||||
cli.add_command(mysql)
|
cli.add_command(mysql)
|
||||||
cli.add_command(postgresql)
|
cli.add_command(postgresql)
|
||||||
|
|
||||||
|
|
||||||
def register_services():
|
|
||||||
# Config
|
|
||||||
# This is a hack to determine if we're in the dev env or not
|
|
||||||
if socket.getfqdn().endswith('.csclub.internal'):
|
|
||||||
with importlib.resources.path('tests', 'ceo_dev.ini') as p:
|
|
||||||
config_file = p.__fspath__()
|
|
||||||
else:
|
|
||||||
config_file = os.environ.get('CEO_CONFIG', '/etc/csc/ceo.ini')
|
|
||||||
cfg = Config(config_file)
|
|
||||||
component.provideUtility(cfg, IConfig)
|
|
||||||
|
|
||||||
# HTTPService
|
|
||||||
http_client = HTTPClient()
|
|
||||||
component.provideUtility(http_client, IHTTPClient)
|
|
||||||
|
|
|
@ -4,15 +4,14 @@ from typing import Dict
|
||||||
import click
|
import click
|
||||||
from zope import component
|
from zope import component
|
||||||
|
|
||||||
from ..utils import http_post, http_get, http_patch, http_delete, get_failed_operations
|
from ..utils import http_post, http_get, http_patch, http_delete, \
|
||||||
from .utils import handle_stream_response, handle_sync_response, print_colon_kv, \
|
get_failed_operations, get_terms_for_new_user, user_dict_lines, \
|
||||||
|
get_adduser_operations
|
||||||
|
from .utils import handle_stream_response, handle_sync_response, print_lines, \
|
||||||
check_if_in_development
|
check_if_in_development
|
||||||
from ceo_common.interfaces import IConfig
|
from ceo_common.interfaces import IConfig
|
||||||
from ceo_common.model import Term
|
from ceo_common.model import Term
|
||||||
from ceod.transactions.members import (
|
from ceod.transactions.members import DeleteMemberTransaction
|
||||||
AddMemberTransaction,
|
|
||||||
DeleteMemberTransaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@click.group(short_help='Perform operations on CSC members and club reps')
|
@click.group(short_help='Perform operations on CSC members and club reps')
|
||||||
|
@ -36,30 +35,12 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address):
|
||||||
cfg = component.getUtility(IConfig)
|
cfg = component.getUtility(IConfig)
|
||||||
uw_domain = cfg.get('uw_domain')
|
uw_domain = cfg.get('uw_domain')
|
||||||
|
|
||||||
current_term = Term.current()
|
terms = get_terms_for_new_user(num_terms)
|
||||||
terms = [current_term + i for i in range(num_terms)]
|
|
||||||
terms = list(map(str, terms))
|
|
||||||
|
|
||||||
|
# TODO: get email address from UWLDAP
|
||||||
if forwarding_address is None:
|
if forwarding_address is None:
|
||||||
forwarding_address = username + '@' + uw_domain
|
forwarding_address = username + '@' + uw_domain
|
||||||
|
|
||||||
click.echo("The following user will be created:")
|
|
||||||
lines = [
|
|
||||||
('uid', username),
|
|
||||||
('cn', cn),
|
|
||||||
]
|
|
||||||
if program is not None:
|
|
||||||
lines.append(('program', program))
|
|
||||||
if clubrep:
|
|
||||||
lines.append(('non-member terms', ','.join(terms)))
|
|
||||||
else:
|
|
||||||
lines.append(('member terms', ','.join(terms)))
|
|
||||||
if forwarding_address != '':
|
|
||||||
lines.append(('forwarding address', forwarding_address))
|
|
||||||
print_colon_kv(lines)
|
|
||||||
|
|
||||||
click.confirm('Do you want to continue?', abort=True)
|
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
'uid': username,
|
'uid': username,
|
||||||
'cn': cn,
|
'cn': cn,
|
||||||
|
@ -72,10 +53,14 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address):
|
||||||
body['terms'] = terms
|
body['terms'] = terms
|
||||||
if forwarding_address != '':
|
if forwarding_address != '':
|
||||||
body['forwarding_addresses'] = [forwarding_address]
|
body['forwarding_addresses'] = [forwarding_address]
|
||||||
operations = AddMemberTransaction.operations
|
else:
|
||||||
if forwarding_address == '':
|
body['forwarding_addresses'] = []
|
||||||
# don't bother displaying this because it won't be run
|
|
||||||
operations.remove('set_forwarding_addresses')
|
click.echo("The following user will be created:")
|
||||||
|
print_user_lines(body)
|
||||||
|
click.confirm('Do you want to continue?', abort=True)
|
||||||
|
|
||||||
|
operations = get_adduser_operations(body)
|
||||||
|
|
||||||
resp = http_post('/api/members', json=body)
|
resp = http_post('/api/members', json=body)
|
||||||
data = handle_stream_response(resp, operations)
|
data = handle_stream_response(resp, operations)
|
||||||
|
@ -89,30 +74,9 @@ def add(username, cn, program, num_terms, clubrep, forwarding_address):
|
||||||
'send the user their password.', fg='yellow'))
|
'send the user their password.', fg='yellow'))
|
||||||
|
|
||||||
|
|
||||||
def print_user_lines(result: Dict):
|
def print_user_lines(d: Dict):
|
||||||
"""Pretty-print a user JSON response."""
|
"""Pretty-print a serialized User."""
|
||||||
lines = [
|
print_lines(user_dict_lines(d))
|
||||||
('uid', result['uid']),
|
|
||||||
('cn', result['cn']),
|
|
||||||
('program', result.get('program', 'Unknown')),
|
|
||||||
('UID number', result['uid_number']),
|
|
||||||
('GID number', result['gid_number']),
|
|
||||||
('login shell', result['login_shell']),
|
|
||||||
('home directory', result['home_directory']),
|
|
||||||
('is a club', result['is_club']),
|
|
||||||
]
|
|
||||||
if 'forwarding_addresses' in result:
|
|
||||||
if len(result['forwarding_addresses']) != 0:
|
|
||||||
lines.append(('forwarding addresses', result['forwarding_addresses'][0]))
|
|
||||||
for address in result['forwarding_addresses'][1:]:
|
|
||||||
lines.append(('', address))
|
|
||||||
if 'terms' in result:
|
|
||||||
lines.append(('terms', ','.join(result['terms'])))
|
|
||||||
if 'non_member_terms' in result:
|
|
||||||
lines.append(('non-member terms', ','.join(result['non_member_terms'])))
|
|
||||||
if 'password' in result:
|
|
||||||
lines.append(('password', result['password']))
|
|
||||||
print_colon_kv(lines)
|
|
||||||
|
|
||||||
|
|
||||||
@members.command(short_help='Get info about a user')
|
@members.command(short_help='Get info about a user')
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import json
|
|
||||||
import socket
|
import socket
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from typing import List, Tuple, Dict
|
from typing import List, Tuple, Dict
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from ..operation_strings import descriptions as op_desc
|
from ..utils import space_colon_kv, generic_handle_stream_response
|
||||||
|
from .CLIStreamResponseHandler import CLIStreamResponseHandler
|
||||||
|
|
||||||
|
|
||||||
class Abort(click.ClickException):
|
class Abort(click.ClickException):
|
||||||
|
@ -21,86 +21,23 @@ class Abort(click.ClickException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def print_lines(lines: List[str]):
|
||||||
|
"""Print multiple lines to stdout."""
|
||||||
|
for line in lines:
|
||||||
|
click.echo(line)
|
||||||
|
|
||||||
|
|
||||||
def print_colon_kv(pairs: List[Tuple[str, str]]):
|
def print_colon_kv(pairs: List[Tuple[str, str]]):
|
||||||
"""
|
"""
|
||||||
Pretty-print a list of key-value pairs such that the key and value
|
Pretty-print a list of key-value pairs.
|
||||||
columns align.
|
|
||||||
Example:
|
|
||||||
key1: value1
|
|
||||||
key1000: value2
|
|
||||||
"""
|
"""
|
||||||
maxlen = max(len(key) for key, val in pairs)
|
for line in space_colon_kv(pairs):
|
||||||
for key, val in pairs:
|
click.echo(line)
|
||||||
if key != '':
|
|
||||||
click.echo(key + ': ', nl=False)
|
|
||||||
else:
|
|
||||||
# assume this is a continuation from the previous line
|
|
||||||
click.echo(' ', 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]:
|
def handle_stream_response(resp: requests.Response, operations: List[str]) -> List[Dict]:
|
||||||
"""
|
handler = CLIStreamResponseHandler(operations)
|
||||||
Print output to the console while operations are being streamed
|
return generic_handle_stream_response(resp, operations, handler)
|
||||||
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':
|
|
||||||
if idx < len(operations):
|
|
||||||
click.echo('Skipped')
|
|
||||||
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):
|
def handle_sync_response(resp: requests.Response):
|
||||||
|
|
|
@ -3,17 +3,28 @@ import subprocess
|
||||||
import gssapi
|
import gssapi
|
||||||
|
|
||||||
|
|
||||||
|
_username = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_username():
|
||||||
|
"""Get the user currently logged into CEO."""
|
||||||
|
return _username
|
||||||
|
|
||||||
|
|
||||||
def krb_check():
|
def krb_check():
|
||||||
"""
|
"""
|
||||||
Spawns a `kinit` process if no credentials are available or the
|
Spawns a `kinit` process if no credentials are available or the
|
||||||
credentials have expired.
|
credentials have expired.
|
||||||
Returns the principal string 'user@REALM'.
|
Stores the username for later use by get_username().
|
||||||
"""
|
"""
|
||||||
|
global _username
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
try:
|
try:
|
||||||
creds = gssapi.Credentials(usage='initiate')
|
creds = gssapi.Credentials(usage='initiate')
|
||||||
result = creds.inquire()
|
result = creds.inquire()
|
||||||
return str(result.name)
|
princ = str(result.name)
|
||||||
|
_username = princ[:princ.index('@')]
|
||||||
|
return
|
||||||
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError):
|
except (gssapi.raw.misc.GSSError, gssapi.raw.exceptions.ExpiredCredentialsError):
|
||||||
kinit()
|
kinit()
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
from asciimatics.exceptions import NextScene
|
||||||
|
from asciimatics.widgets import Frame, Layout, Button, Divider, Label
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmView(Frame):
|
||||||
|
def __init__(self, screen, width, height, model):
|
||||||
|
super().__init__(
|
||||||
|
screen,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
can_scroll=False,
|
||||||
|
on_load=self._on_load,
|
||||||
|
title='Confirmation',
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
|
||||||
|
def _add_buttons(self):
|
||||||
|
layout = Layout([100])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Divider())
|
||||||
|
|
||||||
|
layout = Layout([1, 1])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Button('No', self._back), 0)
|
||||||
|
layout.add_widget(Button('Yes', self._next), 1)
|
||||||
|
|
||||||
|
def _add_line(self, text: str = ''):
|
||||||
|
layout = Layout([100])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Label(text, align='^'))
|
||||||
|
|
||||||
|
def _add_pair(self, key: str, val: str):
|
||||||
|
layout = Layout([10, 1, 10])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Label(key + ':', align='>'), 0)
|
||||||
|
layout.add_widget(Label(val, align='<'), 2)
|
||||||
|
|
||||||
|
def _on_load(self):
|
||||||
|
for _ in range(2):
|
||||||
|
self._add_line()
|
||||||
|
for line in self._model.confirm_lines:
|
||||||
|
if isinstance(line, str):
|
||||||
|
self._add_line(line)
|
||||||
|
else:
|
||||||
|
# assume tuple
|
||||||
|
key, val = line
|
||||||
|
self._add_pair(key, val)
|
||||||
|
# fill the rest of the space
|
||||||
|
self.add_layout(Layout([100], fill_frame=True))
|
||||||
|
|
||||||
|
self._add_buttons()
|
||||||
|
self.fix()
|
||||||
|
|
||||||
|
def _back(self):
|
||||||
|
raise NextScene(self._model.scene_stack.pop())
|
||||||
|
|
||||||
|
def _next(self):
|
||||||
|
self._model.scene_stack.append('Confirm')
|
||||||
|
raise NextScene('Transaction')
|
|
@ -0,0 +1,12 @@
|
||||||
|
class Model:
|
||||||
|
"""A convenient place to share data beween views."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# simple key-value pairs
|
||||||
|
self.screen = None
|
||||||
|
self.title = None
|
||||||
|
self.for_member = True
|
||||||
|
self.scene_stack = []
|
||||||
|
self.confirm_lines = None
|
||||||
|
self.operations = None
|
||||||
|
self.deferred_req = None
|
|
@ -0,0 +1,98 @@
|
||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
|
from asciimatics.widgets import Label, Button, Layout, Frame
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .Model import Model
|
||||||
|
from ..StreamResponseHandler import StreamResponseHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TUIStreamResponseHandler(StreamResponseHandler):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: Model,
|
||||||
|
labels: Dict[str, Label],
|
||||||
|
next_btn: Button,
|
||||||
|
msg_layout: Layout,
|
||||||
|
frame: Frame,
|
||||||
|
):
|
||||||
|
self.screen = model.screen
|
||||||
|
self.operations = model.operations
|
||||||
|
self.idx = 0
|
||||||
|
self.labels = labels
|
||||||
|
self.next_btn = next_btn
|
||||||
|
self.msg_layout = msg_layout
|
||||||
|
self.frame = frame
|
||||||
|
self.error_messages = []
|
||||||
|
|
||||||
|
def _update(self):
|
||||||
|
# Since we're running in a separate thread, we need to force the
|
||||||
|
# screen to update. See
|
||||||
|
# https://github.com/peterbrittain/asciimatics/issues/56
|
||||||
|
self.frame.fix()
|
||||||
|
self.screen.force_update()
|
||||||
|
|
||||||
|
def _enable_next_btn(self):
|
||||||
|
self.next_btn.disabled = False
|
||||||
|
self.frame.reset()
|
||||||
|
|
||||||
|
def _show_msg(self, msg: str = ''):
|
||||||
|
for line in msg.splitlines():
|
||||||
|
self.msg_layout.add_widget(Label(line, align='^'))
|
||||||
|
|
||||||
|
def _abort(self):
|
||||||
|
for operation in self.operations[self.idx:]:
|
||||||
|
self.labels[operation].text = 'ABORTED'
|
||||||
|
self._enable_next_btn()
|
||||||
|
|
||||||
|
def handle_non_200(self, resp: requests.Response):
|
||||||
|
self._abort()
|
||||||
|
self._show_msg('An error occurred:')
|
||||||
|
self._show_msg(resp.text)
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def begin(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_aborted(self, err_msg: str):
|
||||||
|
self._abort()
|
||||||
|
self._show_msg('The transaction was rolled back.')
|
||||||
|
self._show_msg('The error was:')
|
||||||
|
self._show_msg(err_msg)
|
||||||
|
self._show_msg('Please check the ceod logs.')
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def handle_completed(self):
|
||||||
|
self._show_msg('Transaction successfully completed.')
|
||||||
|
if len(self.error_messages) > 0:
|
||||||
|
self._show_msg('There were some errors, please check the '
|
||||||
|
'ceod logs.')
|
||||||
|
# we don't have enough space in the TUI to actually
|
||||||
|
# show the error messages
|
||||||
|
self._enable_next_btn()
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def handle_successful_operation(self):
|
||||||
|
operation = self.operations[self.idx]
|
||||||
|
self.labels[operation].text = 'Done'
|
||||||
|
self.idx += 1
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def handle_failed_operation(self, err_msg: Union[str, None]):
|
||||||
|
operation = self.operations[self.idx]
|
||||||
|
self.labels[operation].text = 'Failed'
|
||||||
|
if err_msg is not None:
|
||||||
|
self.error_messages.append(err_msg)
|
||||||
|
self.idx += 1
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def handle_skipped_operation(self):
|
||||||
|
operation = self.operations[self.idx]
|
||||||
|
self.labels[operation].text = 'Skipped'
|
||||||
|
self.idx += 1
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def handle_unrecognized_operation(self, operation: str):
|
||||||
|
self.error_messages.append('Unrecognized operation: ' + operation)
|
||||||
|
self.idx += 1
|
||||||
|
self._update()
|
|
@ -0,0 +1,81 @@
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from asciimatics.exceptions import NextScene
|
||||||
|
from asciimatics.widgets import Frame, Layout, Button, Divider, Label
|
||||||
|
|
||||||
|
from ..operation_strings import descriptions as op_desc
|
||||||
|
from ..utils import generic_handle_stream_response
|
||||||
|
from .TUIStreamResponseHandler import TUIStreamResponseHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionView(Frame):
|
||||||
|
def __init__(self, screen, width, height, model):
|
||||||
|
super().__init__(
|
||||||
|
screen,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
can_scroll=False,
|
||||||
|
on_load=self._on_load,
|
||||||
|
title='Running Transaction',
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
# map operation names to label widgets
|
||||||
|
self._labels = {}
|
||||||
|
# this is an ugly hack to get around the fact that _on_load()
|
||||||
|
# will be called again when we reset() in the TUIStreamResponseHandler
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
|
def _add_buttons(self):
|
||||||
|
layout = Layout([100])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Divider())
|
||||||
|
|
||||||
|
layout = Layout([1, 1])
|
||||||
|
self.add_layout(layout)
|
||||||
|
self._next_btn = Button('Next', self._next)
|
||||||
|
self._next_btn.disabled = True
|
||||||
|
layout.add_widget(self._next_btn, 1)
|
||||||
|
|
||||||
|
def _add_line(self, text: str = ''):
|
||||||
|
layout = Layout([100])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Label(text, align='^'))
|
||||||
|
|
||||||
|
def _on_load(self):
|
||||||
|
if self._loaded:
|
||||||
|
return
|
||||||
|
self._loaded = True
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
self._add_line()
|
||||||
|
for operation in self._model.operations:
|
||||||
|
desc = op_desc[operation]
|
||||||
|
layout = Layout([10, 1, 10])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Label(desc + '...', align='>'), 0)
|
||||||
|
desc_label = Label('', align='<')
|
||||||
|
layout.add_widget(desc_label, 2)
|
||||||
|
self._labels[operation] = desc_label
|
||||||
|
self._add_line()
|
||||||
|
self._msg_layout = Layout([100])
|
||||||
|
self.add_layout(self._msg_layout)
|
||||||
|
self.add_layout(Layout([100], fill_frame=True))
|
||||||
|
|
||||||
|
self._add_buttons()
|
||||||
|
self.fix()
|
||||||
|
Thread(target=self._do_txn).start()
|
||||||
|
|
||||||
|
def _do_txn(self):
|
||||||
|
resp = self._model.deferred_req()
|
||||||
|
handler = TUIStreamResponseHandler(
|
||||||
|
model=self._model,
|
||||||
|
labels=self._labels,
|
||||||
|
next_btn=self._next_btn,
|
||||||
|
msg_layout=self._msg_layout,
|
||||||
|
frame=self,
|
||||||
|
)
|
||||||
|
generic_handle_stream_response(resp, self._model.operations, handler)
|
||||||
|
|
||||||
|
def _next(self):
|
||||||
|
self._model.scene_stack.clear()
|
||||||
|
raise NextScene('Welcome')
|
|
@ -0,0 +1,57 @@
|
||||||
|
from asciimatics.widgets import Frame, ListBox, Layout, Divider, \
|
||||||
|
Button, Widget
|
||||||
|
from asciimatics.exceptions import NextScene, StopApplication
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeView(Frame):
|
||||||
|
def __init__(self, screen, width, height, model):
|
||||||
|
super().__init__(
|
||||||
|
screen,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
can_scroll=False,
|
||||||
|
title='CSC Electronic Office',
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
self._members_menu_items = [
|
||||||
|
('Add member', 'AddUser'),
|
||||||
|
('Add club rep', 'AddUser'),
|
||||||
|
('Renew member', 'RenewUser'),
|
||||||
|
('Renew club rep', 'RenewUser'),
|
||||||
|
('Get user info', 'GetUserInfo'),
|
||||||
|
('Reset password', 'ResetPassword'),
|
||||||
|
('Modify user', 'ModifyUser'),
|
||||||
|
]
|
||||||
|
self._members_menu = ListBox(
|
||||||
|
Widget.FILL_FRAME,
|
||||||
|
[
|
||||||
|
(desc, i) for i, (desc, view) in
|
||||||
|
enumerate(self._members_menu_items)
|
||||||
|
],
|
||||||
|
name='members',
|
||||||
|
label='Members',
|
||||||
|
on_select=self._members_menu_select,
|
||||||
|
)
|
||||||
|
layout = Layout([100], fill_frame=True)
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(self._members_menu)
|
||||||
|
layout.add_widget(Divider())
|
||||||
|
|
||||||
|
layout = Layout([1, 1, 1])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Button("Quit", self._quit), 2)
|
||||||
|
self.fix()
|
||||||
|
|
||||||
|
def _members_menu_select(self):
|
||||||
|
self.save()
|
||||||
|
item_id = self.data['members']
|
||||||
|
desc, view = self._members_menu_items[item_id]
|
||||||
|
if desc.endswith('club rep'):
|
||||||
|
self._model.for_member = False
|
||||||
|
self._model.title = desc
|
||||||
|
self._model.scene_stack.append('Welcome')
|
||||||
|
raise NextScene(view)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _quit():
|
||||||
|
raise StopApplication("User pressed quit")
|
|
@ -0,0 +1,105 @@
|
||||||
|
from asciimatics.exceptions import NextScene
|
||||||
|
from asciimatics.widgets import Frame, Layout, Text, Button, Divider
|
||||||
|
|
||||||
|
from ...utils import http_get, http_post, defer, user_dict_kv, \
|
||||||
|
get_terms_for_new_user, get_adduser_operations
|
||||||
|
|
||||||
|
|
||||||
|
class AddUserView(Frame):
|
||||||
|
def __init__(self, screen, width, height, model):
|
||||||
|
super().__init__(
|
||||||
|
screen,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
can_scroll=False,
|
||||||
|
on_load=self._on_load,
|
||||||
|
)
|
||||||
|
self._model = model
|
||||||
|
self._username_changed = False
|
||||||
|
layout = Layout([100], fill_frame=True)
|
||||||
|
self.add_layout(layout)
|
||||||
|
self._username = Text(
|
||||||
|
"Username:", "uid",
|
||||||
|
on_change=self._on_username_change,
|
||||||
|
on_blur=self._on_username_blur,
|
||||||
|
)
|
||||||
|
layout.add_widget(self._username)
|
||||||
|
self._full_name = Text("Full name:", "cn")
|
||||||
|
layout.add_widget(self._full_name)
|
||||||
|
self._program = Text("Program:", "program")
|
||||||
|
layout.add_widget(self._program)
|
||||||
|
self._forwarding_address = Text("Forwarding address:", "forwarding_address")
|
||||||
|
layout.add_widget(self._forwarding_address)
|
||||||
|
self._num_terms = Text(
|
||||||
|
"Number of terms:", "num_terms",
|
||||||
|
validator=lambda s: s.isdigit() and s[0] != '0')
|
||||||
|
self._num_terms.value = '1'
|
||||||
|
layout.add_widget(self._num_terms)
|
||||||
|
|
||||||
|
layout = Layout([100])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Divider())
|
||||||
|
|
||||||
|
layout = Layout([1, 1])
|
||||||
|
self.add_layout(layout)
|
||||||
|
layout.add_widget(Button('Back', self._back), 0)
|
||||||
|
layout.add_widget(Button("Next", self._next), 1)
|
||||||
|
self.fix()
|
||||||
|
|
||||||
|
def _on_load(self):
|
||||||
|
self.title = self._model.title
|
||||||
|
|
||||||
|
def _on_username_change(self):
|
||||||
|
self._username_changed = True
|
||||||
|
|
||||||
|
def _on_username_blur(self):
|
||||||
|
if not self._username_changed:
|
||||||
|
return
|
||||||
|
self._username_changed = False
|
||||||
|
username = self._username.value
|
||||||
|
if username == '':
|
||||||
|
return
|
||||||
|
self._get_uwldap_info(username)
|
||||||
|
|
||||||
|
def _get_uwldap_info(self, username):
|
||||||
|
resp = http_get('/api/uwldap/' + username)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return
|
||||||
|
data = resp.json()
|
||||||
|
self._full_name.value = data['cn']
|
||||||
|
self._program.value = data.get('program', '')
|
||||||
|
if data.get('mail_local_addresses'):
|
||||||
|
self._forwarding_address.value = data['mail_local_addresses'][0]
|
||||||
|
|
||||||
|
def _back(self):
|
||||||
|
raise NextScene(self._model.scene_stack.pop())
|
||||||
|
|
||||||
|
def _next(self):
|
||||||
|
self._model.prev_scene = 'AddUser'
|
||||||
|
body = {
|
||||||
|
'uid': self._username.value,
|
||||||
|
'cn': self._full_name.value,
|
||||||
|
}
|
||||||
|
if self._program.value:
|
||||||
|
body['program'] = self._program.value
|
||||||
|
if self._forwarding_address.value:
|
||||||
|
body['forwarding_addresses'] = [self._forwarding_address.value]
|
||||||
|
new_terms = get_terms_for_new_user(int(self._num_terms.value))
|
||||||
|
if self._model.for_member:
|
||||||
|
body['terms'] = new_terms
|
||||||
|
else:
|
||||||
|
body['non_member_terms'] = new_terms
|
||||||
|
pairs = user_dict_kv(body)
|
||||||
|
self._model.confirm_lines = [
|
||||||
|
'The following user will be created:',
|
||||||
|
'',
|
||||||
|
] + pairs + [
|
||||||
|
'',
|
||||||
|
'Are you sure you want to continue?',
|
||||||
|
]
|
||||||
|
|
||||||
|
self._model.deferred_req = defer(http_post, '/api/members', json=body)
|
||||||
|
self._model.operations = get_adduser_operations(body)
|
||||||
|
|
||||||
|
self._model.scene_stack.append('AddUser')
|
||||||
|
raise NextScene('Confirm')
|
|
@ -0,0 +1,46 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from asciimatics.event import KeyboardEvent
|
||||||
|
from asciimatics.exceptions import ResizeScreenError, StopApplication
|
||||||
|
from asciimatics.scene import Scene
|
||||||
|
from asciimatics.screen import Screen
|
||||||
|
|
||||||
|
from .ConfirmView import ConfirmView
|
||||||
|
from .Model import Model
|
||||||
|
from .TransactionView import TransactionView
|
||||||
|
from .WelcomeView import WelcomeView
|
||||||
|
from .members.AddUserView import AddUserView
|
||||||
|
|
||||||
|
|
||||||
|
def unhandled(event):
|
||||||
|
if isinstance(event, KeyboardEvent):
|
||||||
|
c = event.key_code
|
||||||
|
# Stop on 'q' or 'Esc'
|
||||||
|
if c in (113, 27):
|
||||||
|
raise StopApplication("User terminated app")
|
||||||
|
|
||||||
|
|
||||||
|
def screen_wrapper(screen, scene, model):
|
||||||
|
model.screen = screen
|
||||||
|
width = min(screen.width, 90)
|
||||||
|
height = min(screen.height, 24)
|
||||||
|
scenes = [
|
||||||
|
Scene([WelcomeView(screen, width, height, model)], -1, name='Welcome'),
|
||||||
|
Scene([AddUserView(screen, width, height, model)], -1, name='AddUser'),
|
||||||
|
Scene([ConfirmView(screen, width, height, model)], -1, name='Confirm'),
|
||||||
|
Scene([TransactionView(screen, width, height, model)], -1, name='Transaction'),
|
||||||
|
]
|
||||||
|
screen.play(
|
||||||
|
scenes, stop_on_resize=True, start_scene=scene, allow_int=True,
|
||||||
|
unhandled_input=unhandled)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
last_scene = None
|
||||||
|
model = Model()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
Screen.wrapper(screen_wrapper, arguments=[last_scene, model])
|
||||||
|
sys.exit(0)
|
||||||
|
except ResizeScreenError as e:
|
||||||
|
last_scene = e.scene
|
152
ceo/utils.py
152
ceo/utils.py
|
@ -1,9 +1,15 @@
|
||||||
from typing import List, Dict
|
import functools
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import List, Dict, Tuple, Callable
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from zope import component
|
from zope import component
|
||||||
|
|
||||||
|
from .StreamResponseHandler import StreamResponseHandler
|
||||||
from ceo_common.interfaces import IHTTPClient, IConfig
|
from ceo_common.interfaces import IHTTPClient, IConfig
|
||||||
|
from ceo_common.model import Term
|
||||||
|
from ceod.transactions.members import AddMemberTransaction
|
||||||
|
|
||||||
|
|
||||||
def http_request(method: str, path: str, **kwargs) -> requests.Response:
|
def http_request(method: str, path: str, **kwargs) -> requests.Response:
|
||||||
|
@ -11,13 +17,10 @@ def http_request(method: str, path: str, **kwargs) -> requests.Response:
|
||||||
cfg = component.getUtility(IConfig)
|
cfg = component.getUtility(IConfig)
|
||||||
if path.startswith('/api/db'):
|
if path.startswith('/api/db'):
|
||||||
host = cfg.get('ceod_database_host')
|
host = cfg.get('ceod_database_host')
|
||||||
delegate = False
|
|
||||||
else:
|
else:
|
||||||
host = cfg.get('ceod_admin_host')
|
host = cfg.get('ceod_admin_host')
|
||||||
# The forwarded TGT is only needed for endpoints which write to LDAP
|
|
||||||
delegate = method != 'GET'
|
|
||||||
return client.request(
|
return client.request(
|
||||||
host, path, method, delegate=delegate, stream=True, **kwargs)
|
method, host, path, stream=True, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def http_get(path: str, **kwargs) -> requests.Response:
|
def http_get(path: str, **kwargs) -> requests.Response:
|
||||||
|
@ -56,3 +59,142 @@ def get_failed_operations(data: List[Dict]) -> List[str]:
|
||||||
operation = operation[:operation.index(':')]
|
operation = operation[:operation.index(':')]
|
||||||
failed.append(operation)
|
failed.append(operation)
|
||||||
return failed
|
return failed
|
||||||
|
|
||||||
|
|
||||||
|
def space_colon_kv(pairs: List[Tuple[str, str]]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Pretty-format the lines so that the keys and values
|
||||||
|
are aligned into columns.
|
||||||
|
Example:
|
||||||
|
key1: val1
|
||||||
|
key2: val2
|
||||||
|
key1000: val3
|
||||||
|
val4
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
maxlen = max(len(key) for key, val in pairs)
|
||||||
|
for key, val in pairs:
|
||||||
|
if key != '':
|
||||||
|
prefix = key + ': '
|
||||||
|
else:
|
||||||
|
# assume this is a continuation from the previous line
|
||||||
|
prefix = ' '
|
||||||
|
extra_space = ' ' * (maxlen - len(key))
|
||||||
|
line = prefix + extra_space + str(val)
|
||||||
|
lines.append(line)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def get_terms_for_new_user(num_terms: int) -> List[str]:
|
||||||
|
current_term = Term.current()
|
||||||
|
terms = [current_term + i for i in range(num_terms)]
|
||||||
|
return list(map(str, terms))
|
||||||
|
|
||||||
|
|
||||||
|
def user_dict_kv(d: Dict) -> List[Tuple[str]]:
|
||||||
|
"""Pretty-format a serialized User as (key, value) pairs."""
|
||||||
|
pairs = [
|
||||||
|
('uid', d['uid']),
|
||||||
|
('cn', d['cn']),
|
||||||
|
('program', d.get('program', 'Unknown')),
|
||||||
|
]
|
||||||
|
if 'uid_number' in d:
|
||||||
|
pairs.append(('UID number', d['uid_number']))
|
||||||
|
if 'gid_number' in d:
|
||||||
|
pairs.append(('GID number', d['gid_number']))
|
||||||
|
if 'login_shell' in d:
|
||||||
|
pairs.append(('login shell', d['login_shell']))
|
||||||
|
if 'home_directory' in d:
|
||||||
|
pairs.append(('home directory', d['home_directory']))
|
||||||
|
if 'is_club' in d:
|
||||||
|
pairs.append(('is a club', str(d['is_club'])))
|
||||||
|
if 'forwarding_addresses' in d:
|
||||||
|
if len(d['forwarding_addresses']) > 0:
|
||||||
|
pairs.append(('forwarding addresses', d['forwarding_addresses'][0]))
|
||||||
|
for address in d['forwarding_addresses'][1:]:
|
||||||
|
pairs.append(('', address))
|
||||||
|
else:
|
||||||
|
pairs.append(('forwarding addresses', ''))
|
||||||
|
if 'terms' in d:
|
||||||
|
pairs.append(('member terms', ','.join(d['terms'])))
|
||||||
|
if 'non_member_terms' in d:
|
||||||
|
pairs.append(('non-member terms', ','.join(d['non_member_terms'])))
|
||||||
|
if 'password' in d:
|
||||||
|
pairs.append(('password', d['password']))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def user_dict_lines(d: Dict) -> List[str]:
|
||||||
|
"""Pretty-format a serialized User."""
|
||||||
|
return space_colon_kv(user_dict_kv(d))
|
||||||
|
|
||||||
|
|
||||||
|
def get_adduser_operations(body: Dict):
|
||||||
|
operations = AddMemberTransaction.operations.copy()
|
||||||
|
if not body.get('forwarding_addresses'):
|
||||||
|
# don't bother displaying this because it won't be run
|
||||||
|
operations.remove('set_forwarding_addresses')
|
||||||
|
return operations
|
||||||
|
|
||||||
|
|
||||||
|
def generic_handle_stream_response(
|
||||||
|
resp: requests.Response,
|
||||||
|
operations: List[str],
|
||||||
|
handler: StreamResponseHandler,
|
||||||
|
) -> 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:
|
||||||
|
handler.handle_non_200(resp)
|
||||||
|
handler.begin()
|
||||||
|
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':
|
||||||
|
handler.handle_aborted(d['error'])
|
||||||
|
sys.exit(1)
|
||||||
|
elif d['status'] == 'completed':
|
||||||
|
while idx < len(operations):
|
||||||
|
handler.handle_skipped_operation()
|
||||||
|
idx += 1
|
||||||
|
handler.handle_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:
|
||||||
|
handler.handle_skipped_operation()
|
||||||
|
idx += 1
|
||||||
|
if idx == len(operations):
|
||||||
|
handler.handle_unrecognized_operation(operation)
|
||||||
|
continue
|
||||||
|
if oper_failed:
|
||||||
|
handler.handle_failed_operation(err_msg)
|
||||||
|
else:
|
||||||
|
handler.handle_successful_operation()
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
raise Exception('server response ended abruptly')
|
||||||
|
|
||||||
|
|
||||||
|
def defer(f: Callable, *args, **kwargs):
|
||||||
|
"""Defer a function's execution."""
|
||||||
|
@functools.wraps(f)
|
||||||
|
def wrapper():
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
|
@ -4,21 +4,20 @@ from zope.interface import Interface
|
||||||
class IHTTPClient(Interface):
|
class IHTTPClient(Interface):
|
||||||
"""A helper class for HTTP requests to ceod."""
|
"""A helper class for HTTP requests to ceod."""
|
||||||
|
|
||||||
def request(host: str, api_path: str, method: str, delegate: bool, **kwargs):
|
def request(host: str, path: str, method: str, **kwargs):
|
||||||
"""
|
"""
|
||||||
Make an HTTP request.
|
Make an HTTP request.
|
||||||
If `delegate` is True, GSSAPI credentials will be forwarded to the
|
**kwargs are passed to requests.request().
|
||||||
remote.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get(host: str, api_path: str, delegate: bool = True, **kwargs):
|
def get(host: str, path: str, **kwargs):
|
||||||
"""Make a GET request."""
|
"""Make a GET request."""
|
||||||
|
|
||||||
def post(host: str, api_path: str, delegate: bool = True, **kwargs):
|
def post(host: str, path: str, **kwargs):
|
||||||
"""Make a POST request."""
|
"""Make a POST request."""
|
||||||
|
|
||||||
def patch(host: str, api_path: str, delegate: bool = True, **kwargs):
|
def patch(host: str, path: str, **kwargs):
|
||||||
"""Make a PATCH request."""
|
"""Make a PATCH request."""
|
||||||
|
|
||||||
def delete(host: str, api_path: str, delegate: bool = True, **kwargs):
|
def delete(host: str, path: str, **kwargs):
|
||||||
"""Make a DELETE request."""
|
"""Make a DELETE request."""
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import flask
|
import flask
|
||||||
|
from flask import g
|
||||||
import gssapi
|
import gssapi
|
||||||
import requests
|
import requests
|
||||||
from requests_gssapi import HTTPSPNEGOAuth
|
from requests_gssapi import HTTPSPNEGOAuth
|
||||||
|
@ -20,40 +21,51 @@ class HTTPClient:
|
||||||
self.ceod_port = cfg.get('ceod_port')
|
self.ceod_port = cfg.get('ceod_port')
|
||||||
self.base_domain = cfg.get('base_domain')
|
self.base_domain = cfg.get('base_domain')
|
||||||
|
|
||||||
def request(self, host: str, api_path: str, method: str, delegate: bool, **kwargs):
|
def request(self, method: str, host: str, path: str, **kwargs):
|
||||||
# always use the FQDN
|
# always use the FQDN
|
||||||
if '.' not in host:
|
if '.' not in host:
|
||||||
host = host + '.' + self.base_domain
|
host = host + '.' + self.base_domain
|
||||||
|
|
||||||
|
if method == 'GET':
|
||||||
|
# This is the only GET endpoint which requires auth
|
||||||
|
need_auth = path.startswith('/api/members')
|
||||||
|
delegate = False
|
||||||
|
else:
|
||||||
|
need_auth = True
|
||||||
|
delegate = True
|
||||||
|
|
||||||
# SPNEGO
|
# SPNEGO
|
||||||
spnego_kwargs = {
|
if need_auth:
|
||||||
'opportunistic_auth': True,
|
spnego_kwargs = {
|
||||||
'target_name': gssapi.Name('ceod/' + host),
|
'opportunistic_auth': True,
|
||||||
}
|
'target_name': gssapi.Name('ceod/' + host),
|
||||||
if flask.has_request_context() and 'client_token' in flask.g:
|
}
|
||||||
# This is reached when we are the server and the client has forwarded
|
if flask.has_request_context() and 'client_token' in g:
|
||||||
# their credentials to us.
|
# This is reached when we are the server and the client has
|
||||||
spnego_kwargs['creds'] = gssapi.Credentials(token=flask.g.client_token)
|
# forwarded their credentials to us.
|
||||||
if delegate:
|
spnego_kwargs['creds'] = gssapi.Credentials(token=flask.g.client_token)
|
||||||
# This is reached when we are the client and we want to forward our
|
elif delegate:
|
||||||
# credentials to the server.
|
# This is reached when we are the client and we want to
|
||||||
spnego_kwargs['delegate'] = True
|
# forward our credentials to the server.
|
||||||
auth = HTTPSPNEGOAuth(**spnego_kwargs)
|
spnego_kwargs['delegate'] = True
|
||||||
|
auth = HTTPSPNEGOAuth(**spnego_kwargs)
|
||||||
|
else:
|
||||||
|
auth = None
|
||||||
|
|
||||||
return requests.request(
|
return requests.request(
|
||||||
method,
|
method,
|
||||||
f'{self.scheme}://{host}:{self.ceod_port}{api_path}',
|
f'{self.scheme}://{host}:{self.ceod_port}{path}',
|
||||||
auth=auth, **kwargs,
|
auth=auth, **kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
def get(self, host: str, path: str, **kwargs):
|
||||||
return self.request(host, api_path, 'GET', delegate, **kwargs)
|
return self.request('GET', host, path, **kwargs)
|
||||||
|
|
||||||
def post(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
def post(self, host: str, path: str, **kwargs):
|
||||||
return self.request(host, api_path, 'POST', delegate, **kwargs)
|
return self.request('POST', host, path, **kwargs)
|
||||||
|
|
||||||
def patch(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
def patch(self, host: str, path: str, **kwargs):
|
||||||
return self.request(host, api_path, 'PATCH', delegate, **kwargs)
|
return self.request('PATCH', host, path, **kwargs)
|
||||||
|
|
||||||
def delete(self, host: str, api_path: str, delegate: bool = True, **kwargs):
|
def delete(self, host: str, path: str, **kwargs):
|
||||||
return self.request(host, api_path, 'DELETE', delegate, **kwargs)
|
return self.request('DELETE', host, path, **kwargs)
|
||||||
|
|
|
@ -15,8 +15,7 @@ class RemoteMailmanService:
|
||||||
|
|
||||||
def subscribe(self, address: str, mailing_list: str):
|
def subscribe(self, address: str, mailing_list: str):
|
||||||
resp = self.http_client.post(
|
resp = self.http_client.post(
|
||||||
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
self.mailman_host, f'/api/mailman/{mailing_list}/{address}')
|
||||||
delegate=False)
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
if resp.status_code == 409:
|
if resp.status_code == 409:
|
||||||
raise UserAlreadySubscribedError()
|
raise UserAlreadySubscribedError()
|
||||||
|
@ -26,8 +25,7 @@ class RemoteMailmanService:
|
||||||
|
|
||||||
def unsubscribe(self, address: str, mailing_list: str):
|
def unsubscribe(self, address: str, mailing_list: str):
|
||||||
resp = self.http_client.delete(
|
resp = self.http_client.delete(
|
||||||
self.mailman_host, f'/api/mailman/{mailing_list}/{address}',
|
self.mailman_host, f'/api/mailman/{mailing_list}/{address}')
|
||||||
delegate=False)
|
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
if resp.status_code == 404:
|
if resp.status_code == 404:
|
||||||
raise UserNotSubscribedError()
|
raise UserNotSubscribedError()
|
||||||
|
|
|
@ -46,17 +46,21 @@ class MySQLService:
|
||||||
password = gen_password()
|
password = gen_password()
|
||||||
search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'"
|
search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'"
|
||||||
search_for_db = f"SHOW DATABASES LIKE '{username}'"
|
search_for_db = f"SHOW DATABASES LIKE '{username}'"
|
||||||
create_user = f"""
|
# CREATE USER can't be used in a query with multiple statements
|
||||||
CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s;
|
create_user_commands = [
|
||||||
"""
|
f"CREATE USER '{username}'@'localhost' IDENTIFIED BY %(password)s",
|
||||||
|
f"CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s",
|
||||||
|
]
|
||||||
create_database = f"""
|
create_database = f"""
|
||||||
CREATE DATABASE {username};
|
CREATE DATABASE {username};
|
||||||
|
GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'localhost';
|
||||||
GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'%';
|
GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'%';
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with self.mysql_connection() as con, con.cursor() as cursor:
|
with self.mysql_connection() as con, con.cursor() as cursor:
|
||||||
if response_is_empty(search_for_user, con):
|
if response_is_empty(search_for_user, con):
|
||||||
cursor.execute(create_user, {'password': password})
|
for cmd in create_user_commands:
|
||||||
|
cursor.execute(cmd, {'password': password})
|
||||||
if response_is_empty(search_for_db, con):
|
if response_is_empty(search_for_db, con):
|
||||||
cursor.execute(create_database)
|
cursor.execute(create_database)
|
||||||
else:
|
else:
|
||||||
|
@ -67,7 +71,8 @@ class MySQLService:
|
||||||
password = gen_password()
|
password = gen_password()
|
||||||
search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'"
|
search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'"
|
||||||
reset_password = f"""
|
reset_password = f"""
|
||||||
ALTER USER '{username}'@'%' IDENTIFIED BY %(password)s
|
ALTER USER '{username}'@'localhost' IDENTIFIED BY %(password)s;
|
||||||
|
ALTER USER '{username}'@'%' IDENTIFIED BY %(password)s;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
with self.mysql_connection() as con, con.cursor() as cursor:
|
with self.mysql_connection() as con, con.cursor() as cursor:
|
||||||
|
@ -80,6 +85,7 @@ class MySQLService:
|
||||||
def delete_db(self, username: str):
|
def delete_db(self, username: str):
|
||||||
drop_db = f"DROP DATABASE IF EXISTS {username}"
|
drop_db = f"DROP DATABASE IF EXISTS {username}"
|
||||||
drop_user = f"""
|
drop_user = f"""
|
||||||
|
DROP USER IF EXISTS '{username}'@'localhost';
|
||||||
DROP USER IF EXISTS '{username}'@'%';
|
DROP USER IF EXISTS '{username}'@'%';
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
14
gen_cred.py
14
gen_cred.py
|
@ -1,14 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from base64 import b64encode
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from ceo_common.krb5.utils import get_fwd_tgt
|
|
||||||
|
|
||||||
if len(sys.argv) != 2:
|
|
||||||
print(f'Usage: {sys.argv[0]} <ceod hostname>', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
b = get_fwd_tgt('ceod/' + sys.argv[1])
|
|
||||||
with open('cred', 'wb') as f:
|
|
||||||
f.write(b64encode(b))
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
asciimatics==1.13.0
|
||||||
click==8.0.1
|
click==8.0.1
|
||||||
Flask==2.0.1
|
Flask==2.0.1
|
||||||
gssapi==1.6.14
|
gssapi==1.6.14
|
||||||
|
|
|
@ -45,6 +45,7 @@ def test_groups(cli_setup, ldap_user):
|
||||||
f"Are you sure you want to add {ldap_user.uid} to test_group_1? [y/N]: y\n"
|
f"Are you sure you want to add {ldap_user.uid} to test_group_1? [y/N]: y\n"
|
||||||
"Add user to group... Done\n"
|
"Add user to group... Done\n"
|
||||||
"Add user to auxiliary groups... Skipped\n"
|
"Add user to auxiliary groups... Skipped\n"
|
||||||
|
"Subscribe user to auxiliary mailing lists... Skipped\n"
|
||||||
"Transaction successfully completed.\n"
|
"Transaction successfully completed.\n"
|
||||||
"Added to groups: test_group_1\n"
|
"Added to groups: test_group_1\n"
|
||||||
)
|
)
|
||||||
|
@ -65,6 +66,7 @@ def test_groups(cli_setup, ldap_user):
|
||||||
f"Are you sure you want to remove {ldap_user.uid} from test_group_1? [y/N]: y\n"
|
f"Are you sure you want to remove {ldap_user.uid} from test_group_1? [y/N]: y\n"
|
||||||
"Remove user from group... Done\n"
|
"Remove user from group... Done\n"
|
||||||
"Remove user from auxiliary groups... Skipped\n"
|
"Remove user from auxiliary groups... Skipped\n"
|
||||||
|
"Unsubscribe user from auxiliary mailing lists... Skipped\n"
|
||||||
"Transaction successfully completed.\n"
|
"Transaction successfully completed.\n"
|
||||||
"Removed from groups: test_group_1\n"
|
"Removed from groups: test_group_1\n"
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,15 +12,16 @@ def test_members_get(cli_setup, ldap_user):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ['members', 'get', ldap_user.uid])
|
result = runner.invoke(cli, ['members', 'get', ldap_user.uid])
|
||||||
expected = (
|
expected = (
|
||||||
f"uid: {ldap_user.uid}\n"
|
f"uid: {ldap_user.uid}\n"
|
||||||
f"cn: {ldap_user.cn}\n"
|
f"cn: {ldap_user.cn}\n"
|
||||||
f"program: {ldap_user.program}\n"
|
f"program: {ldap_user.program}\n"
|
||||||
f"UID number: {ldap_user.uid_number}\n"
|
f"UID number: {ldap_user.uid_number}\n"
|
||||||
f"GID number: {ldap_user.gid_number}\n"
|
f"GID number: {ldap_user.gid_number}\n"
|
||||||
f"login shell: {ldap_user.login_shell}\n"
|
f"login shell: {ldap_user.login_shell}\n"
|
||||||
f"home directory: {ldap_user.home_directory}\n"
|
f"home directory: {ldap_user.home_directory}\n"
|
||||||
f"is a club: {ldap_user.is_club()}\n"
|
f"is a club: {ldap_user.is_club()}\n"
|
||||||
f"terms: {','.join(ldap_user.terms)}\n"
|
"forwarding addresses: \n"
|
||||||
|
f"member terms: {','.join(ldap_user.terms)}\n"
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.output == expected
|
assert result.output == expected
|
||||||
|
@ -34,11 +35,11 @@ def test_members_add(cli_setup):
|
||||||
], input='y\n')
|
], input='y\n')
|
||||||
expected_pat = re.compile((
|
expected_pat = re.compile((
|
||||||
"^The following user will be created:\n"
|
"^The following user will be created:\n"
|
||||||
"uid: test_1\n"
|
"uid: test_1\n"
|
||||||
"cn: Test One\n"
|
"cn: Test One\n"
|
||||||
"program: Math\n"
|
"program: Math\n"
|
||||||
"member terms: [sfw]\\d{4}\n"
|
"forwarding addresses: test_1@uwaterloo.internal\n"
|
||||||
"forwarding address: test_1@uwaterloo.internal\n"
|
"member terms: [sfw]\\d{4}\n"
|
||||||
"Do you want to continue\\? \\[y/N\\]: y\n"
|
"Do you want to continue\\? \\[y/N\\]: y\n"
|
||||||
"Add user to LDAP... Done\n"
|
"Add user to LDAP... Done\n"
|
||||||
"Add group to LDAP... Done\n"
|
"Add group to LDAP... Done\n"
|
||||||
|
@ -58,7 +59,7 @@ def test_members_add(cli_setup):
|
||||||
"home directory: [a-z0-9/_-]+/test_1\n"
|
"home directory: [a-z0-9/_-]+/test_1\n"
|
||||||
"is a club: False\n"
|
"is a club: False\n"
|
||||||
"forwarding addresses: test_1@uwaterloo.internal\n"
|
"forwarding addresses: test_1@uwaterloo.internal\n"
|
||||||
"terms: [sfw]\\d{4}\n"
|
"member terms: [sfw]\\d{4}\n"
|
||||||
"password: \\S+\n$"
|
"password: \\S+\n$"
|
||||||
), re.MULTILINE)
|
), re.MULTILINE)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
|
@ -392,16 +392,13 @@ def app_process(cfg, app, http_client):
|
||||||
proc.start()
|
proc.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Currently the HTTPClient uses SPNEGO for all requests,
|
for i in range(5):
|
||||||
# even GETs
|
try:
|
||||||
with gssapi_token_ctx('ctdalek'):
|
http_client.get(hostname, '/ping')
|
||||||
for i in range(5):
|
except requests.exceptions.ConnectionError:
|
||||||
try:
|
time.sleep(1)
|
||||||
http_client.get(hostname, '/ping', delegate=False)
|
continue
|
||||||
except requests.exceptions.ConnectionError:
|
break
|
||||||
time.sleep(1)
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
assert i != 5, 'Timed out'
|
assert i != 5, 'Timed out'
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
|
|
@ -46,12 +46,14 @@ class CeodTestClient:
|
||||||
headers = list(req.prepare().headers.items())
|
headers = list(req.prepare().headers.items())
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def request(self, method: str, path: str, principal: str, delegate: bool, **kwargs):
|
def request(self, method, path, principal, need_auth, delegate, **kwargs):
|
||||||
# make sure that we're not already in a Flask context
|
# make sure that we're not already in a Flask context
|
||||||
assert not flask.has_app_context()
|
assert not flask.has_app_context()
|
||||||
if principal is None:
|
if need_auth:
|
||||||
principal = self.syscom_principal
|
principal = principal or self.syscom_principal
|
||||||
headers = self.get_headers(principal, delegate)
|
headers = self.get_headers(principal, delegate)
|
||||||
|
else:
|
||||||
|
headers = []
|
||||||
resp = self.client.open(path, method=method, headers=headers, **kwargs)
|
resp = self.client.open(path, method=method, headers=headers, **kwargs)
|
||||||
status = int(resp.status.split(' ', 1)[0])
|
status = int(resp.status.split(' ', 1)[0])
|
||||||
if resp.headers['content-type'] == 'application/json':
|
if resp.headers['content-type'] == 'application/json':
|
||||||
|
@ -60,14 +62,14 @@ class CeodTestClient:
|
||||||
data = [json.loads(line) for line in resp.data.splitlines()]
|
data = [json.loads(line) for line in resp.data.splitlines()]
|
||||||
return status, data
|
return status, data
|
||||||
|
|
||||||
def get(self, path, principal=None, delegate=True, **kwargs):
|
def get(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||||
return self.request('GET', path, principal, delegate, **kwargs)
|
return self.request('GET', path, principal, need_auth, delegate, **kwargs)
|
||||||
|
|
||||||
def post(self, path, principal=None, delegate=True, **kwargs):
|
def post(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||||
return self.request('POST', path, principal, delegate, **kwargs)
|
return self.request('POST', path, principal, need_auth, delegate, **kwargs)
|
||||||
|
|
||||||
def patch(self, path, principal=None, delegate=True, **kwargs):
|
def patch(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||||
return self.request('PATCH', path, principal, delegate, **kwargs)
|
return self.request('PATCH', path, principal, need_auth, delegate, **kwargs)
|
||||||
|
|
||||||
def delete(self, path, principal=None, delegate=True, **kwargs):
|
def delete(self, path, principal=None, need_auth=True, delegate=True, **kwargs):
|
||||||
return self.request('DELETE', path, principal, delegate, **kwargs)
|
return self.request('DELETE', path, principal, need_auth, delegate, **kwargs)
|
||||||
|
|
Loading…
Reference in New Issue