Skip to content

Commit

Permalink
Merge remote-tracking branch 'refs/remotes/origin/develop' into SS-12…
Browse files Browse the repository at this point in the history
  • Loading branch information
churnikov committed Feb 21, 2025
2 parents 43be5b1 + a01a52b commit f7369ac
Show file tree
Hide file tree
Showing 44 changed files with 667 additions and 337 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/e2e-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,13 @@ jobs:
config: pageLoadTimeout=100000,baseUrl=${{ env.STUDIO_URL }}
quiet: true
- name: Save cypress screenshot artifacts on failure
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
- name: Save cypress video artifacts on failure
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-videos
Expand Down
2 changes: 2 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ProjectList,
ProjectTemplateList,
ResourceList,
docker_image_search,
get_subdomain_input_html,
get_subdomain_is_available,
get_subdomain_is_valid,
Expand Down Expand Up @@ -57,4 +58,5 @@
path("app-subdomain/validate/", get_subdomain_is_valid),
path("app-subdomain/is-available/", get_subdomain_is_available),
path("htmx/subdomain-input/", get_subdomain_input_html, name="get_subdomain_input_html"),
path("docker_image_search/", docker_image_search, name="docker_image_search"),
]
41 changes: 41 additions & 0 deletions api/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import List

import requests
from django.conf import settings


def fetch_docker_hub_images_and_tags(query: str) -> List[str]:
"""
Fetch Docker images and latest tags matching a query.
This function fetches images with the highest pull count.
"""
image_search_url = f"{settings.DOCKER_HUB_IMAGE_SEARCH}?query={query}"
try:
response = requests.get(image_search_url, timeout=3)
response.raise_for_status()
results = response.json().get("results", [])
except requests.RequestException:
return []

# Sort images by pull count
sorted_results = sorted(results, key=lambda x: x.get("pull_count", 0), reverse=True)

images = []
# Use the top 5 images
for repo in sorted_results[:5]:
repo_name = repo["repo_name"]

# Fetch available tags
tags_search_url = f"{settings.DOCKER_HUB_TAG_SEARCH}{repo_name}/tags/?page_size=3"
try:
tag_response = requests.get(tags_search_url, timeout=2)
tag_response.raise_for_status()
tags = [tag["name"] for tag in tag_response.json().get("results", [])]
except requests.RequestException:
# Default to latest if tags cannot be fetched
tags = ["latest"]

for tag in tags:
images.append(f"docker.io/{repo_name}:{tag}")

return images
20 changes: 19 additions & 1 deletion api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from datetime import datetime, timedelta, timezone

import pytz
import requests
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models import Q
from django.http import HttpRequest, HttpResponse
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.template import loader
from django.utils.safestring import mark_safe
from django_filters.rest_framework import DjangoFilterBackend
Expand Down Expand Up @@ -57,6 +58,7 @@
ProjectTemplateSerializer,
UserSerializer,
)
from .utils import fetch_docker_hub_images_and_tags

logger = get_logger(__name__)

Expand Down Expand Up @@ -956,3 +958,19 @@ def update_app_status(request: HttpRequest) -> HttpResponse:
# GET verb
logger.info("API method update_app_status called with GET verb.")
return Response({"message": "DEBUG: GET"})


@api_view(["GET"])
@permission_classes(
(
# IsAuthenticated,
)
)
def docker_image_search(request):
query = request.GET.get("query", "").strip()
if not query:
return JsonResponse({"error": "Query parameter is required"}, status=400)

docker_images = fetch_docker_hub_images_and_tags(query)

