Skip to content

Commit

Permalink
feat: save storage secrets (#1927)
Browse files Browse the repository at this point in the history
  • Loading branch information
m-alisafaee authored Aug 5, 2024
1 parent d01d07e commit 4c9e0d4
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 40 deletions.
57 changes: 55 additions & 2 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ apispec = {extras = ["marshmallow"], version = "*"}
importlib-metadata = "*"
dataconf = "^3.2.0"
python-ulid = "^2.7.0"
cryptography = "^42.0.5"

[tool.poetry.group.dev.dependencies]
chartpress = "*"
Expand Down
22 changes: 13 additions & 9 deletions renku_notebooks/api/classes/data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,25 @@ class CloudStorageConfig(NamedTuple):
target_path: str
readonly: bool
name: str
secrets: dict[str, str]


@dataclass
class StorageValidator:
storage_url: str
data_service_url: str

def __post_init__(self):
self.storage_url = self.storage_url.rstrip("/")
self.data_service_url = self.data_service_url.rstrip("/")

def get_storage_by_id(self, user: User, project_id: int, storage_id: str) -> CloudStorageConfig:
def get_storage_by_id(self, user: User, endpoint: str, storage_id: str) -> CloudStorageConfig:
headers = None
if user is not None and user.access_token is not None and user.git_token is not None:
headers = {
"Authorization": f"bearer {user.access_token}",
"Gitlab-Access-Token": user.git_token,
}
# TODO: remove project_id once authz on the data service works properly
request_url = self.storage_url + f"/storage/{storage_id}?project_id={project_id}"
endpoint = endpoint.strip("/")
request_url = self.data_service_url + f"/{endpoint}/{storage_id}"
current_app.logger.info(f"getting storage info by id: {request_url}")
res = requests.get(request_url, headers=headers)
if res.status_code == 404:
Expand All @@ -53,17 +54,20 @@ def get_storage_by_id(self, user: User, project_id: int, storage_id: str) -> Clo
raise IntermittentError(
message="The data service sent an unexpected response, please try again later",
)
storage = res.json()["storage"]
response = res.json()
storage = response["storage"]
secrets = {s["secret_id"]: s["name"] for s in response["secrets"]} if "secrets" in response else {}
return CloudStorageConfig(
config=storage["configuration"],
source_path=storage["source_path"],
target_path=storage["target_path"],
readonly=storage.get("readonly", True),
name=storage["name"],
secrets=secrets,
)

def validate_storage_configuration(self, configuration: dict[str, Any], source_path: str) -> None:
res = requests.post(self.storage_url + "/storage_schema/validate", json=configuration)
res = requests.post(self.data_service_url + "/storage_schema/validate", json=configuration)
if res.status_code == 422:
raise InvalidCloudStorageConfiguration(
message=f"The provided cloud storage configuration isn't valid: {res.json()}",
Expand All @@ -75,7 +79,7 @@ def validate_storage_configuration(self, configuration: dict[str, Any], source_p

def obscure_password_fields_for_storage(self, configuration: dict[str, Any]) -> dict[str, Any]:
"""Obscures password fields for use with rclone."""
res = requests.post(self.storage_url + "/storage_schema/obscure", json=configuration)
res = requests.post(self.data_service_url + "/storage_schema/obscure", json=configuration)

if res.status_code != 200:
raise InvalidCloudStorageConfiguration(
Expand All @@ -87,7 +91,7 @@ def obscure_password_fields_for_storage(self, configuration: dict[str, Any]) ->

@dataclass
class DummyStorageValidator:
def get_storage_by_id(self, user: User, project_id: int, storage_id: str) -> CloudStorageConfig:
def get_storage_by_id(self, user: User, endpoint: str, storage_id: str) -> CloudStorageConfig:
raise NotImplementedError()

def validate_storage_configuration(self, configuration: dict[str, Any], source_path: str) -> None:
Expand Down
63 changes: 42 additions & 21 deletions renku_notebooks/api/notebooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from ..errors.intermittent import AnonymousUserPatchError, PVDisabledError
from ..errors.programming import ProgrammingError
from ..errors.user import MissingResourceError, UserInputError
from ..util.cryptography import get_user_key
from ..util.kubernetes_ import (
find_container,
renku_1_make_server_name,
Expand Down Expand Up @@ -218,6 +219,7 @@ def launch_notebook(
default_url=default_url,
lfs_auto_fetch=lfs_auto_fetch,
cloudstorage=cloudstorage,
cloudstorage_endpoint="storage",
server_options=server_options,
project_id=None,
launcher_id=None,
Expand Down Expand Up @@ -268,6 +270,7 @@ def renku_2_launch_notebook_helper(
default_url=default_url,
lfs_auto_fetch=lfs_auto_fetch,
cloudstorage=cloudstorage,
cloudstorage_endpoint="storages_v2",
server_options=server_options,
project_id=project_id,
launcher_id=launcher_id,
Expand All @@ -287,6 +290,7 @@ def launch_notebook_helper(
default_url,
lfs_auto_fetch,
cloudstorage,
cloudstorage_endpoint: str,
server_options,
namespace: str | None, # Renku 1.0
project: str | None, # Renku 1.0
Expand Down Expand Up @@ -407,15 +411,16 @@ def launch_notebook_helper(

storages = []
if cloudstorage:
gl_project_id = gl_project.id if gl_project is not None else 0
user_secret_key = get_user_key(data_svc_url=config.data_service_url, access_token=user.access_token) or ""
try:
for storage in cloudstorage:
storages.append(
RCloneStorage.storage_from_schema(
storage,
user=user,
project_id=gl_project_id,
endpoint=cloudstorage_endpoint,
work_dir=server_work_dir.absolute(),
user_secret_key=user_secret_key,
)
)
except ValidationError as e:
Expand Down Expand Up @@ -468,36 +473,52 @@ def launch_notebook_helper(

current_app.logger.debug(f"Server {server.server_name} has been started")

if k8s_user_secret is not None:
owner_reference = {
"apiVersion": "amalthea.dev/v1alpha1",
"kind": "JupyterServer",
"name": server.server_name,
"uid": manifest["metadata"]["uid"],
}
request_data = {
"name": k8s_user_secret.name,
"namespace": server.k8s_client.preferred_namespace,
"secret_ids": [str(id_) for id_ in k8s_user_secret.user_secret_ids],
"owner_references": [owner_reference],
}
headers = {"Authorization": f"bearer {user.access_token}"}
owner_reference = {
"apiVersion": "amalthea.dev/v1alpha1",
"kind": "JupyterServer",
"name": server.server_name,
"uid": manifest["metadata"]["uid"],
}

def create_secret(payload, type_message):
def _on_error(error_msg):
config.k8s.client.delete_server(server.server_name, forced=True, safe_username=user.safe_username)
raise RuntimeError(error_msg)

try:
response = requests.post(
config.user_secrets.secrets_storage_service_url + "/api/secrets/kubernetes",
json=request_data,
headers=headers,
json=payload,
headers={"Authorization": f"bearer {user.access_token}"},
)
except requests.exceptions.ConnectionError as exc:
_on_error(f"User secrets storage service could not be contacted {exc}")
_on_error(f"{type_message} storage service could not be contacted {exc}")
else:
if response.status_code != 201:
_on_error(f"{type_message} could not be created {response.json()}")

if k8s_user_secret is not None:
request_data = {
"name": k8s_user_secret.name,
"namespace": server.k8s_client.preferred_namespace,
"secret_ids": [str(id_) for id_ in k8s_user_secret.user_secret_ids],
"owner_references": [owner_reference],
}

create_secret(payload=request_data, type_message="User secrets")

# NOTE: Create a secret for each storage that has saved secrets
for cloud_storage in storages:
if cloud_storage.secrets and cloud_storage.base_name:
request_data = {
"name": f"{cloud_storage.base_name}-secrets",
"namespace": server.k8s_client.preferred_namespace,
"secret_ids": list(cloud_storage.secrets),
"owner_references": [owner_reference],
"key_mapping": cloud_storage.secrets,
}

if response.status_code != 201:
_on_error(f"User secret could not be created {response.json()}")
create_secret(payload=request_data, type_message="Saved storage secrets")

return NotebookResponse().dump(UserServerManifest(manifest)), 201

Expand Down
21 changes: 14 additions & 7 deletions renku_notebooks/api/schemas/cloud_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class Meta:
source_path: Optional[str] = fields.Str()
target_path: Optional[str] = fields.Str()
configuration: Optional[dict[str, Any]] = fields.Dict(
keys=fields.Str(), values=fields.Raw(), load_default=None, allow_none=True
keys=fields.Str(), values=fields.Raw(), load_default=dict, allow_none=True
)
storage_id: Optional[str] = fields.Str(load_default=None, allow_none=True)
readonly: bool = fields.Bool(load_default=True, allow_none=False)
Expand All @@ -42,44 +42,50 @@ def __init__(
readonly: bool,
mount_folder: str,
name: Optional[str],
secrets: dict[str, str],
user_secret_key: str,
) -> None:
config.storage_validator.validate_storage_configuration(configuration, source_path)
self.configuration = configuration
self.source_path = source_path
self.mount_folder = mount_folder
self.readonly = readonly
self.name = name
self.secrets = secrets
self.user_secret_key = user_secret_key
self.base_name: Optional[str] = None

@classmethod
def storage_from_schema(cls, data: dict[str, Any], user: User, project_id: int, work_dir: Path):
def storage_from_schema(cls, data: dict[str, Any], user: User, endpoint: str, work_dir: Path, user_secret_key: str):
"""Create storage object from request."""
name = None
if data.get("storage_id"):
# Load from storage service
if user.access_token is None:
raise ValidationError("Storage mounting is only supported for logged-in users.")
if project_id < 1:
raise ValidationError("Could not get gitlab project id")
(
configuration,
source_path,
target_path,
readonly,
name,
) = config.storage_validator.get_storage_by_id(user, project_id, data["storage_id"])
secrets,
) = config.storage_validator.get_storage_by_id(user, endpoint, data["storage_id"])
configuration = {**configuration, **(data.get("configuration", {}))}
readonly = readonly
else:
source_path = data["source_path"]
target_path = data["target_path"]
configuration = data["configuration"]
readonly = data.get("readonly", True)
secrets = {}
mount_folder = str(work_dir / target_path)

return cls(source_path, configuration, readonly, mount_folder, name)
return cls(source_path, configuration, readonly, mount_folder, name, secrets, user_secret_key)

def get_manifest_patch(self, base_name: str, namespace: str, labels={}, annotations={}) -> list[dict[str, Any]]:
"""Get server manifest patch."""
self.base_name = base_name
patches = []
patches.append(
{
Expand Down Expand Up @@ -116,6 +122,7 @@ def get_manifest_patch(self, base_name: str, namespace: str, labels={}, annotati
"stringData": {
"remote": self.name or base_name,
"remotePath": self.source_path,
"secretKey": self.user_secret_key,
"configData": self.config_string(self.name or base_name),
},
},
Expand Down Expand Up @@ -146,7 +153,7 @@ def get_manifest_patch(self, base_name: str, namespace: str, labels={}, annotati
return patches

def config_string(self, name: str) -> str:
"""Convert configuration oblect to string representation.
"""Convert the configuration object to string representation.
Needed to create RClone compatible INI files.
"""
Expand Down
Loading

0 comments on commit 4c9e0d4

Please sign in to comment.