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

Convert ToolDeployment to a django model #1423

Draft
wants to merge 18 commits 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
15 changes: 11 additions & 4 deletions controlpanel/api/cluster.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,7 +1007,9 @@ def _set_values(self, **kwargs):
return set_values

def install(self, **kwargs):
self._delete_legacy_release()
# TODO remove as should no longer be necessary as we uninstall the previous release before
# installing the new one
# self._delete_legacy_release()

try:
set_values = self._set_values(**kwargs)
Expand All @@ -1026,9 +1028,12 @@ def install(self, **kwargs):
except helm.HelmError as error:
raise ToolDeploymentError(error)

def uninstall(self, id_token):
deployment = self.get_deployment(id_token)
helm.delete(self.k8s_namespace, deployment.metadata.name)
def uninstall(self):
try:
return helm.delete(self.k8s_namespace, self.release_name)
except helm.HelmError as error:
# TODO make this less generic
raise ToolDeploymentError(error)

def restart(self, id_token):
k8s = KubernetesClient(id_token=id_token)
Expand Down Expand Up @@ -1119,6 +1124,8 @@ def get_status(self, id_token, deployment=None):

if "Available" in conditions:
if conditions["Available"].status == "True":
# TODO to save us having to call the KubeAPI to get deployments we could use the
# ToolDeployment created/modified timestamp to determine if the tool is idle
if deployment.spec.replicas == 0:
return TOOL_IDLED
return TOOL_READY
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Generated by Django 5.1.2 on 2025-01-08 16:33

import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("api", "0054_alter_tool_description"),
]

operations = [
migrations.CreateModel(
name="ToolDeployment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"tool_type",
models.CharField(
choices=[
("jupyter", "JupyterLab"),
("rstudio", "RStudio"),
("vscode", "Visual Studio Code"),
],
max_length=100,
),
),
("is_active", models.BooleanField(default=False)),
(
"tool",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tool_deployments",
to="api.tool",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tool_deployments",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created"],
},
),
migrations.AddField(
model_name="tool",
name="users_deployed",
field=models.ManyToManyField(
related_name="deployed_tools",
through="api.ToolDeployment",
to=settings.AUTH_USER_MODEL,
),
),
]
103 changes: 61 additions & 42 deletions controlpanel/api/models/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@ class Tool(TimeStampedModel):
instance of a tool.
"""

# Defines how a matching chart name is put into a named tool bucket.
# E.g. jupyter-* charts all end up in the jupyter-lab bucket.
# chart name match: tool bucket
TOOL_BOX_CHART_LOOKUP = {
"jupyter": "jupyter-lab",
"rstudio": "rstudio",
"vscode": "vscode",
}
DEFAULT_DEPRECATED_MESSAGE = "The selected release has been deprecated and will be retired soon. Please update to a more recent version." # noqa
JUPYTER_DATASCIENCE_CHART_NAME = "jupyter-lab-datascience-notebook"
JUPYTER_ALL_SPARK_CHART_NAME = "jupyter-lab-all-spark"
Expand Down Expand Up @@ -67,6 +59,9 @@ class Tool(TimeStampedModel):
)
is_retired = models.BooleanField(default=False)
image_tag = models.CharField(max_length=100)
users_deployed = models.ManyToManyField(
"User", through="ToolDeployment", related_name="deployed_tools"
)

class Meta(TimeStampedModel.Meta):
db_table = "control_panel_api_tool"
Expand All @@ -75,9 +70,8 @@ class Meta(TimeStampedModel.Meta):
def __repr__(self):
return f"<Tool: {self.chart_name} {self.version}>"

def url(self, user):
tool = self.tool_domain or self.chart_name
return f"https://{user.slug}-{tool}.{settings.TOOLS_DOMAIN}/"
def __str__(self):
return f"[{self.chart_name} {self.image_tag}] {self.description}"

def save(self, *args, **kwargs):
helm.update_helm_repository(force=True)
Expand Down Expand Up @@ -131,57 +125,78 @@ def status_colour(self):
}
return mapping[self.status.lower()]

@property
def tool_type(self):
return self.chart_name.split("-")[0]

class ToolDeploymentManager:
"""
Emulates a Django model manager
"""
@property
def tool_type_name(self):
mapping = {
"jupyter": "JupyterLab",
"rstudio": "RStudio",
"vscode": "Visual Studio Code",
}
return mapping[self.tool_type]


class ToolDeploymentQuerySet(models.QuerySet):
def active(self):
return self.filter(is_active=True)

def create(self, *args, **kwargs):
tool_deployment = ToolDeployment(*args, **kwargs)
tool_deployment.save()
return tool_deployment
def inactive(self):
return self.filter(is_active=False)


class ToolDeployment:
class ToolDeployment(TimeStampedModel):
"""
Represents a deployed Tool in the cluster
"""

DoesNotExist = django.core.exceptions.ObjectDoesNotExist
class ToolType(models.TextChoices):
JUPYTER = "jupyter", "JupyterLab"
RSTUDIO = "rstudio", "RStudio"
VSCODE = "vscode", "Visual Studio Code"

user = models.ForeignKey(to="User", on_delete=models.CASCADE, related_name="tool_deployments")
tool = models.ForeignKey(to="Tool", on_delete=models.CASCADE, related_name="tool_deployments")
tool_type = models.CharField(max_length=100, choices=ToolType.choices)
is_active = models.BooleanField(default=False)

Error = cluster.ToolDeploymentError
MultipleObjectsReturned = django.core.exceptions.MultipleObjectsReturned

objects = ToolDeploymentManager()
objects = ToolDeploymentQuerySet.as_manager()

class Meta:
ordering = ["-created"]

def __init__(self, tool, user, old_chart_name=None):
def __init__(self, *args, **kwargs):
# TODO these may not be necessary but leaving for now
self._subprocess = None
self.tool = tool
self.user = user
self.old_chart_name = old_chart_name
super().__init__(*args, **kwargs)

def __repr__(self):
return f"<ToolDeployment: {self.tool!r} {self.user!r}>"

def delete(self, id_token):
def uninstall(self):
"""
Remove the release from the cluster
"""
cluster.ToolDeployment(self.user, self.tool).uninstall(id_token)
return cluster.ToolDeployment(tool=self.tool, user=self.user).uninstall()

@property
def host(self):
return f"{self.user.slug}-{self.tool.chart_name}.{settings.TOOLS_DOMAIN}"
def delete(self, *args, **kwargs):
"""
Remove the release from the cluster
"""
self.uninstall()
super().delete(*args, **kwargs)

def save(self, *args, **kwargs):
def deploy(self):
"""
Deploy the tool to the cluster (asynchronous)
"""
self._subprocess = cluster.ToolDeployment(
self.user, self.tool, self.old_chart_name
).install()
self._subprocess = cluster.ToolDeployment(self.user, self.tool).install()

def get_status(self, id_token, deployment=None):
def get_status(self, id_token=None, deployment=None):
"""
Get the current status of the deployment.
Polls the subprocess if running, otherwise returns idled status.
Expand All @@ -194,9 +209,17 @@ def get_status(self, id_token, deployment=None):
log.info(status)
return status
return cluster.ToolDeployment(self.user, self.tool).get_status(
id_token, deployment=deployment
id_token or self.user.get_id_token(), deployment=deployment
)

