From e863ff7706f11c2fddf54ae624d5a0af570fedf8 Mon Sep 17 00:00:00 2001 From: sjayellis Date: Thu, 16 Jan 2025 14:18:10 -0500 Subject: [PATCH 1/5] Adding a dockerfile for hosting a QCPortal Jupyterhub. --- Dockerfile.jupyterhub | 21 ++++++++++++ docker/jupyterhub_config.py | 24 +++++++++++++ docker/qcarchive_authenticator.py | 56 +++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 Dockerfile.jupyterhub create mode 100644 docker/jupyterhub_config.py create mode 100644 docker/qcarchive_authenticator.py diff --git a/Dockerfile.jupyterhub b/Dockerfile.jupyterhub new file mode 100644 index 000000000..87d8b1912 --- /dev/null +++ b/Dockerfile.jupyterhub @@ -0,0 +1,21 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +EXPOSE 8000 + +RUN apt-get upgrade -y && apt-get update -y && apt-get install -yqq --no-install-recommends python3-pip && pip3 install --upgrade pip +RUN apt-get install npm nodejs -y \ + && npm install -g configurable-http-proxy \ + && pip3 install jupyterhub \ + && pip3 install --upgrade notebook \ + && pip3 install --upgrade jupyterlab \ + && pip3 install --upgrade qcportal \ + && rm -rf /var/lib/apt/lists/* /var/log/* /var/tmp/* ~/.npm + +ADD docker/jupyterhub_config.py /etc/jupyterhub_config.py +ADD docker/qcarchive_authenticator.py /etc/qcarchive_authenticator.py + +ENV PYTHONPATH="${PYTHONPATH}:/etc" + +CMD ["jupyterhub", "--ip=0.0.0.0", "--port=8000", "--no-ssl", "-f", "/etc/jupyterhub_config.py"] \ No newline at end of file diff --git a/docker/jupyterhub_config.py b/docker/jupyterhub_config.py new file mode 100644 index 000000000..f70c6069f --- /dev/null +++ b/docker/jupyterhub_config.py @@ -0,0 +1,24 @@ +"""sample jupyterhub config file for testing + +configures jupyterhub with dummyauthenticator and simplespawner +to enable testing without administrative privileges. +""" + +c = get_config() # noqa + +from qcarchive_authenticator import QCArchiveAuthenticator + +c.JupyterHub.authenticator_class = QCArchiveAuthenticator + +# don't cache static files +c.JupyterHub.tornado_settings = { + "no_cache_static": True, + "slow_spawn_timeout": 0, +} + +c.JupyterHub.allow_named_servers = True +c.JupyterHub.default_url = "/hub/home" + +# make sure admin UI is available and any user can login +#c.Authenticator.admin_users = {"admin"} +c.Authenticator.allow_all = True \ No newline at end of file diff --git a/docker/qcarchive_authenticator.py b/docker/qcarchive_authenticator.py new file mode 100644 index 000000000..e2751ad4d --- /dev/null +++ b/docker/qcarchive_authenticator.py @@ -0,0 +1,56 @@ +from tornado import gen +import os +from jupyterhub.auth import Authenticator +import requests +import jwt +from subprocess import PIPE, STDOUT, Popen + +class QCArchiveAuthenticator(Authenticator): + + @gen.coroutine + def authenticate(self, handler, data): + base_address = os.getenv("QCFRACTAL_ADDRESS") + + if base_address is None: + raise RuntimeError(f"QCArchive address returned None.") + + login_address = f"{base_address}/auth/v1/login" + uinfo_address = f"{base_address}/api/v1/users/{data['username']}" + + # Log in to get JWT + body = {"username": data['username'], "password": data['password']} + + # 'verify' means whether or not to verify SSL certificates + r = requests.post(login_address, json=body, verify=True) + + if r.status_code != 200: + fail_msg = r.json()['msg'] + raise RuntimeError(f"Login failure: {r.status_code} - {fail_msg}") + + print("Successfully logged in!") + + # Grab access token, use to get all user information + access_token = r.json()['access_token'] + + headers = {'Authorization': f"Bearer {access_token}"} + r = requests.get(uinfo_address, headers=headers) + + if r.status_code != 200: + fail_msg = r.json()['msg'] + raise RuntimeError(f"Unable to get user information: {r.status_code} - {fail_msg}") + + uinfo = r.json() + username = uinfo['username'] + user_id = uinfo['id'] + role = uinfo['role'] + + cmd = ['adduser', '-q', '--disabled-password', '--uid', f'{100000+user_id}', username] + p = Popen(cmd, stdout=PIPE, stderr=STDOUT) + p.wait() + + os.environ['QCPORTAL_ADDRESS'] = base_address + os.environ['QCPORTAL_USERNAME'] = username + os.environ['QCORTAL_PASSWORD'] = data['password'] + os.environ['QCPORTAL_VERIFY'] = os.getenv("QCFRACTAL_VERIFY", "True") + + return username \ No newline at end of file From fbcde95e7e3057bf85fdcf3ebd1314b0b248c9ed Mon Sep 17 00:00:00 2001 From: sjayellis Date: Thu, 16 Jan 2025 14:22:27 -0500 Subject: [PATCH 2/5] Updating docstring in jupyterhub_config.py --- docker/jupyterhub_config.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docker/jupyterhub_config.py b/docker/jupyterhub_config.py index f70c6069f..4a4ece533 100644 --- a/docker/jupyterhub_config.py +++ b/docker/jupyterhub_config.py @@ -1,7 +1,6 @@ -"""sample jupyterhub config file for testing +"""Jupyterhub Config file for hosting QCPortal. -configures jupyterhub with dummyauthenticator and simplespawner -to enable testing without administrative privileges. +This authenticates using QCArchive server usernames and passwords. """ c = get_config() # noqa @@ -19,6 +18,4 @@ c.JupyterHub.allow_named_servers = True c.JupyterHub.default_url = "/hub/home" -# make sure admin UI is available and any user can login -#c.Authenticator.admin_users = {"admin"} c.Authenticator.allow_all = True \ No newline at end of file From 775adb43acfd410f35d8d7c93cb47af8512a5483 Mon Sep 17 00:00:00 2001 From: sjayellis Date: Thu, 16 Jan 2025 14:25:30 -0500 Subject: [PATCH 3/5] Linting qcarchive_authenticator. --- docker/qcarchive_authenticator.py | 46 +++++++++++++++++++------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/docker/qcarchive_authenticator.py b/docker/qcarchive_authenticator.py index e2751ad4d..9489e12c4 100644 --- a/docker/qcarchive_authenticator.py +++ b/docker/qcarchive_authenticator.py @@ -5,52 +5,62 @@ import jwt from subprocess import PIPE, STDOUT, Popen + class QCArchiveAuthenticator(Authenticator): - + @gen.coroutine def authenticate(self, handler, data): base_address = os.getenv("QCFRACTAL_ADDRESS") - + if base_address is None: raise RuntimeError(f"QCArchive address returned None.") - + login_address = f"{base_address}/auth/v1/login" uinfo_address = f"{base_address}/api/v1/users/{data['username']}" # Log in to get JWT - body = {"username": data['username'], "password": data['password']} + body = {"username": data["username"], "password": data["password"]} # 'verify' means whether or not to verify SSL certificates r = requests.post(login_address, json=body, verify=True) if r.status_code != 200: - fail_msg = r.json()['msg'] + fail_msg = r.json()["msg"] raise RuntimeError(f"Login failure: {r.status_code} - {fail_msg}") print("Successfully logged in!") # Grab access token, use to get all user information - access_token = r.json()['access_token'] + access_token = r.json()["access_token"] - headers = {'Authorization': f"Bearer {access_token}"} + headers = {"Authorization": f"Bearer {access_token}"} r = requests.get(uinfo_address, headers=headers) if r.status_code != 200: - fail_msg = r.json()['msg'] - raise RuntimeError(f"Unable to get user information: {r.status_code} - {fail_msg}") + fail_msg = r.json()["msg"] + raise RuntimeError( + f"Unable to get user information: {r.status_code} - {fail_msg}" + ) uinfo = r.json() - username = uinfo['username'] - user_id = uinfo['id'] - role = uinfo['role'] + username = uinfo["username"] + user_id = uinfo["id"] + role = uinfo["role"] - cmd = ['adduser', '-q', '--disabled-password', '--uid', f'{100000+user_id}', username] + cmd = [ + "adduser", + "-q", + "--disabled-password", + "--uid", + f"{100000+user_id}", + username, + ] p = Popen(cmd, stdout=PIPE, stderr=STDOUT) p.wait() - os.environ['QCPORTAL_ADDRESS'] = base_address - os.environ['QCPORTAL_USERNAME'] = username - os.environ['QCORTAL_PASSWORD'] = data['password'] - os.environ['QCPORTAL_VERIFY'] = os.getenv("QCFRACTAL_VERIFY", "True") + os.environ["QCPORTAL_ADDRESS"] = base_address + os.environ["QCPORTAL_USERNAME"] = username + os.environ["QCORTAL_PASSWORD"] = data["password"] + os.environ["QCPORTAL_VERIFY"] = os.getenv("QCFRACTAL_VERIFY", "True") - return username \ No newline at end of file + return username From 1d0510cda6d429eee2314ae974e38ddacc64acab Mon Sep 17 00:00:00 2001 From: sjayellis Date: Fri, 17 Jan 2025 10:24:35 -0500 Subject: [PATCH 4/5] Adding checks for existing user/home dir and cloning QCArchive demos. --- docker/qcarchive_authenticator.py | 56 ++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/docker/qcarchive_authenticator.py b/docker/qcarchive_authenticator.py index 9489e12c4..8d9ddf0d8 100644 --- a/docker/qcarchive_authenticator.py +++ b/docker/qcarchive_authenticator.py @@ -4,7 +4,8 @@ import requests import jwt from subprocess import PIPE, STDOUT, Popen - +import pwd +import warnings class QCArchiveAuthenticator(Authenticator): @@ -25,8 +26,11 @@ def authenticate(self, handler, data): r = requests.post(login_address, json=body, verify=True) if r.status_code != 200: - fail_msg = r.json()["msg"] - raise RuntimeError(f"Login failure: {r.status_code} - {fail_msg}") + try: + details = r.json() + except: + details = {"msg": r.reason} + raise RuntimeError(f"Request failed: {details['msg']}", r.status_code, details) print("Successfully logged in!") @@ -47,16 +51,42 @@ def authenticate(self, handler, data): user_id = uinfo["id"] role = uinfo["role"] - cmd = [ - "adduser", - "-q", - "--disabled-password", - "--uid", - f"{100000+user_id}", - username, - ] - p = Popen(cmd, stdout=PIPE, stderr=STDOUT) - p.wait() + # Check if the user has a home directory. + home_dir = False + if os.path.isdir(f"/home/{username}"): + home_dir = True + + # Check if the user exists already. + user_exists = True + try: + pwd.getpwnam(username) + except KeyError: + print(f'User {username} does not exist.') + user_exists = False + + # Create the user if they do not exist. + if not user_exists: + cmd = [ + "adduser", + "-q", + "--disabled-password", + "--uid", + f"{100000+user_id}", + username, + ] + p = Popen(cmd, stdout=PIPE, stderr=STDOUT) + p.wait() + + + # If they did not have a home directory before user creation, clone the QCArchive demos. + if not home_dir: + cmd = ["git", "clone", "https://github.com/MolSSI/QCArchiveDemos.git", f"/home/{username}/QCArchiveDemos"] + p = Popen(cmd, stdout=PIPE, stderr=STDOUT) + p.wait() + + cmd = ["chown", f"{username}", f"/home/{username}/QCArchiveDemos"] + p = Popen(cmd, stdout=PIPE, stderr=STDOUT) + p.wait() os.environ["QCPORTAL_ADDRESS"] = base_address os.environ["QCPORTAL_USERNAME"] = username From ecc8ada893086be8c146bb97ec3a66df172eb0ae Mon Sep 17 00:00:00 2001 From: Benjamin Pritchard Date: Fri, 17 Jan 2025 10:48:43 -0500 Subject: [PATCH 5/5] Handle base_url through env as well --- docker/jupyterhub_config.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docker/jupyterhub_config.py b/docker/jupyterhub_config.py index 4a4ece533..ffd961ee0 100644 --- a/docker/jupyterhub_config.py +++ b/docker/jupyterhub_config.py @@ -3,8 +3,14 @@ This authenticates using QCArchive server usernames and passwords. """ +import os + c = get_config() # noqa +# Get the base url from the environment +base_url = os.getenv("QCFRACTAL_JHUB_BASE_URL", "/") +c.JupyterHub.base_url = base_url + from qcarchive_authenticator import QCArchiveAuthenticator c.JupyterHub.authenticator_class = QCArchiveAuthenticator @@ -16,6 +22,5 @@ } c.JupyterHub.allow_named_servers = True -c.JupyterHub.default_url = "/hub/home" -c.Authenticator.allow_all = True \ No newline at end of file +c.Authenticator.allow_all = True