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

feat!: Replace Jupyter file-shares with generic project level volumes #2230

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

"""Add general file-shares

Revision ID: b2fd698ed6cb
Revises: ca9ce61491a7
Create Date: 2025-02-21 15:41:21.391386

"""

import datetime
import logging

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

logger = logging.getLogger(__name__)

# revision identifiers, used by Alembic.
revision = "b2fd698ed6cb"
down_revision = "ca9ce61491a7"
branch_labels = None
depends_on = None

t_tool_models = sa.Table(
"tool_models",
sa.MetaData(),
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("project_id", sa.Integer()),
sa.Column("tool_id"),
sa.Column("configuration", postgresql.JSONB(astext_type=sa.Text())),
)

t_tools = sa.Table(
"tools",
sa.MetaData(),
sa.Column("id", sa.Integer()),
sa.Column("integrations", postgresql.JSONB(astext_type=sa.Text())),
)


def upgrade():
t_project_volumes = op.create_table(
"project_volumes",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("pvc_name", sa.String(), nullable=False),
sa.Column("size", sa.String(), nullable=False),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["project_id"],
["projects.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("pvc_name"),
)
op.create_index(
op.f("ix_project_volumes_id"), "project_volumes", ["id"], unique=False
)

connection = op.get_bind()
tools = connection.execute(sa.select(t_tools)).mappings().all()

jupyter_tool_ids = []
for tool in tools:
if tool["integrations"]["jupyter"]:
jupyter_tool_ids.append(tool["id"])

tool_models = (
connection.execute(
sa.select(t_tool_models).where(
t_tool_models.c.tool_id.in_(jupyter_tool_ids)
)
)
.mappings()
.all()
)

grouped_by_project = {}
for tool_model in tool_models:
logger.info(
"Adding general file share for Jupyter model %s in project %s",
tool_model["id"],
tool_model["project_id"],
)

project_id = tool_model["project_id"]
if project_id in grouped_by_project:
raise ValueError(
"Due a removal of the abstraction layer for"
" Jupyter file-shares, it's no longer possible to"
" have multiple file-shares in one project."
f" In the project {project_id} we've identified multiple Jupyter models."
" Please rollback, merge the files in the file-shares manually"
" and remove empty Jupyter models."
" Then try again."
)

if "workspace" in tool_model["configuration"]:
grouped_by_project[project_id] = {
"created_at": datetime.datetime.now(tz=datetime.UTC),
"pvc_name": "shared-workspace-"
+ tool_model["configuration"]["workspace"],
"project_id": tool_model["project_id"],
"size": "2Gi",
}

op.bulk_insert(
t_project_volumes,
list(grouped_by_project.values()),
)
1 change: 1 addition & 0 deletions backend/capellacollab/core/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import capellacollab.projects.toolmodels.restrictions.models
import capellacollab.projects.tools.models
import capellacollab.projects.users.models
import capellacollab.projects.volumes.models
import capellacollab.sessions.models
import capellacollab.settings.integrations.purevariants.models
import capellacollab.settings.modelsources.git.models
Expand Down
8 changes: 6 additions & 2 deletions backend/capellacollab/projects/permissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,18 @@ class ProjectUserScopes(core_pydantic.BaseModel):
t.Literal[
permissions_models.UserTokenVerb.GET,
permissions_models.UserTokenVerb.UPDATE,
permissions_models.UserTokenVerb.CREATE,
permissions_models.UserTokenVerb.DELETE,
]
] = pydantic.Field(
default_factory=set,
title="Shared Workspaces",
description=(
"Access to shared Jupyter workspaces."
"Access to shared project volumes / workspaces."
" GET will provide read-only access,"
" UPDATE will provide read & write access."
" UPDATE will provide read & write access,"
" CREATE will provide the ability to create a new shared volume,"
" DELETE will provide the ability to delete shared volumes."
),
)

