Skip to content

Commit

Permalink
Merge branch 'staging'
Browse files Browse the repository at this point in the history
  • Loading branch information
churnikov committed Dec 12, 2024
2 parents 32553a2 + 1bf3a55 commit 5c72147
Show file tree
Hide file tree
Showing 48 changed files with 3,809 additions and 1,656 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ coverage.xml
.hypothesis/
.pytest_cache/

# Model diagrams from graphviz
*.dot

# Translations
*.mo
*.pot
Expand Down Expand Up @@ -152,6 +155,9 @@ node_modules
.DS_Store
.idea/

# Temporary Helm and k8s deployment files
charts/values/

# The media folder contents
media/*
!media/.gitkeep
Expand Down
2 changes: 1 addition & 1 deletion apps/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def deploy_resources(self, request, queryset):

for instance in queryset:
instance.set_k8s_values()
instance.url = get_URI(instance.k8s_values)
instance.url = get_URI(instance)
instance.save(update_fields=["k8s_values", "url"])

deploy_resource.delay(instance.serialize())
Expand Down
2 changes: 1 addition & 1 deletion apps/forms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def _setup_form_fields(self):

# Handle Volume field
volume_queryset = (
VolumeInstance.objects.filter(project__pk=self.project_pk)
VolumeInstance.objects.filter(project__pk=self.project_pk).exclude(app_status__status="Deleted")
if self.project_pk
else VolumeInstance.objects.none()
)
Expand Down
29 changes: 28 additions & 1 deletion apps/forms/custom.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from crispy_forms.bootstrap import Accordion, AccordionGroup, PrependedText
from crispy_forms.layout import HTML, Div, Field, Layout, MultiField
from django import forms
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.safestring import mark_safe

from apps.forms.base import AppBaseForm
from apps.forms.field.common import SRVCommonDivField
Expand All @@ -14,12 +18,23 @@ class CustomAppForm(AppBaseForm):
port = forms.IntegerField(min_value=3000, max_value=9999, required=True)
image = forms.CharField(max_length=255, required=True)
path = forms.CharField(max_length=255, required=False)
default_url_subpath = forms.CharField(max_length=255, required=False, label="Custom URL subpath")

def _setup_form_fields(self):
# Handle Volume field
super()._setup_form_fields()
self.fields["volume"].initial = None

self.fields["default_url_subpath"].widget.attrs.update({"class": "textinput form-control"})
self.fields["default_url_subpath"].help_text = "Specify a non-default start URL if your app requires that."
apps_url = reverse("portal:apps")
self.fields["default_url_subpath"].bottom_help_text = mark_safe(
(
f"<span class='fw-bold'>Note:</span> This changes the URL connected to the Open button for an app"
f" on the Serve <a href='{apps_url}'>Apps & Models</a> page."
)
)

def _setup_form_helper(self):
super()._setup_form_helper()

Expand All @@ -39,7 +54,18 @@ def _setup_form_helper(self):
placeholder="Describe why you want to make the app accessible only via a link",
),
SRVCommonDivField("port", placeholder="8000"),
SRVCommonDivField("image"),
SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"),
Accordion(
AccordionGroup(
"Advanced settings",
PrependedText(
"default_url_subpath",
mark_safe("<span id='id_custom_default_url_prepend'>Subdomain/</span>"),
template="apps/partials/srv_prepend_append_input_group.html",
),
active=False,
),
),
css_class="card-body",
)
self.helper.layout = Layout(body, self.footer)
Expand Down Expand Up @@ -81,6 +107,7 @@ class Meta:
"port",
"image",
"tags",
"default_url_subpath",
]
labels = {
"note_on_linkonly_privacy": "Reason for choosing the link only option",
Expand Down
45 changes: 40 additions & 5 deletions apps/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from enum import Enum
from typing import Optional

from django.core.exceptions import ObjectDoesNotExist
import regex as re
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import transaction

from apps.types_.subdomain import SubdomainCandidateName, SubdomainTuple
Expand Down Expand Up @@ -221,12 +222,15 @@ def update_status_time(status_object, status_ts, event_msg=None):
status_object.save(update_fields=["time", "info"])


def get_URI(values):
def get_URI(instance):
values = instance.k8s_values
# Subdomain is empty if app is already deleted
subdomain = values["subdomain"] if "subdomain" in values else ""
URI = f"https://{subdomain}.{values['global']['domain']}"

URI = URI.strip("/")
if hasattr(instance, "default_url_subpath") and instance.default_url_subpath != "":
URI = URI + "/" + instance.default_url_subpath
logger.info("Modified URI by adding custom default url for the custom app: %s", URI)
return URI


Expand Down Expand Up @@ -261,7 +265,16 @@ def create_instance_from_form(form, project, app_slug, app_id=None):
do_deploy = True
else:
# Only re-deploy existing apps if one of the following fields was changed:
redeployment_fields = ["subdomain", "volume", "path", "flavor", "port", "image", "access", "shiny_site_dir"]
redeployment_fields = [
"subdomain",
"volume",
"path",
"flavor",
"port",
"image",
"access",
"shiny_site_dir",
]
logger.debug(f"An existing app has changed. The changed form fields: {form.changed_data}")

# Because not all forms contain all fields, we check if the supposedly changed field
Expand Down Expand Up @@ -357,5 +370,27 @@ def save_instance_and_related_data(instance, form):
instance.save()
form.save_m2m()
instance.set_k8s_values()
instance.url = get_URI(instance.k8s_values)
instance.url = get_URI(instance)
instance.save(update_fields=["k8s_values", "url"])


def validate_path_k8s_label_compatible(candidate: str) -> None:
"""
Validates to be compatible with k8s labels specification.
See: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#syntax-and-character-set
The RegexValidator will raise a ValidationError if the input does not match the regular expression.
It is up to the caller to handle the raised exception if desired.
"""
error_message = (
"Please provide a valid path. "
"It can be empty. "
"Otherwise, it must be 63 characters or less. "
" It must begin and end with an alphanumeric character (a-z, or 0-9, or A-Z)."
" It could contain dashes ( - ), underscores ( _ ), dots ( . ), "
"and alphanumerics."
)

pattern = r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,61}[a-zA-Z0-9])?)?$"

if not re.match(pattern, candidate):
raise ValidationError(error_message)
23 changes: 23 additions & 0 deletions apps/migrations/0018_customappinstance_default_url_subpath.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 5.1.1 on 2024-11-19 13:27

import apps.models.app_types.custom.custom
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("apps", "0017_alter_streamlitinstance_port"),
]

operations = [
migrations.AddField(
model_name="customappinstance",
name="default_url_subpath",
field=models.CharField(
blank=True,
default="",
max_length=255,
validators=[apps.models.app_types.custom.custom.validate_default_url_subpath],
),
),
]
20 changes: 20 additions & 0 deletions apps/migrations/0019_alter_shinyinstance_shiny_site_dir.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 5.1.1 on 2024-11-27 12:47

import apps.models.app_types.shiny
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("apps", "0018_customappinstance_default_url_subpath"),
]

operations = [
migrations.AlterField(
model_name="shinyinstance",
name="shiny_site_dir",
field=models.CharField(
blank=True, default="", max_length=255, validators=[apps.helpers.validate_path_k8s_label_compatible]
),
),
]
28 changes: 28 additions & 0 deletions apps/migrations/0020_alter_jupyterinstance_environment_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-12-12 10:06

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("apps", "0019_alter_shinyinstance_shiny_site_dir"),
("projects", "0008_project_deleted_on"),
]

operations = [
migrations.AlterField(
model_name="jupyterinstance",
name="environment",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, to="projects.environment"
),
),
migrations.AlterField(
model_name="rstudioinstance",
name="environment",
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, to="projects.environment"
),
),
]
29 changes: 29 additions & 0 deletions apps/models/app_types/custom/custom.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
import regex as re
from django.core.exceptions import ValidationError
from django.db import models

from apps.models import AppInstanceManager, BaseAppInstance

from .base import AbstractCustomAppInstance


def validate_default_url_subpath(candidate):
"""
Validates a custom default url path addition.
The RegexValidator will raise a ValidationError if the input does not match the regular expression.
It is up to the caller to handle the raised exception if desired.
"""
error_message = (
"Your custom URL subpath is not valid, please correct it. "
"It must be 1-53 characters long."
" It can contain only Unicode letters, digits, hyphens"
" ( - ), forward slashes ( / ), and underscores ( _ )."
" It cannot start or end with a hyphen ( - ) and "
"cannot start with a forward slash ( / )."
" It cannot contain consecutive forward slashes ( // )."
)

pattern = r"^(?!-)(?!/)(?!.*//)[\p{Letter}\p{Mark}0-9-/_]{1,53}(?<!-)$|^$"

