feat(jupyterhub): update SecretStore I/F

This commit is contained in:
Masaki Yatsu
2025-08-31 22:00:35 +09:00
parent 1e9d9520e9
commit 4a1e8f2ec1
2 changed files with 38 additions and 35 deletions

View File

@@ -6,7 +6,7 @@ export JUPYTERHUB_CHART_VERSION := env("JUPYTERHUB_CHART_VERSION", "4.2.0")
export JUPYTERHUB_OIDC_CLIENT_ID := env("JUPYTERHUB_OIDC_CLIENT_ID", "jupyterhub") export JUPYTERHUB_OIDC_CLIENT_ID := env("JUPYTERHUB_OIDC_CLIENT_ID", "jupyterhub")
export JUPYTERHUB_ENABLE_NFS_PV := env("JUPYTERHUB_ENABLE_NFS_PV", "") export JUPYTERHUB_ENABLE_NFS_PV := env("JUPYTERHUB_ENABLE_NFS_PV", "")
export JUPYTERHUB_VAULT_INTEGRATION_ENABLED := env("JUPYTERHUB_VAULT_INTEGRATION_ENABLED", "false") export JUPYTERHUB_VAULT_INTEGRATION_ENABLED := env("JUPYTERHUB_VAULT_INTEGRATION_ENABLED", "false")
export JUPYTER_PYTHON_KERNEL_TAG := env("JUPYTER_PYTHON_KERNEL_TAG", "python-3.12-4") export JUPYTER_PYTHON_KERNEL_TAG := env("JUPYTER_PYTHON_KERNEL_TAG", "python-3.12-5")
export KERNEL_IMAGE_BUUN_STACK_REPOSITORY := env("KERNEL_IMAGE_BUUN_STACK_REPOSITORY", "buun-stack-notebook") export KERNEL_IMAGE_BUUN_STACK_REPOSITORY := env("KERNEL_IMAGE_BUUN_STACK_REPOSITORY", "buun-stack-notebook")
export KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY := env("KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY", "buun-stack-cuda-notebook") export KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY := env("KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY", "buun-stack-cuda-notebook")
export JUPYTER_PROFILE_MINIMAL_ENABLED := env("JUPYTER_PROFILE_MINIMAL_ENABLED", "false") export JUPYTER_PROFILE_MINIMAL_ENABLED := env("JUPYTER_PROFILE_MINIMAL_ENABLED", "false")

View File

