Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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})
58 changes: 57 additions & 1 deletion apps/forms/custom.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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
Expand Down Expand Up @@ -54,7 +56,17 @@ 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"),
# Docker image input with datalist
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",
),
Accordion(
AccordionGroup(
"Advanced settings",
Expand Down Expand Up @@ -93,6 +105,50 @@ def clean_path(self):

return path

def clean_image(self):
cleaned_data = super().clean()

image = cleaned_data.get("image", "").strip()

if not image:
self.add_error("image", "Docker 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

class Meta:
model = CustomAppInstance
fields = [
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
52 changes: 52 additions & 0 deletions templates/apps/create_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,58 @@ <h1 class="h3 mb-3 card-title">Create {{ form.model_name }}</h1>

<script>

// Fetch docker image suggestions and populate the datalist
document.addEventListener("DOMContentLoaded", function() {
const imageInput = $("#id_image");
const datalist = $("#docker-image-list");

function fetchDockerImageSuggestions() {
let query = imageInput.val().trim();
if (query.length < 3) {
datalist.empty();
return;
}

let api_url = window.location.origin + "/api/docker_image_search/";

let request = $.ajax({
type: "GET",
url: api_url,
data: { "query": query },
beforeSend: function () {
console.log("Fetching Docker image suggestions for:", query);
}
});

request.done(function (data) {
// Clear existing suggestions
datalist.empty();

if (data && data.images) {
// Create possible image options
let options = data.images.map(function (image) {
return $("<option>").val(image)[0];
});

// Append available options
datalist.append(options);
}
});

request.fail(function (jqXHR, textStatus, errorThrown) {
console.error(`Error fetching Docker images: ${textStatus}, ${errorThrown}`);
});
}

// Run function on input
imageInput.on("input", fetchDockerImageSuggestions);

// Just for demo purposes, when the user hits Enter
imageInput.on("change", function () {
console.log("Final selection:", imageInput.val());
});
});

function clearSubdomainValidation() {
// Must clear django form validation message
$("#id_subdomain").each(function() {
Expand Down
Loading