return JsonResponse({"images": docker_images})
9 changes: 7 additions & 2 deletions apps/forms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,14 @@ def _setup_form_fields(self):
def _setup_form_helper(self):
# Create a footer for submit form or cancel
self.footer = Div(
Button("cancel", "Cancel", css_class="btn-danger", onclick="window.history.back()"),
Button(
"cancel",
"Cancel",
css_class="btn-outline-dark btn-outline-cancel me-2",
onclick="window.history.back()",
),
Submit("submit", "Submit"),
css_class="card-footer d-flex justify-content-between",
css_class="card-footer d-flex justify-content-end",
)
self.helper = FormHelper(self)
self.helper.form_method = "post"
Expand Down
12 changes: 9 additions & 3 deletions apps/forms/custom.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import requests
from crispy_forms.bootstrap import Accordion, AccordionGroup, PrependedText
from crispy_forms.layout import HTML, Div, Field, Layout, MultiField
from django import forms
from django.conf import settings
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
from apps.forms.mixins import ContainerImageMixin
from apps.models import CustomAppInstance, VolumeInstance
from projects.models import Flavor

__all__ = ["CustomAppForm"]


class CustomAppForm(AppBaseForm):
class CustomAppForm(ContainerImageMixin, AppBaseForm):
flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None)
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")

Expand All @@ -35,6 +37,9 @@ def _setup_form_fields(self):
)
)

# Setup container image field from mixin
self._setup_container_image_field()

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

Expand All @@ -54,7 +59,8 @@ 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", placeholder="e.g. docker.io/username/image-name:image-tag"),
# Container image field
self._setup_container_image_helper(),
Accordion(
AccordionGroup(
"Advanced settings",
Expand Down
10 changes: 7 additions & 3 deletions apps/forms/dash.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@

from apps.forms.base import AppBaseForm
from apps.forms.field.common import SRVCommonDivField
from apps.forms.mixins import ContainerImageMixin
from apps.models import DashInstance
from projects.models import Flavor

__all__ = ["DashForm"]


class DashForm(AppBaseForm):
class DashForm(ContainerImageMixin, AppBaseForm):
flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None)
port = forms.IntegerField(min_value=3000, max_value=9999, required=True)
image = forms.CharField(max_length=255, required=True)
default_url_subpath = forms.CharField(max_length=255, required=False, label="Custom URL subpath")

def _setup_form_fields(self):
Expand All @@ -33,6 +33,9 @@ def _setup_form_fields(self):
)
)

# Setup container image field from mixin
self._setup_container_image_field()

def _setup_form_helper(self):
super()._setup_form_helper()
body = Div(
Expand All @@ -50,7 +53,8 @@ def _setup_form_helper(self):
),
SRVCommonDivField("source_code_url", placeholder="Provide a link to the public source code"),
SRVCommonDivField("port", placeholder="8000"),
SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"),
# Container image field
self._setup_container_image_helper(),
Accordion(
AccordionGroup(
"Advanced settings",
Expand Down
9 changes: 7 additions & 2 deletions apps/forms/filemanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ def _setup_form_helper(self):
super()._setup_form_helper()

self.footer = Div(
Button("cancel", "Cancel", css_class="btn-danger", onclick="window.history.back()"),
Button(
"cancel",
"Cancel",
css_class="btn-outline-dark btn-outline-cancel me-2",
onclick="window.history.back()",
),
Submit("submit", "Activate", css_class="btn-profile text-dark"),
css_class="card-footer d-flex justify-content-between",
css_class="card-footer d-flex justify-content-end",
)
body = Div(
Div(
Expand Down
10 changes: 7 additions & 3 deletions apps/forms/gradio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@

from apps.forms.base import AppBaseForm
from apps.forms.field.common import SRVCommonDivField
from apps.forms.mixins import ContainerImageMixin
from apps.models import GradioInstance
from projects.models import Flavor

__all__ = ["GradioForm"]


class GradioForm(AppBaseForm):
class GradioForm(ContainerImageMixin, AppBaseForm):
flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None)
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)

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

# Setup container image field from mixin
self._setup_container_image_field()

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

Expand All @@ -39,7 +42,8 @@ def _setup_form_helper(self):
placeholder="Describe why you want to make the app accessible only via a link",
),
SRVCommonDivField("port", placeholder="7860"),
SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"),
# Container image field
self._setup_container_image_helper(),
css_class="card-body",
)
self.helper.layout = Layout(body, self.footer)
Expand Down
83 changes: 83 additions & 0 deletions apps/forms/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import requests
from crispy_forms.layout import HTML, Div, Field, MultiField
from django import forms
from django.conf import settings