@@ -22,7 +22,8 @@ logger.addHandler(logging.NullHandler()) # Default to no output
class SecretStore: class SecretStore:
"""Simple and powerful secrets management for JupyterHub with Vault backend. """
Simple secrets management for JupyterHub with Vault backend.
SecretStore provides a secure interface for managing secrets in JupyterHub SecretStore provides a secure interface for managing secrets in JupyterHub
environments using HashiCorp Vault as the backend storage. It supports environments using HashiCorp Vault as the backend storage. It supports
@@ -48,7 +49,6 @@ class SecretStore:
-------- --------
>>> secrets = SecretStore() >>> secrets = SecretStore()
>>> secrets.put('api-keys', openai='sk-123', github='ghp-456') >>> secrets.put('api-keys', openai='sk-123', github='ghp-456')
'jupyter/users/username/api-keys'
>>> data = secrets.get('api-keys') >>> data = secrets.get('api-keys')
>>> print(data['openai']) >>> print(data['openai'])
'sk-123' 'sk-123'
@@ -251,7 +251,7 @@ class SecretStore:
"Failed to refresh tokens. Manual re-authentication required." "Failed to refresh tokens. Manual re-authentication required."
) )
def put(self, key: str, **kwargs: Any) -> str: def put(self, key: str, **kwargs: Any) -> None:
""" """
Store data in your personal secret storage. Store data in your personal secret storage.
@@ -266,11 +266,6 @@ class SecretStore:
Key-value pairs to store as the secret data. All values must be strings. Key-value pairs to store as the secret data. All values must be strings.
For complex types, encode them as JSON strings first. For complex types, encode them as JSON strings first.
Returns
-------
str
Full Vault path where the secret was stored.
Raises Raises
------ ------
ValueError ValueError
@@ -287,9 +282,7 @@ class SecretStore:
-------- --------
>>> import json >>> import json
>>> secrets = SecretStore() >>> secrets = SecretStore()
>>> path = secrets.put('api-keys', openai='sk-123', github='ghp-456') >>> secrets.put('api-keys', openai='sk-123', github='ghp-456')
>>> print(path)
'jupyter/users/username/api-keys'
>>> # Store complex data as JSON strings >>> # Store complex data as JSON strings
>>> config_data = {'debug': True, 'max_workers': 4} >>> config_data = {'debug': True, 'max_workers': 4}
@@ -317,7 +310,6 @@ class SecretStore:
path=path, secret=kwargs, mount_point="secret" path=path, secret=kwargs, mount_point="secret"
) )
logger.info(f"Put secret: {key}") logger.info(f"Put secret: {key}")
return path
except Exception as e: except Exception as e:
logger.error(f"Failed to put secret: {e}") logger.error(f"Failed to put secret: {e}")
# Retry once with re-authentication # Retry once with re-authentication
@@ -325,21 +317,20 @@ class SecretStore:
self.client.secrets.kv.v2.create_or_update_secret( self.client.secrets.kv.v2.create_or_update_secret(
path=path, secret=kwargs, mount_point="secret" path=path, secret=kwargs, mount_point="secret"
) )
return path
@overload @overload
def get(self, key: str, field: None = None) -> dict[str, Any] | None: ... def get(self, key: str, field: None = None) -> dict[str, Any]: ...
@overload @overload
def get(self, key: str, field: str) -> str | None: ... def get(self, key: str, field: str) -> str: ...
def get(self, key: str, field: str | None = None) -> dict[str, Any] | str | None: def get(self, key: str, field: str | None = None) -> dict[str, Any] | str:
""" """
Retrieve data from your personal secret storage. Retrieve data from your personal secret storage.
Loads the data dictionary stored under the specified key from Vault. Loads the data dictionary stored under the specified key from Vault.
If field is specified, returns only that field's value. Returns None If field is specified, returns only that field's value. Raises KeyError
if the key doesn't exist or if there's an access error. if the key doesn't exist or if the specified field is not found.
Parameters Parameters
---------- ----------
@@ -351,13 +342,14 @@ class SecretStore:
Returns Returns
------- -------
dict[str, Any] or str or None dict[str, Any] or str
- If field is None: The complete stored data dictionary if found, None otherwise. - If field is None: The complete stored data dictionary.
- If field is specified: The value of the specified field, or None if - If field is specified: The value of the specified field.
field doesn't exist or secret is not found.
Raises Raises
------ ------
KeyError
If the key doesn't exist or if the specified field is not found.
ConnectionError ConnectionError
If unable to connect to Vault server. If unable to connect to Vault server.
hvac.exceptions.InvalidRequest hvac.exceptions.InvalidRequest
@@ -377,11 +369,13 @@ class SecretStore:
>>> print(f'OpenAI key: {openai_key}') >>> print(f'OpenAI key: {openai_key}')
>>> # Handle missing keys or fields >>> # Handle missing keys or fields
>>> config = secrets.get('nonexistent-key') >>> try:
>>> if config is None: ... config = secrets.get('nonexistent-key')
... except KeyError:
... print('Key not found') ... print('Key not found')
>>> missing_field = secrets.get('api-keys', field='nonexistent') >>> try:
>>> if missing_field is None: ... missing_field = secrets.get('api-keys', field='nonexistent')
... except KeyError:
... print('Field not found') ... print('Field not found')
""" """
self._ensure_authenticated() self._ensure_authenticated()
@@ -397,10 +391,13 @@ class SecretStore:
# Return specific field if requested # Return specific field if requested
if field is not None: if field is not None:
return data.get(field) if field not in data:
raise KeyError(f"Field '{field}' not found in secret '{key}'")
return data[field]
return data return data
return None else:
raise KeyError(f"Secret '{key}' not found")
except Exception as e: except Exception as e:
if "permission denied" in str(e).lower(): if "permission denied" in str(e).lower():
logger.info("Permission denied, re-authenticating...") logger.info("Permission denied, re-authenticating...")
@@ -411,10 +408,16 @@ class SecretStore:
if response and "data" in response and "data" in response["data"]: if response and "data" in response and "data" in response["data"]:
data = response["data"]["data"] data = response["data"]["data"]
if field is not None: if field is not None:
return data.get(field) if field not in data:
raise KeyError(
f"Field '{field}' not found in secret '{key}'"
)
return data[field]
return data return data
else:
raise KeyError(f"Secret '{key}' not found")
logger.warning(f'Could not get secret "{key}": {e}') logger.warning(f'Could not get secret "{key}": {e}')
return None raise KeyError(f"Secret '{key}' not found") from e
def delete(self, key: str) -> None: def delete(self, key: str) -> None:
""" """
@@ -796,15 +799,15 @@ def put_env_to_secrets(
... 'DEBUG': 'false', ... 'DEBUG': 'false',
... 'MAX_WORKERS': '4' ... 'MAX_WORKERS': '4'
... } ... }
>>> path = put_env_to_secrets(secrets, env_vars) >>> put_env_to_secrets(secrets, env_vars)
>>> print(f'Stored at: {path}')
'jupyter/users/username/environment' 'jupyter/users/username/environment'
>>> # Store with custom key >>> # Store with custom key
>>> put_env_to_secrets(secrets, {'API_KEY': 'secret'}, 'production-config') >>> put_env_to_secrets(secrets, {'API_KEY': 'secret'}, 'production-config')
'jupyter/users/username/environment'
""" """
# Convert all values to strings and use **kwargs for put() # Convert all values to strings and use **kwargs for put()
string_env_dict = {k: str(v) for k, v in env_dict.items()} string_env_dict = {k: str(v) for k, v in env_dict.items()}
path = secrets.put(key, **string_env_dict) secrets.put(key, **string_env_dict)
logger.info(f"Put {len(env_dict)} environment variables") logger.info(f"Put {len(env_dict)} environment variables")
return path return f"jupyter/users/{secrets.username}/{key}"