Skip to content

Commit

Permalink
Merge pull request #3 from minrk/jupyter-health-client
Browse files Browse the repository at this point in the history
add jupyter_health client package
  • Loading branch information
minrk authored Apr 11, 2024
2 parents 0ceb730 + 23e5410 commit 8707b1a
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 2 deletions.
10 changes: 8 additions & 2 deletions Dockerfile
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/
11 changes: 11 additions & 0 deletions jupyter_health/__init__.py
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",
]
168 changes: 168 additions & 0 deletions jupyter_health/ch_client.py
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)
79 changes: 79 additions & 0 deletions pyproject.toml
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"

0 comments on commit 8707b1a

Please sign in to comment.