@property
def url(self):
tool = self.tool.tool_domain or self.tool.chart_name
url = f"https://{self.user.slug}-{tool}.{settings.TOOLS_DOMAIN}/"
if self.tool_type == self.ToolType.VSCODE:
url = f"{url}?folder=/home/analyticalplatform/workspace"
return url

def _poll(self):
"""
Poll the deployment subprocess for status
Expand All @@ -212,10 +235,6 @@ def _poll(self):
log.info(self._subprocess.stdout.read().strip())
self._subprocess = None

@property
def url(self):
return f"https://{self.host}/"

def restart(self, id_token):
"""
Restart the tool deployment
Expand Down
47 changes: 37 additions & 10 deletions controlpanel/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@
AppS3Bucket,
IPAllowlist,
S3Bucket,
ToolDeployment,
User,
UserApp,
UserS3Bucket,
)
from controlpanel.utils import start_background_task


class AppS3BucketSerializer(serializers.ModelSerializer):
Expand Down Expand Up @@ -337,17 +339,42 @@ class DeleteAppCustomerSerializer(serializers.Serializer):
env_name = serializers.CharField(max_length=64, required=True)


class ToolDeploymentSerializer(serializers.Serializer):
old_chart_name = serializers.CharField(max_length=64, required=False)
version = serializers.CharField(max_length=64, required=True)
class ToolDeploymentSerializer(serializers.ModelSerializer):
class Meta:
model = ToolDeployment
fields = ("tool",)

def validate_version(self, value):
try:
_, _, _ = value.split("__")
except ValueError:
raise serializers.ValidationError(
"This field include chart name, version and tool.id," ' they are joined by "__".'
)
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)

def create(self, validated_data):
tool = validated_data["tool"]
# get the currently active deployment
previous_deployment = ToolDeployment.objects.filter(
user=self.request.user, tool_type=tool.tool_type, is_active=True
).first()
# mark all previous deployments for this tool type as inactive
ToolDeployment.objects.filter(user=self.request.user, tool_type=tool.tool_type).update(
is_active=False
)
# create the new active deployment record
new_deployment = ToolDeployment.objects.create(
tool=tool,
tool_type=tool.tool_type,
user=self.request.user,
is_active=True,
)
# use these details to start a background process to uninstall the deploy the new tool
# TODO we may want to refactor this to be handled by celery
start_background_task(
"tool.deploy",
{
"new_deployment_id": new_deployment.id,
"previous_deployment_id": previous_deployment.id if previous_deployment else None,
},
)
return new_deployment


class ESBucketHitsSerializer(serializers.BaseSerializer):
Expand Down
Loading
Loading