Expand Down
5 changes: 5 additions & 0 deletions backend/capellacollab/projects/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from capellacollab.projects.users import crud as projects_users_crud
from capellacollab.projects.users import models as projects_users_models
from capellacollab.projects.users import routes as projects_users_routes
from capellacollab.projects.volumes import routes as volumes_routes
from capellacollab.users import injectables as users_injectables
from capellacollab.users import models as users_models
from capellacollab.users.tokens import models as tokens_models
Expand Down Expand Up @@ -283,3 +284,7 @@ def _delete_all_pipelines_for_project(
prefix="/-/permissions",
tags=["Projects - Permissions"],
)
router.include_router(
volumes_routes.router,
prefix="/{project_slug}",
)
2 changes: 0 additions & 2 deletions backend/capellacollab/projects/toolmodels/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ def create_model(
tool: tools_models.DatabaseTool,
version: tools_models.DatabaseVersion | None = None,
nature: tools_models.DatabaseNature | None = None,
configuration: dict[str, str] | None = None,
display_order: int | None = None,
) -> models.DatabaseToolModel:
model = models.DatabaseToolModel(
Expand All @@ -93,7 +92,6 @@ def create_model(
tool=tool,
version=version,
nature=nature,
configuration=configuration,
display_order=display_order,
)

Expand Down
24 changes: 2 additions & 22 deletions backend/capellacollab/projects/toolmodels/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
# SPDX-License-Identifier: Apache-2.0

import typing as t
import uuid

import fastapi
import slugify
Expand All @@ -26,7 +25,7 @@
from capellacollab.users import models as users_models
from capellacollab.users.tokens import models as tokens_models

from . import crud, exceptions, injectables, models, workspace
from . import crud, exceptions, injectables, models
from .backups import routes as backups_routes
from .diagrams import routes as diagrams_routes
from .modelbadge import routes as complexity_badge_routes
Expand Down Expand Up @@ -109,26 +108,14 @@ def create_new_tool_model(
tool = tools_injectables.get_existing_tool(
tool_id=new_model.tool_id, db=db
)
configuration = {}
if tool.integrations.jupyter:
configuration["workspace"] = str(uuid.uuid4())

slug = slugify.slugify(new_model.name)
if project.type not in tool.config.supported_project_types:
raise exceptions.ProjectTypeNotSupportedByToolModel(project.slug, slug)
if crud.get_model_by_slugs(db, project.slug, slug):
raise exceptions.ToolModelAlreadyExistsError(project.slug, slug)

model = crud.create_model(
db, project, new_model, tool, configuration=configuration
)

if tool.integrations.jupyter:
workspace.create_shared_workspace(
configuration["workspace"], project, model, "2Gi"
)

return model
return crud.create_model(db, project, new_model, tool)


@router.patch(
Expand Down Expand Up @@ -269,13 +256,6 @@ def delete_tool_model(
model.name, f"{model.tool.name} model", dependencies
)

if (
model.tool.integrations.jupyter
and model.configuration
and "workspace" in model.configuration
):
workspace.delete_shared_workspace(model.configuration["workspace"])

crud.delete_model(db, model)


Expand Down
2 changes: 2 additions & 0 deletions backend/capellacollab/projects/volumes/crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0
40 changes: 40 additions & 0 deletions backend/capellacollab/projects/volumes/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

from fastapi import status

from capellacollab.core import exceptions


class OnlyOneVolumePerProjectError(exceptions.BaseError):
def __init__(self, project_slug: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
title="One one volume per project allowed",
reason=(
f"You can't add another volume to the project '{project_slug}'."
" Each project can only have a maximum of one volume."
" Reuse the existing volume or delete the old volume first."
),
err_code="ONLY_ONE_VOLUME_PER_PROJECT",
)

@classmethod
def openapi_example(cls) -> "OnlyOneVolumePerProjectError":
return cls("test")


class VolumeNotFoundError(exceptions.BaseError):
def __init__(self, volume_id: int, project_slug: str):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
title="Project volume not found",
reason=(
f"Couldn't find the volume {volume_id} in the project '{project_slug}'."
),
err_code="PROJECT_VOLUME_NOT_FOUND",
)

@classmethod
def openapi_example(cls) -> "VolumeNotFoundError":
return cls(-1, "test")
39 changes: 39 additions & 0 deletions backend/capellacollab/projects/volumes/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import datetime

import pydantic
import sqlalchemy as sa
from sqlalchemy import orm

from capellacollab.core import database
from capellacollab.core import pydantic as core_pydantic
from capellacollab.projects import models as projects_models


class ProjectVolume(core_pydantic.BaseModel):
created_at: datetime.datetime
size: str
pvc_name: str

_validate_created_at = pydantic.field_serializer("created_at")(
core_pydantic.datetime_serializer
)


class DatabaseProjectVolume(database.Base):
__tablename__ = "project_volumes"

id: orm.Mapped[int] = orm.mapped_column(
init=False, primary_key=True, index=True, autoincrement=True
)

created_at: orm.Mapped[datetime.datetime]
pvc_name: orm.Mapped[str] = orm.mapped_column(unique=True)
size: orm.Mapped[str]

project_id: orm.Mapped[int] = orm.mapped_column(
sa.ForeignKey("projects.id"), init=False
)
project: orm.Mapped[projects_models.DatabaseProject] = orm.relationship()
67 changes: 67 additions & 0 deletions backend/capellacollab/projects/volumes/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# SPDX-FileCopyrightText: Copyright DB InfraGO AG and contributors
# SPDX-License-Identifier: Apache-2.0

import fastapi

from capellacollab.permissions import models as permissions_models
from capellacollab.projects.permissions import (
injectables as projects_permissions_injectables,
)
from capellacollab.projects.permissions import (
models as projects_permissions_models,
)

from . import models

router = fastapi.APIRouter(
prefix="/volumes",
tags=["Projects - Volumes"],
)


@router.get(
"",
dependencies=[
fastapi.Depends(
projects_permissions_injectables.ProjectPermissionValidation(
required_scope=projects_permissions_models.ProjectUserScopes(
project_users={permissions_models.UserTokenVerb.GET}
)
)
)
],
)
def get_project_volumes() -> models.ProjectVolume:
return []


@router.get(
"",
dependencies=[
fastapi.Depends(
projects_permissions_injectables.ProjectPermissionValidation(
required_scope=projects_permissions_models.ProjectUserScopes(
project_users={permissions_models.UserTokenVerb.CREATE}
)
)
)
],
)
def create_project_volume():
return {}


@router.get(
"",
dependencies=[
fastapi.Depends(
projects_permissions_injectables.ProjectPermissionValidation(
required_scope=projects_permissions_models.ProjectUserScopes(
project_users={permissions_models.UserTokenVerb.DELETE}
)
)
)
],
)
def delete_project_volume():
return {}
Loading
Loading