if not re.match(pattern, candidate):
raise ValidationError(error_message)


class CustomAppInstanceManager(AppInstanceManager):
model_type = "customappinstance"


class CustomAppInstance(AbstractCustomAppInstance, BaseAppInstance):
default_url_subpath = models.CharField(
validators=[validate_default_url_subpath], max_length=255, default="", blank=True
)
objects = CustomAppInstanceManager()
2 changes: 1 addition & 1 deletion apps/models/app_types/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class JupyterInstance(BaseAppInstance):
)
volume = models.ManyToManyField("VolumeInstance", blank=True)
access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES)
environment: Environment = models.ForeignKey(Environment, on_delete=models.DO_NOTHING, null=True, blank=True)
environment: Environment = models.ForeignKey(Environment, on_delete=models.RESTRICT, null=True, blank=True)

def get_k8s_values(self):
k8s_values = super().get_k8s_values()
Expand Down
2 changes: 1 addition & 1 deletion apps/models/app_types/rstudio.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class RStudioInstance(BaseAppInstance):
)
volume = models.ManyToManyField("VolumeInstance", blank=True)
access = models.CharField(max_length=20, default="project", choices=ACCESS_TYPES)
environment: Environment = models.ForeignKey(Environment, on_delete=models.DO_NOTHING, null=True, blank=True)
environment: Environment = models.ForeignKey(Environment, on_delete=models.RESTRICT, null=True, blank=True)

def get_k8s_values(self):
k8s_values = super().get_k8s_values()
Expand Down
5 changes: 4 additions & 1 deletion apps/models/app_types/shiny.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models

from apps.helpers import validate_path_k8s_label_compatible
from apps.models import (
AppInstanceManager,
BaseAppInstance,
Expand Down Expand Up @@ -35,7 +36,9 @@ class ShinyInstance(BaseAppInstance, SocialMixin, LogsEnabledMixin):
container_waittime = models.IntegerField(default=20000)
heartbeat_timeout = models.IntegerField(default=60000)
heartbeat_rate = models.IntegerField(default=10000)
shiny_site_dir = models.CharField(max_length=255, default="", blank=True)
shiny_site_dir = models.CharField(
validators=[validate_path_k8s_label_compatible], max_length=255, default="", blank=True
)

# The following three settings control the pre-init and seats behaviour (see documentation)
# These settings override the Helm chart default values
Expand Down
Loading

0 comments on commit 5c72147

Please sign in to comment.