From 23e54109a982ad6c412dfffa5bb5148a878df7e1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 11 Apr 2024 11:27:47 +0200 Subject: [PATCH] add jupyter_health client package once mature, this will live in its own repo, but for now let's build it right in the image --- Dockerfile | 10 ++- jupyter_health/__init__.py | 11 +++ jupyter_health/ch_client.py | 168 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 79 +++++++++++++++++ 4 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 jupyter_health/__init__.py create mode 100644 jupyter_health/ch_client.py create mode 100644 pyproject.toml diff --git a/Dockerfile b/Dockerfile index 67eeb51..2c5014b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,15 @@ FROM quay.io/jupyter/scipy-notebook:2024-04-01 # make sure jupyterhub version matches -RUN mamba install jupyterhub==4.1.5 \ +RUN mamba install -y jupyterhub==4.1.5 \ && mamba clean -pity # have to do a bit of manual install to avoid commonhealth-cloud pins -RUN pip install --no-cache tink \ +ARG PIP_CACHE_DIR=/tmp/pip-cache +RUN --mount=type=cache,target=${PIP_CACHE_DIR} \ + pip install tink \ && pip install --no-deps commonhealth-cloud-storage-client + +COPY . /src/ +RUN --mount=type=cache,target=${PIP_CACHE_DIR} \ + pip install /src/ diff --git a/jupyter_health/__init__.py b/jupyter_health/__init__.py new file mode 100644 index 0000000..992af18 --- /dev/null +++ b/jupyter_health/__init__.py @@ -0,0 +1,11 @@ +""" +JupyterHealth client for CommonHealth Cloud +""" + +__version__ = "0.0.1a0" + +from .ch_client import JupyterHealthCHClient + +__all__ = [ + "JupyterHealthCHClient", +] diff --git a/jupyter_health/ch_client.py b/jupyter_health/ch_client.py new file mode 100644 index 0000000..2d935c6 --- /dev/null +++ b/jupyter_health/ch_client.py @@ -0,0 +1,168 @@ +"""JupyterHealth subclass of CommonHealth client + +- sets default values +- loads state from AWS Secrets (credentials loaded by default) +""" + +from __future__ import annotations + +import json +from collections.abc import ItemsView, KeysView + +import boto3.session +from commonhealth_cloud_storage_client import CHClient, CHStorageDelegate + + +class AWSSecretStorageDelegate(CHStorageDelegate): + """Implement CommonHealth storage delegate API backed by AWS secret""" + + def __init__( + self, secret_name: str, *, client: boto3.session.Session | None = None + ): + """Construct a CHStorageDelegate backed by an AWS Secret + + Args: + secret_name: the name of the secret + client (optional): the boto3 secretsmanager client + If not defined, will be constructed with defaults from the environment + """ + if client is None: + session = boto3.session.Session() + self.client = session.client("secretsmanager") + self.client = client + self.secret_name = secret_name + self._cached_value = None + + def _load(self): + """Load the secret value into a local cache""" + secret_response = self.client.get_secret_value(SecretId=self.secret_name) + self._cached_value = json.loads(secret_response["SecretString"]) + + def _save(self): + """Persist any changes to the secret""" + self.client.update_secret( + SecretId=self.secret_name, + SecretString=json.dumps(self._cached_value), + ) + + @property + def _secret_value(self): + """The value of the secret as a dict + + Loads it if it hasn't been loaded yet + """ + if self._cached_value is None: + self._load() + return self._cached_value + + # the storage delegate API + + def get_secure_value(self, key: str, default=None) -> str | None: + """Retrieve one secret value + + if not defined, return None + """ + return self._secret_value.get(key, default) + + def set_secure_value(self, key: str, value: str) -> None: + """Set one new value""" + # load before writing to avoid writing back stale state + self._load() + self._secret_value[key] = value + self._save() + + def clear_value(self, key: str) -> None: + """Remove one value from the storage""" + if key not in self._secret_value: + return + self._secret_value.pop(key) + self._save() + + def clear_all_values(self) -> None: + """Clear all values in the storage + + Probably shouldn't do this! + """ + raise NotImplementedError("clear all values not allowed") + self._load() + new_value = {} + # don't allow deleting signing key + if "private_signing_key" in self._cached_value: + new_value["private_signing_key"] = self._cached_value["private_signing_key"] + self._cached_value = new_value + self._save() + + # additional API methods not in the base class + + @property + def keys(self) -> KeysView: + """Return currently stored keys""" + return self._secret_value.keys() + + @property + def items(self) -> ItemsView: + """Return currently stored items, as `dict.items()`""" + return self._secret_value.items() + + +class JupyterHealthCHClient(CHClient): + """JupyterHealth client for CommonHealth Cloud + + Fills out default values for all args and loads state from AWS Secrets + """ + + def __init__(self, deployment: str = "testing", *, client=None, **user_kwargs): + """Construct a JupyterHealth cilent for Common Health Cloud + + Credentials will be loaded from the environment and defaults. + No arguments are required. + + By default, creates a client connected to the 'testing' application, + but pass:: + + JupyterHealthCHClient("prod") + + to connect to the production pre-MVP application. + + A boto3 `client=Session().client("secretsmanager")` can be provided, + otherwise a default client will be constructed loading credentials from the environment + (works on the JupyterHealth deployment). + + Any additional keyword arguments will be passed through to CHClient + """ + self.deployment = deployment + + # the names of the secrets where state is stored: + storage_delegate_secret_name = f"ch-cloud-delegate-{deployment}" + credentials_secret_name = f"ch-cloud-creds-{deployment}" + + # connect the client + if client is None: + session = boto3.session.Session() + client = session.client("secretsmanager") + self.client = client + + # fetch client_id/secret for the ch cloud API + credentials_secret = self.client.get_secret_value( + SecretId=credentials_secret_name + ) + credentials = json.loads(credentials_secret["SecretString"]) + + # construct storage delegate backed by AWS Secret + storage = AWSSecretStorageDelegate( + client=self.client, secret_name=storage_delegate_secret_name + ) + # fill out default kwargs for the base class constructor + kwargs = dict( + ch_authorization_deeplink="https://appdev.tcpdev.org/m/phr/cloud-sharing/authorize", + ch_host="chcs.tcpdev.org", + ch_port=443, + ch_scheme="https", + storage_delegate=storage, + partner_id=credentials["partner_id"], + client_id=credentials["client_id"], + client_secret=credentials["client_secret"], + ) + # load user_kwargs so they can override any of the defaults above + kwargs.update(user_kwargs) + super().__init__(**kwargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6ab125e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +[project] +name = "jupyter-health" +description = "Jupyter Health client library" +authors = [ + {name = "Jupyter", email = "benjaminrk@gmail.com"}, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Framework :: Jupyter", +] +readme = "README.md" +license = {file = "LICENSE"} +dynamic = ["version"] +requires-python = ">=3.7" +dependencies = [ + "boto3", + # wait for commonhealth_cloud_storage_client to fix pinning + # before we depend on it explicitly + # "commonhealth_cloud_storage_client", +] + +[project.urls] +# this will eventually be in its own repo +Documentation = "https://github.com/jupyterhealth/singleuser-image#readme" +Issues = "https://github.com/jupyterhealth/singleuser-image/issues" +Source = "https://github.com/jupyterhealth/singleuser-image" + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# hatch ref: https://hatch.pypa.io +# +[tool.hatch.version] +path = "jupyter_health/__init__.py" + +# ruff is our linter and formatter +[tool.ruff.format] +quote-style = "preserve" + +[tool.ruff.lint] +ignore = [ + # "F841", # unused variable +] +select = [ + "E9", # syntax + "D1", # missing docstrings + "I", # isort + "UP", # pyupgrade + "F", # flake8 +] + +[tool.tbump] +# this will eventually be in its own repo +github_url = "https://github.com/jupyterhealth/singleuser-image" + +[tool.tbump.version] +current = "0.0.1a1" + +regex = ''' + (?P\d+) + \. + (?P\d+) + \. + (?P\d+) + (?P
((a|b|rc)\d+)|)
+  \.?
+  (?P(?<=\.)dev\d*|)
+  '''
+
+[tool.tbump.git]
+message_template = "Bump to {new_version}"
+tag_template = "{new_version}"
+
+[[tool.tbump.file]]
+src = "jupyter_health/__init__.py"