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 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4c9ddaa
Convert ToolDeployment to a django model
michaeljcollinsuk Dec 27, 2024
3dcf2ae
Track if a deployment is active
michaeljcollinsuk Dec 27, 2024
c4deaf4
Simplify tool list view logic
michaeljcollinsuk Dec 27, 2024
9ad5fb9
WIP replace tool options with forms
michaeljcollinsuk Jan 2, 2025
bb1b3fc
WIP Fix tool options display
michaeljcollinsuk Jan 3, 2025
b98dfd5
Update tool deploy api view
michaeljcollinsuk Jan 3, 2025
757c1e4
Some cleanup
michaeljcollinsuk Jan 3, 2025
87d2ee2
Update tool deployment field name
michaeljcollinsuk Jan 3, 2025
3e15ab5
Refactor the tool views, remove unneeded code
michaeljcollinsuk Jan 6, 2025
9f5a738
Refactor building airflow urls
michaeljcollinsuk Jan 6, 2025
320efe3
Refactor serializer to create a ToolDeployment obj
michaeljcollinsuk Jan 7, 2025
741048d
Remove id_token from update_tool_status
michaeljcollinsuk Jan 7, 2025
0bd4103
Fix some bugs, add ordering
michaeljcollinsuk Jan 8, 2025
1815e8e
Update form display
michaeljcollinsuk Jan 8, 2025
690acf2
Display number of users on tool release list page
michaeljcollinsuk Jan 8, 2025
090ac62
Fix some tests
michaeljcollinsuk Jan 8, 2025
b4f20bf
Rebuild migrations
michaeljcollinsuk Jan 8, 2025
e0b0469
Fix more tests
michaeljcollinsuk Jan 8, 2025
899967d
Rebuild migration after rebase
michaeljcollinsuk Jan 20, 2025
3e6bbdd
Update tool restart implementation
michaeljcollinsuk Jan 21, 2025
508e33d
Refactor RestartTool view to use a form
michaeljcollinsuk Jan 22, 2025
8e08316
Revert to using a redirect view
michaeljcollinsuk Jan 22, 2025
9a31df1
Add management command to create ToolDeployments
michaeljcollinsuk Jan 24, 2025
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-20 15:59

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", "0055_alter_user_options"),
]

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