-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3 from minrk/jupyter-health-client
add jupyter_health client package
- Loading branch information
Showing
4 changed files
with
266 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
""" | ||
JupyterHealth client for CommonHealth Cloud | ||
""" | ||
|
||
__version__ = "0.0.1a0" | ||
|
||
from .ch_client import JupyterHealthCHClient | ||
|
||
__all__ = [ | ||
"JupyterHealthCHClient", | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<major>\d+) | ||
\. | ||
(?P<minor>\d+) | ||
\. | ||
(?P<patch>\d+) | ||
(?P<pre>((a|b|rc)\d+)|) | ||
\.? | ||
(?P<dev>(?<=\.)dev\d*|) | ||
''' | ||
|
||
[tool.tbump.git] | ||
message_template = "Bump to {new_version}" | ||
tag_template = "{new_version}" | ||
|
||
[[tool.tbump.file]] | ||
src = "jupyter_health/__init__.py" |