diff --git a/apps/helpers.py b/apps/helpers.py index 0f212b15..2b742be6 100644 --- a/apps/helpers.py +++ b/apps/helpers.py @@ -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 @@ -371,3 +372,25 @@ def save_instance_and_related_data(instance, form): instance.set_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) diff --git a/apps/migrations/0019_alter_shinyinstance_shiny_site_dir.py b/apps/migrations/0019_alter_shinyinstance_shiny_site_dir.py new file mode 100644 index 00000000..96eeb0e1 --- /dev/null +++ b/apps/migrations/0019_alter_shinyinstance_shiny_site_dir.py @@ -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] + ), + ), + ] diff --git a/apps/models/app_types/shiny.py b/apps/models/app_types/shiny.py index f17a8080..d311e354 100644 --- a/apps/models/app_types/shiny.py +++ b/apps/models/app_types/shiny.py @@ -1,5 +1,6 @@ from django.db import models +from apps.helpers import validate_path_k8s_label_compatible from apps.models import ( AppInstanceManager, BaseAppInstance, @@ -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 diff --git a/apps/tests/test_forms.py b/apps/tests/test_forms.py index 845b44d1..17d88374 100644 --- a/apps/tests/test_forms.py +++ b/apps/tests/test_forms.py @@ -5,6 +5,7 @@ from django.test import TestCase from apps.forms import CustomAppForm +from apps.helpers import validate_path_k8s_label_compatible from apps.models import Apps, AppStatus, Subdomain, VolumeInstance from apps.models.app_types.custom.custom import validate_default_url_subpath from projects.models import Flavor, Project @@ -240,3 +241,71 @@ def test_invalid_default_url_subpath(invalid_default_url_subpath): valid_check = False assert not valid_check + + +invalid_shiny_site_dir_list = [ + "-invalidStart", # Starts with a non-alphanumeric character + "invalidEnd-", # Ends with a non-alphanumeric character + ".dotStart", # Starts with a dot + "dotEnd.", # Ends with a dot + "_underscoreStart", # Starts with an underscore + "underscoreEnd_", # Ends with an underscore + "label with spaces", # Contains spaces + "label@value", # Contains an invalid character (@) + "too_long_label_with_more_than_sixty_three_characters__1234567890", # Exceeds 63 characters + "just-dashes-", # Ends with a dash + "123#", # Contains an invalid character (#) + ".....", # Only contains dots + "-a", # Starts with a dash + "_a_", # Starts and ends with underscores + " ", # Contains only whitespace +] + +valid_shiny_site_dir_list = [ + "", # Empty string is allowed + "a", # Single alphanumeric character + "validLabel", # Alphanumeric characters only + "label-123", # Contains a dash + "label_with_underscores", # Contains underscores + "label.with.dots", # Contains dots + "abc-def_ghi.jkl", # Contains all allowed special characters + "label1", # Ends with a number + "1stLabel", # Starts with a number + "example-label", # Simple example with a dash + "nested.label.value", # Dots between words + "underscore_ending_label", # Underscore in the middle + "valid-value-0123", # Contains numbers and special characters + "long-valid-label-abcdefg-hijklmn-opqrstuv-wxyz", # Long but within 63 characters + "labelvalue123456789", # Combination of letters and numbers + "consecutive-dashes--allowed", # Contains consecutive dashes + "consecutive_underscores__allowed", # Contains consecutive underscores + "dots..in..between", # Contains consecutive dots + "mixed__--..label", # Contains a mix of consecutive allowed characters + "label---with---many---dashes", # Multiple consecutive dashes in different parts + "label..with..dots", # Multiple consecutive dots in between + "valid_--..mix_12", # Combination of numbers, letters, and allowed characters + "simple.label-value_1-2-3", # Mixed with numbers, dots, underscores, and dashes + "complex__label..value--mixed", # A complex mix with all allowed characters in a consecutive manner +] + + +@pytest.mark.parametrize("valid_shiny_site_dir", valid_shiny_site_dir_list) +def test_valid_shiny_site_dir(valid_shiny_site_dir): + valid_check = True + try: + validate_path_k8s_label_compatible(valid_shiny_site_dir) + except ValidationError: + valid_check = False + + assert valid_check + + +@pytest.mark.parametrize("invalid_shiny_site_dir", invalid_shiny_site_dir_list) +def test_invalid_shiny_site_dir(invalid_shiny_site_dir): + valid_check = True + try: + validate_path_k8s_label_compatible(invalid_shiny_site_dir) + except ValidationError: + valid_check = False + + assert not valid_check diff --git a/cypress/e2e/ui-tests/test-deploy-app.cy.js b/cypress/e2e/ui-tests/test-deploy-app.cy.js index cc82ca63..4cffdff7 100644 --- a/cypress/e2e/ui-tests/test-deploy-app.cy.js +++ b/cypress/e2e/ui-tests/test-deploy-app.cy.js @@ -278,6 +278,7 @@ describe("Test deploying app", () => { }) // This test is skipped because it will only work against a Serve instance running on our cluster. should be switched on for the e2e tests against remote. + // We need to add a test here for validating Site-dir option. See SS-1206 for details it.skip("can deploy a shiny app", { defaultCommandTimeout: defaultCmdTimeoutMs }, () => { // Names of objects to create const project_name = "e2e-deploy-app-test"