Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add jupyter_health client package #3

Merged
merged 1 commit into from
Apr 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"