Skip to content
Merged
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
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})
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
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
10 changes: 7 additions & 3 deletions apps/forms/streamlit.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, StreamlitInstance
from projects.models import Flavor

__all__ = ["StreamlitForm"]


class StreamlitForm(AppBaseForm):
class StreamlitForm(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="8501"),
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
5 changes: 5 additions & 0 deletions studio/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@
KUBE_API_REQUEST_TIMEOUT = 1
STORAGECLASS = "local-path"

# Docker hub API
DOCKER_HUB_IMAGE_SEARCH = "https://hub.docker.com/v2/search/repositories/"
DOCKER_HUB_TAG_SEARCH = "https://hub.docker.com/v2/repositories/"


# This can be simply "localhost", but it's better to test with a
# wildcard dns such as nip.io
IP = os.environ.get("IP", "127.0.0.1")
Expand Down
Loading
Loading