From 33323fd1128c3bf38e6a7c4ea682450473fcb793 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Sat, 11 Sep 2021 13:33:43 -0400 Subject: [PATCH 1/4] Add database CLI (#15) Closes #12 Co-authored-by: Andrew Wang Co-authored-by: Max Erenberg Reviewed-on: https://git.csclub.uwaterloo.ca/public/pyceo/pulls/15 Co-authored-by: Andrew Wang Co-committed-by: Andrew Wang --- .drone/coffee-setup.sh | 2 +- README.md | 2 +- ceo/cli/database.py | 104 ++++++++++++++++++++++++++++ ceo/cli/entrypoint.py | 4 ++ ceo/cli/mysql.py | 26 +++++++ ceo/cli/postgresql.py | 26 +++++++ ceo/cli/utils.py | 11 +++ ceod/api/database.py | 4 +- ceod/db/MySQLService.py | 30 ++++---- ceod/db/PostgreSQLService.py | 2 + ceod/model/User.py | 13 ++-- tests/ceo/cli/test_db_mysql.py | 82 ++++++++++++++++++++++ tests/ceo/cli/test_db_postgresql.py | 84 ++++++++++++++++++++++ tests/ceo_dev.ini | 7 ++ tests/ceod_dev.ini | 2 +- 15 files changed, 373 insertions(+), 26 deletions(-) create mode 100644 ceo/cli/database.py create mode 100644 ceo/cli/mysql.py create mode 100644 ceo/cli/postgresql.py create mode 100644 tests/ceo/cli/test_db_mysql.py create mode 100644 tests/ceo/cli/test_db_postgresql.py diff --git a/.drone/coffee-setup.sh b/.drone/coffee-setup.sh index e84d137..cc991c4 100755 --- a/.drone/coffee-setup.sh +++ b/.drone/coffee-setup.sh @@ -30,7 +30,7 @@ host all postgres 0.0.0.0/0 md5 local all all peer host all all localhost md5 -local sameuser all md5 +local sameuser all peer host sameuser all 0.0.0.0/0 md5 EOF grep -Eq "^listen_addresses = '*'$" $POSTGRES_DIR/postgresql.conf || \ diff --git a/README.md b/README.md index 2a8e1b9..0ad696c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ host all postgres 0.0.0.0/0 md5 local all all peer host all all localhost md5 -local sameuser all md5 +local sameuser all peer host sameuser all 0.0.0.0/0 md5 ``` **Warning**: in prod, the postgres user should only be allowed to connect locally, diff --git a/ceo/cli/database.py b/ceo/cli/database.py new file mode 100644 index 0000000..6a7571b --- /dev/null +++ b/ceo/cli/database.py @@ -0,0 +1,104 @@ +import os +from typing import Dict + +import click +from zope import component + +from ..utils import http_post, http_get, http_delete +from .utils import handle_sync_response, check_file_path, check_if_in_development +from ceo_common.interfaces import IConfig + + +def db_cli_response(filename: str, user_dict: Dict, password: str, db_type: str, op: str): + cfg_srv = component.getUtility(IConfig) + db_host = cfg_srv.get(f'{db_type}_host') + username = user_dict['uid'] + if db_type == 'mysql': + db_type_name = 'MySQL' + db_cli_local_cmd = f'mysql {username}' + db_cli_cmd = f'mysql {username} -h {db_host} -u {username} -p' + else: + db_type_name = 'PostgreSQL' + db_cli_local_cmd = f'psql {username}' + db_cli_cmd = f'psql -d {username} -h {db_host} -U {username} -W' + username = user_dict['uid'] + info = f"""{db_type_name} Database Information for {username} + +Your new {db_type_name} database was created. To connect, use the following options: + +Database: {username} +Username: {username} +Password: {password} +Host: {db_host} + +On {db_host} to connect using the {db_type_name} command-line client use + + {db_cli_local_cmd} + +From other CSC machines you can connect using + + {db_cli_cmd} +""" + wrote_to_file = False + try: + # TODO: use phosphoric-acid to write to file (phosphoric-acid makes + # internal API call to caffeine) + with click.open_file(filename, "w") as f: + f.write(info) + os.chown(filename, user_dict['uid_number'], user_dict['gid_number']) + os.chmod(filename, 0o640) + wrote_to_file = True + except PermissionError: + pass + if op == 'create': + click.echo(f'{db_type_name} database created.') + click.echo(f'''Connection Information: + +Database: {username} +Username: {username} +Password: {password} +Host: {db_host}''') + if wrote_to_file: + click.echo(f"\nThese settings have been written to {filename}.") + else: + click.echo(f"\nWe were unable to write these settings to {filename}.") + + +def create(username: str, db_type: str): + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + resp = http_get(f'/api/members/{username}') + user_dict = handle_sync_response(resp) + click.confirm(f'Are you sure you want to create a {db_type_name} database for {username}?', abort=True) + + info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") + check_file_path(info_file_path) + + resp = http_post(f'/api/db/{db_type}/{username}') + result = handle_sync_response(resp) + password = result['password'] + + db_cli_response(info_file_path, user_dict, password, db_type, 'create') + + +def pwreset(username: str, db_type: str): + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + resp = http_get(f'/api/members/{username}') + user_dict = handle_sync_response(resp) + click.confirm(f'Are you sure you want reset the {db_type_name} password for {username}?', abort=True) + + info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") + check_file_path(info_file_path) + + resp = http_post(f'/api/db/{db_type}/{username}/pwreset') + result = handle_sync_response(resp) + password = result['password'] + + db_cli_response(info_file_path, user_dict, password, db_type, 'pwreset') + + +def delete(username: str, db_type: str): + check_if_in_development() + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + click.confirm(f"Are you sure you want to delete the {db_type_name} database for {username}?", abort=True) + resp = http_delete(f'/api/db/{db_type}/{username}') + handle_sync_response(resp) diff --git a/ceo/cli/entrypoint.py b/ceo/cli/entrypoint.py index e686133..b0edd59 100644 --- a/ceo/cli/entrypoint.py +++ b/ceo/cli/entrypoint.py @@ -4,6 +4,8 @@ from .members import members from .groups import groups from .positions import positions from .updateprograms import updateprograms +from .mysql import mysql +from .postgresql import postgresql @click.group() @@ -15,3 +17,5 @@ cli.add_command(members) cli.add_command(groups) cli.add_command(positions) cli.add_command(updateprograms) +cli.add_command(mysql) +cli.add_command(postgresql) diff --git a/ceo/cli/mysql.py b/ceo/cli/mysql.py new file mode 100644 index 0000000..c7e099f --- /dev/null +++ b/ceo/cli/mysql.py @@ -0,0 +1,26 @@ +import click + +from .database import create as db_create, pwreset as db_pwreset, delete as db_delete + + +@click.group(short_help='Perform operations on MySQL') +def mysql(): + pass + + +@mysql.command(short_help='Create a MySQL database for a user') +@click.argument('username') +def create(username): + db_create(username, 'mysql') + + +@mysql.command(short_help='Reset the password of a MySQL user') +@click.argument('username') +def pwreset(username): + db_pwreset(username, 'mysql') + + +@mysql.command(short_help="Delete the database of a MySQL user") +@click.argument('username') +def delete(username): + db_delete(username, 'mysql') diff --git a/ceo/cli/postgresql.py b/ceo/cli/postgresql.py new file mode 100644 index 0000000..bad5723 --- /dev/null +++ b/ceo/cli/postgresql.py @@ -0,0 +1,26 @@ +import click + +from .database import create as db_create, pwreset as db_pwreset, delete as db_delete + + +@click.group(short_help='Perform operations on PostgreSQL') +def postgresql(): + pass + + +@postgresql.command(short_help='Create a PostgreSQL database for a user') +@click.argument('username') +def create(username): + db_create(username, 'postgresql') + + +@postgresql.command(short_help='Reset the password of a PostgreSQL user') +@click.argument('username') +def pwreset(username): + db_pwreset(username, 'postgresql') + + +@postgresql.command(short_help="Delete the database of a PostgreSQL user") +@click.argument('username') +def delete(username): + db_delete(username, 'postgresql') diff --git a/ceo/cli/utils.py b/ceo/cli/utils.py index d50f38e..9423a2c 100644 --- a/ceo/cli/utils.py +++ b/ceo/cli/utils.py @@ -1,4 +1,6 @@ import socket +import os + from typing import List, Tuple, Dict import click @@ -50,6 +52,15 @@ def handle_sync_response(resp: requests.Response): return resp.json() +def check_file_path(file): + if os.path.isfile(file): + click.echo(f"{file} will be overwritten") + click.confirm('Do you want to continue?', abort=True) + elif os.path.isdir(file): + click.echo(f"Error: there exists a directory at {file}") + raise Abort() + + def check_if_in_development() -> bool: """Aborts if we are not currently in the dev environment.""" if not socket.getfqdn().endswith('.csclub.internal'): diff --git a/ceod/api/database.py b/ceod/api/database.py index ca81367..94e9dc0 100644 --- a/ceod/api/database.py +++ b/ceod/api/database.py @@ -31,9 +31,9 @@ def db_exception_handler(func): except InvalidUsernameError: return {'error': 'username contains invalid characters'}, 400 except DatabaseConnectionError: - return {'error': 'unable to connect or authenticate to sql server'}, 500 + return {'error': 'unable to connect to sql server'}, 500 except DatabasePermissionError: - return {'error': 'unable to perform action due to permissions'}, 500 + return {'error': 'unable to connect or action failed due to permissions'}, 500 return function diff --git a/ceod/db/MySQLService.py b/ceod/db/MySQLService.py index e6a194d..34bd0bf 100644 --- a/ceod/db/MySQLService.py +++ b/ceod/db/MySQLService.py @@ -10,7 +10,7 @@ from ceod.utils import gen_password from ceod.db.utils import response_is_empty from mysql.connector import connect -from mysql.connector.errors import InterfaceError, ProgrammingError +from mysql.connector.errors import OperationalError, ProgrammingError logger = logger_factory(__name__) @@ -35,9 +35,11 @@ class MySQLService: password=self.auth_password, ) as con: yield con - except InterfaceError as e: + # unable to connect + except OperationalError as e: logger.error(e) raise DatabaseConnectionError() + # invalid credentials / user does not exist / invalid permissions for action except ProgrammingError as e: logger.error(e) raise DatabasePermissionError() @@ -47,24 +49,21 @@ class MySQLService: search_for_user = f"SELECT user FROM mysql.user WHERE user='{username}'" search_for_db = f"SHOW DATABASES LIKE '{username}'" # CREATE USER can't be used in a query with multiple statements - create_user_commands = [ - f"CREATE USER '{username}'@'localhost' IDENTIFIED BY %(password)s", - f"CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s", - ] + create_local_user_cmd = f"CREATE USER '{username}'@'localhost' IDENTIFIED VIA unix_socket" + create_user_cmd = f"CREATE USER '{username}'@'%' IDENTIFIED BY %(password)s" create_database = f""" CREATE DATABASE {username}; - GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'localhost'; + GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'localhost' IDENTIFIED VIA unix_socket; GRANT ALL PRIVILEGES ON {username}.* TO '{username}'@'%'; """ with self.mysql_connection() as con, con.cursor() as cursor: - if response_is_empty(search_for_user, con): - for cmd in create_user_commands: - cursor.execute(cmd, {'password': password}) - if response_is_empty(search_for_db, con): - cursor.execute(create_database) - else: + if not response_is_empty(search_for_user, con): raise UserAlreadyExistsError() + cursor.execute(create_local_user_cmd) + cursor.execute(create_user_cmd, {'password': password}) + if response_is_empty(search_for_db, con): + cursor.execute(create_database) return password def reset_db_passwd(self, username: str) -> str: @@ -76,10 +75,9 @@ class MySQLService: """ with self.mysql_connection() as con, con.cursor() as cursor: - if not response_is_empty(search_for_user, con): - cursor.execute(reset_password, {'password': password}) - else: + if response_is_empty(search_for_user, con): raise UserNotFoundError(username) + cursor.execute(reset_password, {'password': password}) return password def delete_db(self, username: str): diff --git a/ceod/db/PostgreSQLService.py b/ceod/db/PostgreSQLService.py index 3f3cbb8..e01e3e1 100644 --- a/ceod/db/PostgreSQLService.py +++ b/ceod/db/PostgreSQLService.py @@ -39,9 +39,11 @@ class PostgreSQLService: ) con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) yield con + # unable to connect / invalid credentials / user does not exist except OperationalError as e: logger.error(e) raise DatabaseConnectionError() + # invalid permissions for action except ProgrammingError as e: logger.error(e) raise DatabasePermissionError() diff --git a/ceod/model/User.py b/ceod/model/User.py index 5bad209..7b57eeb 100644 --- a/ceod/model/User.py +++ b/ceod/model/User.py @@ -56,7 +56,6 @@ class User: self.ldap_srv = component.getUtility(ILDAPService) self.krb_srv = component.getUtility(IKerberosService) - self.file_srv = component.getUtility(IFileService) def to_dict(self, get_forwarding_addresses: bool = False) -> Dict: data = { @@ -103,10 +102,12 @@ class User: self.krb_srv.change_password(self.uid, password) def create_home_dir(self): - self.file_srv.create_home_dir(self) + file_srv = component.getUtility(IFileService) + file_srv.create_home_dir(self) def delete_home_dir(self): - self.file_srv.delete_home_dir(self) + file_srv = component.getUtility(IFileService) + file_srv.delete_home_dir(self) def subscribe_to_mailing_list(self, mailing_list: str): component.getUtility(IMailmanService).subscribe(self.uid, mailing_list) @@ -163,7 +164,9 @@ class User: self.positions = positions def get_forwarding_addresses(self) -> List[str]: - return self.file_srv.get_forwarding_addresses(self) + file_srv = component.getUtility(IFileService) + return file_srv.get_forwarding_addresses(self) def set_forwarding_addresses(self, addresses: List[str]): - self.file_srv.set_forwarding_addresses(self, addresses) + file_srv = component.getUtility(IFileService) + file_srv.set_forwarding_addresses(self, addresses) diff --git a/tests/ceo/cli/test_db_mysql.py b/tests/ceo/cli/test_db_mysql.py new file mode 100644 index 0000000..6418b0c --- /dev/null +++ b/tests/ceo/cli/test_db_mysql.py @@ -0,0 +1,82 @@ +import os + +from click.testing import CliRunner +from mysql.connector import connect +from mysql.connector.errors import ProgrammingError +import pytest + +from ceo.cli import cli + + +def mysql_attempt_connection(host, username, password): + with connect( + host=host, + user=username, + password=password, + ) as con, con.cursor() as cur: + cur.execute("SHOW DATABASES") + response = cur.fetchall() + assert len(response) == 2 + + with pytest.raises(ProgrammingError): + cur.execute("CREATE DATABASE new_db") + + +def test_mysql(cli_setup, cfg, ldap_user): + runner = CliRunner() + + username = ldap_user.uid + os.makedirs(ldap_user.home_directory) + host = cfg.get("mysql_host") + info_file_path = os.path.join(ldap_user.home_directory, "ceo-mysql-info") + assert not os.path.isfile(info_file_path) + + # create database for user + result = runner.invoke(cli, ['mysql', 'create', username], input='y\n') + assert result.exit_code == 0 + assert os.path.isfile(info_file_path) + + response_arr = result.output.split() + passwd = response_arr[response_arr.index("Password:") + 1] + with open(info_file_path, 'r') as file: + old_info = file.read() + + expected = f"""Are you sure you want to create a MySQL database for {username}? [y/N]: y +MySQL database created. +Connection Information: + +Database: {username} +Username: {username} +Password: {passwd} +Host: {host} + +These settings have been written to {info_file_path}. +""" + + assert result.output == expected + mysql_attempt_connection(host, username, passwd) + + # perform password reset for user + # confirm once to reset password, another to overwrite the file + result = runner.invoke(cli, ['mysql', 'pwreset', username], input="y\ny\n") + assert result.exit_code == 0 + + response_arr = result.output.split() + new_passwd = response_arr[response_arr.index("Password:") + 1] + with open(info_file_path, 'r') as file: + new_info = file.read() + + assert new_passwd != passwd + assert old_info != new_info + mysql_attempt_connection(host, username, new_passwd) + + # delete database and file + result = runner.invoke(cli, ['mysql', 'delete', username], input="y\n") + assert result.exit_code == 0 + + # user should be deleted + with pytest.raises(ProgrammingError): + mysql_attempt_connection(host, username, passwd) + + os.remove(info_file_path) + os.rmdir(ldap_user.home_directory) diff --git a/tests/ceo/cli/test_db_postgresql.py b/tests/ceo/cli/test_db_postgresql.py new file mode 100644 index 0000000..ddd2353 --- /dev/null +++ b/tests/ceo/cli/test_db_postgresql.py @@ -0,0 +1,84 @@ +import pytest +import os + +from click.testing import CliRunner +from ceo.cli import cli + +from psycopg2 import connect, OperationalError, ProgrammingError + + +def psql_attempt_connection(host, username, password): + con = connect( + host=host, + user=username, + password=password, + ) + con.autocommit = True + with con.cursor() as cur: + cur.execute("SELECT datname FROM pg_database") + response = cur.fetchall() + # 3 of the 4 are postgres, template0, template1 + assert len(response) == 4 + with pytest.raises(ProgrammingError): + cur.execute("CREATE DATABASE new_db") + con.close() + + +def test_postgresql(cli_setup, cfg, ldap_user): + runner = CliRunner() + + username = ldap_user.uid + os.makedirs(ldap_user.home_directory) + host = cfg.get("postgresql_host") + info_file_path = os.path.join(ldap_user.home_directory, "ceo-postgresql-info") + assert not os.path.isfile(info_file_path) + + # create database for user + result = runner.invoke(cli, ['postgresql', 'create', username], input='y\n') + assert result.exit_code == 0 + assert os.path.isfile(info_file_path) + + response_arr = result.output.split() + passwd = response_arr[response_arr.index("Password:") + 1] + with open(info_file_path, 'r') as file: + old_info = file.read() + + expected = f"""Are you sure you want to create a PostgreSQL database for {username}? [y/N]: y +PostgreSQL database created. +Connection Information: + +Database: {username} +Username: {username} +Password: {passwd} +Host: {host} + +These settings have been written to {info_file_path}. +""" + + assert result.output == expected + psql_attempt_connection(host, username, passwd) + + # perform password reset for user + # confirm once to reset password, another to overwrite the file + result = runner.invoke(cli, ['postgresql', 'pwreset', username], input="y\ny\n") + assert result.exit_code == 0 + + response_arr = result.output.split() + new_passwd = response_arr[response_arr.index("Password:") + 1] + with open(info_file_path, 'r') as file: + new_info = file.read() + + assert new_passwd != passwd + assert old_info != new_info + psql_attempt_connection(host, username, new_passwd) + + # delete database and file + result = runner.invoke(cli, ['postgresql', 'delete', username], input="y\n") + assert result.exit_code == 0 + + # user should be deleted + with pytest.raises(OperationalError): + psql_attempt_connection(host, username, passwd) + + os.remove(info_file_path) + os.rmdir(ldap_user.home_directory) diff --git a/tests/ceo_dev.ini b/tests/ceo_dev.ini index 978f6f5..08652dc 100644 --- a/tests/ceo_dev.ini +++ b/tests/ceo_dev.ini @@ -5,6 +5,7 @@ uw_domain = uwaterloo.internal [ceod] # this is the host with the ceod/admin Kerberos key admin_host = phosphoric-acid +database_host = coffee use_https = false port = 9987 @@ -12,3 +13,9 @@ port = 9987 required = president,vice-president,sysadmin available = president,vice-president,treasurer,secretary, sysadmin,cro,librarian,imapd,webmaster,offsck + +[mysql] +host = coffee + +[postgresql] +host = coffee diff --git a/tests/ceod_dev.ini b/tests/ceod_dev.ini index f577120..fea17d5 100644 --- a/tests/ceod_dev.ini +++ b/tests/ceod_dev.ini @@ -13,7 +13,7 @@ port = 9987 [ldap] admin_principal = ceod/admin -server_url = ldap://ldap-master.csclub.internal +server_url = ldap://auth1.csclub.internal sasl_realm = CSCLUB.INTERNAL users_base = ou=People,dc=csclub,dc=internal groups_base = ou=Group,dc=csclub,dc=internal From ad38588141dae6fc820690bbd52e10d2bad6606b Mon Sep 17 00:00:00 2001 From: Max Erenberg <> Date: Sat, 11 Sep 2021 17:24:23 -0400 Subject: [PATCH 2/4] use single ListBox in WelcomeView --- ceo/tui/CeoFrame.py | 5 ++- ceo/tui/WelcomeView.py | 89 +++++++++++++++++++----------------------- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/ceo/tui/CeoFrame.py b/ceo/tui/CeoFrame.py index c18e0c4..1a30d21 100644 --- a/ceo/tui/CeoFrame.py +++ b/ceo/tui/CeoFrame.py @@ -110,7 +110,10 @@ class CeoFrame(Frame): layout.add_widget(Divider()) def _back(): - raise NextScene(self._model.scene_stack.pop()) + last_scene = self._model.scene_stack.pop() + if last_scene == 'Welcome': + self._model.reset() + raise NextScene(last_scene) def _next(): if on_next_excl is not None: diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index df800a7..5e4fce1 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -1,5 +1,3 @@ -import functools - from asciimatics.widgets import ListBox, Layout, Divider, Button, Label from asciimatics.exceptions import NextScene, StopApplication @@ -23,40 +21,50 @@ class WelcomeView(CeoFrame): ('Change login shell', 'ChangeLoginShell'), ('Set forwarding addresses', 'SetForwardingAddresses'), ] - members_menu = self._create_menu( - members_menu_items, 'members', self._members_menu_select) groups_menu_items = [ ('Add group', 'AddGroup'), ('Get group members', 'GetGroup'), ('Add member to group', 'AddMemberToGroup'), ('Remove member from group', 'RemoveMemberFromGroup'), ] - groups_menu = self._create_menu(groups_menu_items, 'groups') db_menu_items = [ ('Create MySQL database', 'CreateMySQL'), ('Reset MySQL password', 'ResetMySQLPassword'), ('Create PostgreSQL database', 'CreatePostgreSQL'), ('Reset PostgreSQL password', 'ResetPostgreSQLPassword'), ] - db_menu = self._create_menu( - db_menu_items, 'databases', self._db_menu_select) positions_menu_items = [ ('Get positions', 'GetPositions'), ('Set positions', 'SetPositions'), ] - positions_menu = self._create_menu(positions_menu_items, 'positions') - self._menu_groups = { - 'members': members_menu_items, - 'groups': groups_menu_items, - 'databases': db_menu_items, - 'positions': positions_menu_items, - } - layout = Layout([1, 4, 1], fill_frame=True) + self.menu_items = [ + ('members', members_menu_items), + ('groups', groups_menu_items), + ('databases', db_menu_items), + ('positions', positions_menu_items), + ] + self.menu_items_dict = dict(self.menu_items) + flat_menu_items = [item for name, items in self.menu_items for item in items] + menu = ListBox( + len(flat_menu_items), + [ + (desc, i) for i, (desc, view) in + enumerate(flat_menu_items) + ], + name='menu', + on_select=self._menu_select, + ) + labels = [] + for name, items in self.menu_items: + labels.append(Label(name.capitalize(), align='>')) + for _ in range(len(items) - 1): + labels.append(Label('')) + + layout = Layout([5, 1, 8], fill_frame=True) self.add_layout(layout) - layout.add_widget(members_menu, 1) - layout.add_widget(groups_menu, 1) - layout.add_widget(db_menu, 1) - layout.add_widget(positions_menu, 1) + layout.add_widget(menu, 2) + for label in labels: + layout.add_widget(label, 0) layout = Layout([100]) self.add_layout(layout) @@ -68,37 +76,20 @@ class WelcomeView(CeoFrame): layout.add_widget(Button("Quit", self._quit), 2) self.fix() - def _create_menu(self, menu_items, name, on_select=None): - if on_select is None: - on_select = functools.partial(self._generic_menu_select, name) - return ListBox( - len(menu_items), - [ - (desc, i) for i, (desc, view) in - enumerate(menu_items) - ], - name=name, - label=name.capitalize(), - on_select=on_select, - ) - - def _get_menu_item_desc_view(self, menu_name: str): + def _menu_select(self): self.save() - item_id = self.data[menu_name] - menu_items = self._menu_groups[menu_name] - return menu_items[item_id] - - def _members_menu_select(self): - desc, view = self._get_menu_item_desc_view('members') - if desc.endswith('club rep'): - self._model.is_club_rep = True - self._welcomeview_go_to_next_scene(desc, view) - - def _db_menu_select(self): - pass - - def _generic_menu_select(self, menu_name): - desc, view = self._get_menu_item_desc_view('groups') + item_id = self.data['menu'] + # find which submenu the item belongs to + counter = 0 + for name, items in self.menu_items: + if item_id < counter + len(items): + break + counter += len(items) + submenu_idx = item_id - counter + desc, view = items[submenu_idx] + if name == 'members': + if desc.endswith('club rep'): + self._model.is_club_rep = True self._welcomeview_go_to_next_scene(desc, view) def _welcomeview_go_to_next_scene(self, desc, view): From 155c96c500ac47ee97376967bd5c4e77fcdc4b23 Mon Sep 17 00:00:00 2001 From: Max Erenberg <> Date: Sun, 12 Sep 2021 02:08:15 -0400 Subject: [PATCH 3/4] implement Database views in TUI --- ceo/cli/database.py | 42 ++-------------- ceo/cli/utils.py | 9 ---- ceo/tui/Model.py | 10 ++++ ceo/tui/ResultView.py | 2 +- ceo/tui/WelcomeView.py | 13 +++-- ceo/tui/databases/CreateDatabaseResultView.py | 34 +++++++++++++ ceo/tui/databases/CreateDatabaseView.py | 44 +++++++++++++++++ .../ResetDatabasePasswordResultView.py | 29 +++++++++++ .../databases/ResetDatabasePasswordView.py | 44 +++++++++++++++++ ceo/tui/databases/__init__.py | 1 + ceo/tui/start.py | 8 ++++ ceo/utils.py | 48 +++++++++++++++++++ tests/ceo/cli/test_db_mysql.py | 8 ++-- tests/ceo/cli/test_db_postgresql.py | 7 ++- 14 files changed, 239 insertions(+), 60 deletions(-) create mode 100644 ceo/tui/databases/CreateDatabaseResultView.py create mode 100644 ceo/tui/databases/CreateDatabaseView.py create mode 100644 ceo/tui/databases/ResetDatabasePasswordResultView.py create mode 100644 ceo/tui/databases/ResetDatabasePasswordView.py create mode 100644 ceo/tui/databases/__init__.py diff --git a/ceo/cli/database.py b/ceo/cli/database.py index 6a7571b..dc9bede 100644 --- a/ceo/cli/database.py +++ b/ceo/cli/database.py @@ -4,54 +4,22 @@ from typing import Dict import click from zope import component -from ..utils import http_post, http_get, http_delete -from .utils import handle_sync_response, check_file_path, check_if_in_development +from ..utils import http_post, http_get, http_delete, write_db_creds +from .utils import handle_sync_response, check_if_in_development from ceo_common.interfaces import IConfig def db_cli_response(filename: str, user_dict: Dict, password: str, db_type: str, op: str): cfg_srv = component.getUtility(IConfig) db_host = cfg_srv.get(f'{db_type}_host') - username = user_dict['uid'] if db_type == 'mysql': db_type_name = 'MySQL' - db_cli_local_cmd = f'mysql {username}' - db_cli_cmd = f'mysql {username} -h {db_host} -u {username} -p' else: db_type_name = 'PostgreSQL' - db_cli_local_cmd = f'psql {username}' - db_cli_cmd = f'psql -d {username} -h {db_host} -U {username} -W' - username = user_dict['uid'] - info = f"""{db_type_name} Database Information for {username} - -Your new {db_type_name} database was created. To connect, use the following options: - -Database: {username} -Username: {username} -Password: {password} -Host: {db_host} - -On {db_host} to connect using the {db_type_name} command-line client use - - {db_cli_local_cmd} - -From other CSC machines you can connect using - - {db_cli_cmd} -""" - wrote_to_file = False - try: - # TODO: use phosphoric-acid to write to file (phosphoric-acid makes - # internal API call to caffeine) - with click.open_file(filename, "w") as f: - f.write(info) - os.chown(filename, user_dict['uid_number'], user_dict['gid_number']) - os.chmod(filename, 0o640) - wrote_to_file = True - except PermissionError: - pass + wrote_to_file = write_db_creds(filename, user_dict, password, db_type, db_host) if op == 'create': click.echo(f'{db_type_name} database created.') + username = user_dict['uid'] click.echo(f'''Connection Information: Database: {username} @@ -71,7 +39,6 @@ def create(username: str, db_type: str): click.confirm(f'Are you sure you want to create a {db_type_name} database for {username}?', abort=True) info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") - check_file_path(info_file_path) resp = http_post(f'/api/db/{db_type}/{username}') result = handle_sync_response(resp) @@ -87,7 +54,6 @@ def pwreset(username: str, db_type: str): click.confirm(f'Are you sure you want reset the {db_type_name} password for {username}?', abort=True) info_file_path = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") - check_file_path(info_file_path) resp = http_post(f'/api/db/{db_type}/{username}/pwreset') result = handle_sync_response(resp) diff --git a/ceo/cli/utils.py b/ceo/cli/utils.py index 9423a2c..294400d 100644 --- a/ceo/cli/utils.py +++ b/ceo/cli/utils.py @@ -52,15 +52,6 @@ def handle_sync_response(resp: requests.Response): return resp.json() -def check_file_path(file): - if os.path.isfile(file): - click.echo(f"{file} will be overwritten") - click.confirm('Do you want to continue?', abort=True) - elif os.path.isdir(file): - click.echo(f"Error: there exists a directory at {file}") - raise Abort() - - def check_if_in_development() -> bool: """Aborts if we are not currently in the dev environment.""" if not socket.getfqdn().endswith('.csclub.internal'): diff --git a/ceo/tui/Model.py b/ceo/tui/Model.py index e662273..1173dde 100644 --- a/ceo/tui/Model.py +++ b/ceo/tui/Model.py @@ -61,6 +61,12 @@ class Model: 'uid': '', 'unsubscribe': True, }, + 'CreateDatabase': { + 'uid': '', + }, + 'ResetDatabasePassword': { + 'uid': '', + }, } self.viewdata = deepcopy(self._initial_viewdata) # data which is shared between multiple views @@ -69,6 +75,8 @@ class Model: self.operations = None self.deferred_req = None self.resp = None + self.db_type = None + self.user_dict = None def reset(self): self.viewdata = deepcopy(self._initial_viewdata) @@ -77,6 +85,8 @@ class Model: self.operations = None self.deferred_req = None self.resp = None + self.db_type = None + self.user_dict = None self.title = None self.error_message = None self.scene_stack.clear() diff --git a/ceo/tui/ResultView.py b/ceo/tui/ResultView.py index 0a414bb..f2b64a0 100644 --- a/ceo/tui/ResultView.py +++ b/ceo/tui/ResultView.py @@ -44,7 +44,7 @@ class ResultView(CeoFrame): def _resultview_on_load(self): self._add_text() resp = self._model.resp - if resp.status_code != 200: + if not resp.ok: self._add_text('An error occurred:') if resp.headers.get('content-type') == 'application/json': err_msg = resp.json()['error'] diff --git a/ceo/tui/WelcomeView.py b/ceo/tui/WelcomeView.py index 5e4fce1..8c30d63 100644 --- a/ceo/tui/WelcomeView.py +++ b/ceo/tui/WelcomeView.py @@ -28,10 +28,10 @@ class WelcomeView(CeoFrame): ('Remove member from group', 'RemoveMemberFromGroup'), ] db_menu_items = [ - ('Create MySQL database', 'CreateMySQL'), - ('Reset MySQL password', 'ResetMySQLPassword'), - ('Create PostgreSQL database', 'CreatePostgreSQL'), - ('Reset PostgreSQL password', 'ResetPostgreSQLPassword'), + ('Create MySQL database', 'CreateDatabase'), + ('Reset MySQL password', 'ResetDatabasePassword'), + ('Create PostgreSQL database', 'CreateDatabase'), + ('Reset PostgreSQL password', 'ResetDatabasePassword'), ] positions_menu_items = [ ('Get positions', 'GetPositions'), @@ -90,6 +90,11 @@ class WelcomeView(CeoFrame): if name == 'members': if desc.endswith('club rep'): self._model.is_club_rep = True + elif name == 'databases': + if 'MySQL' in desc: + self._model.db_type = 'mysql' + else: + self._model.db_type = 'postgresql' self._welcomeview_go_to_next_scene(desc, view) def _welcomeview_go_to_next_scene(self, desc, view): diff --git a/ceo/tui/databases/CreateDatabaseResultView.py b/ceo/tui/databases/CreateDatabaseResultView.py new file mode 100644 index 0000000..1b8ffda --- /dev/null +++ b/ceo/tui/databases/CreateDatabaseResultView.py @@ -0,0 +1,34 @@ +import os + +import requests +from zope import component + +from ...utils import write_db_creds +from ..ResultView import ResultView +from ceo_common.interfaces import IConfig + + +class CreateDatabaseResultView(ResultView): + def show_result(self, resp: requests.Response): + password = resp.json()['password'] + db_type = self._model.db_type + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + db_host = component.getUtility(IConfig).get(f'{db_type}_host') + user_dict = self._model.user_dict + username = user_dict['uid'] + filename = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") + wrote_to_file = write_db_creds( + filename, user_dict, password, db_type, db_host) + self._add_text(f'{db_type_name} database created.', center=True) + self._add_text() + self._add_text((f'''Connection Information: + +Database: {username} +Username: {username} +Password: {password} +Host: {db_host}''')) + self._add_text() + if wrote_to_file: + self._add_text(f"These settings have been written to {filename}.") + else: + self._add_text(f"We were unable to write these settings to {filename}.") diff --git a/ceo/tui/databases/CreateDatabaseView.py b/ceo/tui/databases/CreateDatabaseView.py new file mode 100644 index 0000000..b8ff17f --- /dev/null +++ b/ceo/tui/databases/CreateDatabaseView.py @@ -0,0 +1,44 @@ +from asciimatics.widgets import Layout, Text + +from ...utils import http_post, http_get, defer +from ..CeoFrame import CeoFrame + + +class CreateDatabaseView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'CreateDatabase', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text("Username:", "uid") + layout.add_widget(self._username) + self.add_buttons( + back_btn=True, next_scene='Confirm', + on_next=self._next) + self.fix() + + def _target(self): + username = self._username.value + db_type = self._model.db_type + resp = http_get(f'/api/members/{username}') + if not resp.ok: + return resp + user_dict = resp.json() + self._model.user_dict = user_dict + return http_post(f'/api/db/{db_type}/{username}') + + def _next(self): + username = self._username.value + if not username: + return + if self._model.db_type == 'mysql': + db_type_name = 'MySQL' + else: + db_type_name = 'PostgreSQL' + self._model.confirm_lines = [ + f'Are you sure you want to create a {db_type_name} database for {username}?', + ] + self._model.deferred_req = defer(self._target) + self._model.result_view_name = 'CreateDatabaseResult' diff --git a/ceo/tui/databases/ResetDatabasePasswordResultView.py b/ceo/tui/databases/ResetDatabasePasswordResultView.py new file mode 100644 index 0000000..12b45fb --- /dev/null +++ b/ceo/tui/databases/ResetDatabasePasswordResultView.py @@ -0,0 +1,29 @@ +import os + +import requests +from zope import component + +from ...utils import write_db_creds +from ..ResultView import ResultView +from ceo_common.interfaces import IConfig + + +class ResetDatabasePasswordResultView(ResultView): + def show_result(self, resp: requests.Response): + password = resp.json()['password'] + db_type = self._model.db_type + db_type_name = 'MySQL' if db_type == 'mysql' else 'PostgreSQL' + db_host = component.getUtility(IConfig).get(f'{db_type}_host') + user_dict = self._model.user_dict + username = user_dict['uid'] + filename = os.path.join(user_dict['home_directory'], f"ceo-{db_type}-info") + wrote_to_file = write_db_creds( + filename, user_dict, password, db_type, db_host) + self._add_text(f'The new {db_type_name} password for {username} is:') + self._add_text() + self._add_text(password) + self._add_text() + if wrote_to_file: + self._add_text(f"The settings in {filename} have been updated.") + else: + self._add_text(f"We were unable to update the settings in {filename}.") diff --git a/ceo/tui/databases/ResetDatabasePasswordView.py b/ceo/tui/databases/ResetDatabasePasswordView.py new file mode 100644 index 0000000..8e5075c --- /dev/null +++ b/ceo/tui/databases/ResetDatabasePasswordView.py @@ -0,0 +1,44 @@ +from asciimatics.widgets import Layout, Text + +from ...utils import http_post, http_get, defer +from ..CeoFrame import CeoFrame + + +class ResetDatabasePasswordView(CeoFrame): + def __init__(self, screen, width, height, model): + super().__init__( + screen, height, width, model, 'ResetDatabasePassword', + save_data=True, + ) + layout = Layout([100], fill_frame=True) + self.add_layout(layout) + self._username = Text("Username:", "uid") + layout.add_widget(self._username) + self.add_buttons( + back_btn=True, next_scene='Confirm', + on_next=self._next) + self.fix() + + def _target(self): + username = self._username.value + db_type = self._model.db_type + resp = http_get(f'/api/members/{username}') + if not resp.ok: + return resp + user_dict = resp.json() + self._model.user_dict = user_dict + return http_post(f'/api/db/{db_type}/{username}/pwreset') + + def _next(self): + username = self._username.value + if not username: + return + if self._model.db_type == 'mysql': + db_type_name = 'MySQL' + else: + db_type_name = 'PostgreSQL' + self._model.confirm_lines = [ + f'Are you sure you want to reset the {db_type_name} password for {username}?', + ] + self._model.deferred_req = defer(self._target) + self._model.result_view_name = 'ResetDatabasePasswordResult' diff --git a/ceo/tui/databases/__init__.py b/ceo/tui/databases/__init__.py new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/ceo/tui/databases/__init__.py @@ -0,0 +1 @@ + diff --git a/ceo/tui/start.py b/ceo/tui/start.py index 2bf1825..df88af2 100644 --- a/ceo/tui/start.py +++ b/ceo/tui/start.py @@ -10,6 +10,10 @@ from .Model import Model from .ResultView import ResultView from .TransactionView import TransactionView from .WelcomeView import WelcomeView +from .databases.CreateDatabaseView import CreateDatabaseView +from .databases.CreateDatabaseResultView import CreateDatabaseResultView +from .databases.ResetDatabasePasswordView import ResetDatabasePasswordView +from .databases.ResetDatabasePasswordResultView import ResetDatabasePasswordResultView from .groups.AddGroupView import AddGroupView from .groups.AddMemberToGroupView import AddMemberToGroupView from .groups.GetGroupView import GetGroupView @@ -56,6 +60,10 @@ def screen_wrapper(screen, last_scene, model): ('GetGroupResult', GetGroupResultView(screen, width, height, model)), ('AddMemberToGroup', AddMemberToGroupView(screen, width, height, model)), ('RemoveMemberFromGroup', RemoveMemberFromGroupView(screen, width, height, model)), + ('CreateDatabase', CreateDatabaseView(screen, width, height, model)), + ('CreateDatabaseResult', CreateDatabaseResultView(screen, width, height, model)), + ('ResetDatabasePassword', ResetDatabasePasswordView(screen, width, height, model)), + ('ResetDatabasePasswordResult', ResetDatabasePasswordResultView(screen, width, height, model)), ] scenes = [ Scene([view], -1, name=name) for name, view in views diff --git a/ceo/utils.py b/ceo/utils.py index 28e4a13..a23ecd5 100644 --- a/ceo/utils.py +++ b/ceo/utils.py @@ -1,5 +1,6 @@ import functools import json +import os from typing import List, Dict, Tuple, Callable import requests @@ -193,3 +194,50 @@ def defer(f: Callable, *args, **kwargs): def wrapper(): return f(*args, **kwargs) return wrapper + + +def write_db_creds( + filename: str, + user_dict: Dict, + password: str, + db_type: str, + db_host: str, +) -> bool: + username = user_dict['uid'] + if db_type == 'mysql': + db_type_name = 'MySQL' + db_cli_local_cmd = f'mysql {username}' + db_cli_cmd = f'mysql {username} -h {db_host} -u {username} -p' + else: + db_type_name = 'PostgreSQL' + db_cli_local_cmd = f'psql {username}' + db_cli_cmd = f'psql -d {username} -h {db_host} -U {username} -W' + info = f"""{db_type_name} Database Information for {username} + +Your new {db_type_name} database was created. To connect, use the following options: + +Database: {username} +Username: {username} +Password: {password} +Host: {db_host} + +On {db_host} to connect using the {db_type_name} command-line client use + + {db_cli_local_cmd} + +From other CSC machines you can connect using + + {db_cli_cmd} +""" + try: + # TODO: use phosphoric-acid to write to file (phosphoric-acid makes + # internal API call to caffeine) + if os.path.isfile(filename): + os.rename(filename, filename + '.bak') + with open(filename, "w") as f: + f.write(info) + os.chown(filename, user_dict['uid_number'], user_dict['gid_number']) + os.chmod(filename, 0o640) + return True + except PermissionError: + return False diff --git a/tests/ceo/cli/test_db_mysql.py b/tests/ceo/cli/test_db_mysql.py index 6418b0c..569c3c2 100644 --- a/tests/ceo/cli/test_db_mysql.py +++ b/tests/ceo/cli/test_db_mysql.py @@ -1,4 +1,5 @@ import os +import shutil from click.testing import CliRunner from mysql.connector import connect @@ -33,6 +34,7 @@ def test_mysql(cli_setup, cfg, ldap_user): # create database for user result = runner.invoke(cli, ['mysql', 'create', username], input='y\n') + print(result.output) assert result.exit_code == 0 assert os.path.isfile(info_file_path) @@ -57,8 +59,7 @@ These settings have been written to {info_file_path}. mysql_attempt_connection(host, username, passwd) # perform password reset for user - # confirm once to reset password, another to overwrite the file - result = runner.invoke(cli, ['mysql', 'pwreset', username], input="y\ny\n") + result = runner.invoke(cli, ['mysql', 'pwreset', username], input="y\n") assert result.exit_code == 0 response_arr = result.output.split() @@ -78,5 +79,4 @@ These settings have been written to {info_file_path}. with pytest.raises(ProgrammingError): mysql_attempt_connection(host, username, passwd) - os.remove(info_file_path) - os.rmdir(ldap_user.home_directory) + shutil.rmtree(ldap_user.home_directory) diff --git a/tests/ceo/cli/test_db_postgresql.py b/tests/ceo/cli/test_db_postgresql.py index ddd2353..1421bdc 100644 --- a/tests/ceo/cli/test_db_postgresql.py +++ b/tests/ceo/cli/test_db_postgresql.py @@ -1,5 +1,6 @@ import pytest import os +import shutil from click.testing import CliRunner from ceo.cli import cli @@ -59,8 +60,7 @@ These settings have been written to {info_file_path}. psql_attempt_connection(host, username, passwd) # perform password reset for user - # confirm once to reset password, another to overwrite the file - result = runner.invoke(cli, ['postgresql', 'pwreset', username], input="y\ny\n") + result = runner.invoke(cli, ['postgresql', 'pwreset', username], input="y\n") assert result.exit_code == 0 response_arr = result.output.split() @@ -80,5 +80,4 @@ These settings have been written to {info_file_path}. with pytest.raises(OperationalError): psql_attempt_connection(host, username, passwd) - os.remove(info_file_path) - os.rmdir(ldap_user.home_directory) + shutil.rmtree(ldap_user.home_directory) From 652620a7c5a48270e10e8374fc0353ae10ee1e39 Mon Sep 17 00:00:00 2001 From: Max Erenberg <> Date: Sun, 12 Sep 2021 09:36:54 -0400 Subject: [PATCH 4/4] fix lint errors --- ceo/cli/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ceo/cli/utils.py b/ceo/cli/utils.py index 294400d..7e31884 100644 --- a/ceo/cli/utils.py +++ b/ceo/cli/utils.py @@ -1,5 +1,4 @@ import socket -import os from typing import List, Tuple, Dict