class ContainerImageMixin:
"""Mixin to add a reusable container image field and validation method."""

image = forms.CharField(
max_length=255,
required=True,
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "e.g. docker.io/username/image-name:image-tag",
"list": "docker-image-list",
}
),
)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._setup_container_image_field()

def _setup_container_image_field(self):
"""Setup the container image field in the form."""
self.fields["image"] = self.image

def _setup_container_image_helper(self):
"""Returns the crispy layout for the container image field."""
return Div(
Field(
"image",
css_class="form-control",
placeholder="e.g. docker.io/username/image-name:image-tag",
list="docker-image-list",
),
HTML('<datalist id="docker-image-list"></datalist>'),
css_class="mb-3",
)

def clean_image(self):
"""Validate the container image input."""
image = self.cleaned_data.get("image", "").strip()
if not image:
self.add_error("image", "Container image field cannot be empty.")
return image

# Ignore non-Docker images for now
if "docker.io" not in image:
return image

# Split image into repository and tag
if ":" in image:
repository, tag = image.rsplit(":", 1)
else:
repository, tag = image, "latest"

repository = repository.replace("docker.io/", "", 1)

# Ensure repository is in the correct format
# The request to Docker hub will fail otherwise
if "/" not in repository:
repository = f"library/{repository}"

# Docker Hub API endpoint for checking the image
docker_api_url = f"{settings.DOCKER_HUB_TAG_SEARCH}{repository}/tags/{tag}"

try:
response = requests.get(docker_api_url, timeout=5)
if response.status_code != 200:
self.add_error(
"image",
f"Docker image '{image}' is not publicly available on Docker Hub. "
"The URL you have entered may be incorrect, or the image might be private.",
)
return image
except requests.RequestException:
self.add_error("image", "Could not validate the Docker image. Please try again.")
return image

return image
10 changes: 7 additions & 3 deletions apps/forms/shiny.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@

from apps.forms.base import AppBaseForm
from apps.forms.field.common import SRVCommonDivField
from apps.forms.mixins import ContainerImageMixin
from apps.models import ShinyInstance
from projects.models import Flavor

__all__ = ["ShinyForm"]


class ShinyForm(AppBaseForm):
class ShinyForm(ContainerImageMixin, AppBaseForm):
flavor = forms.ModelChoiceField(queryset=Flavor.objects.none(), required=False, empty_label=None)
port = forms.IntegerField(min_value=3000, max_value=9999, required=True)
image = forms.CharField(max_length=255, required=True)
shiny_site_dir = forms.CharField(max_length=255, required=False, label="Path to site_dir")

def __init__(self, *args, **kwargs):
Expand All @@ -23,6 +23,9 @@ def __init__(self, *args, **kwargs):
if self.instance and self.instance.pk:
self.initial_subdomain = self.instance.subdomain.subdomain

# Setup container image field from mixin
self._setup_container_image_field()

def _setup_form_fields(self):
# Handle Volume field
super()._setup_form_fields()
Expand Down Expand Up @@ -53,7 +56,8 @@ def _setup_form_helper(self):
),
SRVCommonDivField("source_code_url", placeholder="Provide a link to the public source code"),
SRVCommonDivField("port", placeholder="3838"),
SRVCommonDivField("image", placeholder="e.g. docker.io/username/image-name:image-tag"),
# Container image field
self._setup_container_image_helper(),
Accordion(
AccordionGroup(
"Advanced settings",
Expand Down
Loading

0 comments on commit f7369ac

Please sign in to comment.