Skip to content
Open
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
70 changes: 70 additions & 0 deletions src/dstack/_internal/core/models/templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from typing import Annotated, Any, Dict, List, Literal, Optional, Union

from pydantic import Field

from dstack._internal.core.models.common import CoreModel


class BaseUITemplateParameter(CoreModel):
"""Base for all UI template parameters."""

pass


class NameUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["name"], Field(description="The parameter type")]


class IDEUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["ide"], Field(description="The parameter type")]


class ResourcesUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["resources"], Field(description="The parameter type")]


class PythonOrDockerUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["python_or_docker"], Field(description="The parameter type")]


class RepoUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["repo"], Field(description="The parameter type")]


class WorkingDirUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["working_dir"], Field(description="The parameter type")]


class EnvUITemplateParameter(BaseUITemplateParameter):
type: Annotated[Literal["env"], Field(description="The parameter type")]
title: Annotated[Optional[str], Field(description="The display title")] = None
name: Annotated[Optional[str], Field(description="The environment variable name")] = None
value: Annotated[Optional[str], Field(description="The default value")] = None


AnyUITemplateParameter = Annotated[
Union[
NameUITemplateParameter,
IDEUITemplateParameter,
ResourcesUITemplateParameter,
PythonOrDockerUITemplateParameter,
RepoUITemplateParameter,
WorkingDirUITemplateParameter,
EnvUITemplateParameter,
],
Field(discriminator="type"),
]


class UITemplate(CoreModel):
type: Annotated[Literal["ui-template"], Field(description="The template type")]
id: Annotated[str, Field(description="The unique template identifier")]
title: Annotated[str, Field(description="The human-readable template name")]
parameters: Annotated[
List[AnyUITemplateParameter],
Field(description="The template parameters"),
] = []
template: Annotated[
Dict[str, Any],
Field(description="The dstack run configuration"),
]
2 changes: 2 additions & 0 deletions src/dstack/_internal/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
runs,
secrets,
server,
templates,
users,
volumes,
)
Expand Down Expand Up @@ -240,6 +241,7 @@ def register_routes(app: FastAPI, ui: bool = True):
app.include_router(prometheus.router)
app.include_router(files.router)
app.include_router(events.root_router)
app.include_router(templates.router)

@app.exception_handler(ForbiddenError)
async def forbidden_error_handler(request: Request, exc: ForbiddenError):
Expand Down
21 changes: 21 additions & 0 deletions src/dstack/_internal/server/routers/templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import List, Tuple

from fastapi import APIRouter, Depends

from dstack._internal.core.models.templates import UITemplate
from dstack._internal.server.models import ProjectModel, UserModel
from dstack._internal.server.security.permissions import ProjectMember
from dstack._internal.server.services import templates as templates_service
from dstack._internal.server.utils.routers import CustomORJSONResponse

router = APIRouter(
prefix="/api/project/{project_name}/templates",
tags=["templates"],
)


@router.post("/list", response_model=List[UITemplate])
async def list_templates(
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
):
return CustomORJSONResponse(await templates_service.list_templates())
99 changes: 99 additions & 0 deletions src/dstack/_internal/server/services/templates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import shutil
import threading
from pathlib import Path
from typing import List, Optional

import git
import yaml
from cachetools import TTLCache, cached

from dstack._internal.core.models.templates import UITemplate
from dstack._internal.server import settings
from dstack._internal.utils.common import run_async
from dstack._internal.utils.logging import get_logger

logger = get_logger(__name__)

TEMPLATES_DIR_NAME = ".dstack/templates"
CACHE_TTL_SECONDS = 180

_repo_path: Optional[Path] = None
_templates_cache: TTLCache = TTLCache(maxsize=1, ttl=CACHE_TTL_SECONDS)
_templates_lock = threading.Lock()


async def list_templates() -> List[UITemplate]:
"""Return templates available for the UI.

Currently returns only server-wide templates configured via DSTACK_SERVER_TEMPLATES_REPO.
Project-specific templates will be included once implemented.
"""
if not settings.SERVER_TEMPLATES_REPO:
return []
return await run_async(_list_templates_sync)


@cached(cache=_templates_cache, lock=_templates_lock)
def _list_templates_sync() -> List[UITemplate]:
_fetch_templates_repo()
return _parse_templates()


def _fetch_templates_repo() -> None:
global _repo_path

repo_dir = settings.SERVER_DATA_DIR_PATH / "templates-repo"

if _repo_path is not None and _repo_path.exists():
repo = git.Repo(str(_repo_path))
repo.remotes.origin.pull()
return

if repo_dir.exists():
try:
repo = git.Repo(str(repo_dir))
repo.remotes.origin.pull()
_repo_path = repo_dir
return
except (git.InvalidGitRepositoryError, git.GitCommandError):
logger.warning("Invalid templates repo at %s, re-cloning", repo_dir)
shutil.rmtree(repo_dir)

assert settings.SERVER_TEMPLATES_REPO is not None
git.Repo.clone_from(
settings.SERVER_TEMPLATES_REPO,
str(repo_dir),
depth=1,
)
_repo_path = repo_dir


def _parse_templates() -> List[UITemplate]:
if _repo_path is None:
return []

templates_dir = _repo_path / TEMPLATES_DIR_NAME
if not templates_dir.is_dir():
logger.warning("Templates directory %s not found in repo", TEMPLATES_DIR_NAME)
return []

templates: List[UITemplate] = []
for entry in sorted(templates_dir.iterdir()):
if entry.suffix not in (".yml", ".yaml"):
continue
try:
with open(entry) as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
logger.warning("Skipping %s: not a valid YAML mapping", entry.name)
continue
if data.get("type") != "ui-template":
logger.debug("Skipping %s: type is not 'ui-template'", entry.name)
continue
template = UITemplate.parse_obj(data)
templates.append(template)
except Exception:
logger.warning("Skipping invalid template %s", entry.name, exc_info=True)
continue

return templates
2 changes: 2 additions & 0 deletions src/dstack/_internal/server/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@

SERVER_CODE_UPLOAD_LIMIT = int(os.getenv("DSTACK_SERVER_CODE_UPLOAD_LIMIT", 2 * 2**20))

SERVER_TEMPLATES_REPO = os.getenv("DSTACK_SERVER_TEMPLATES_REPO")

# Development settings

SQL_ECHO_ENABLED = os.getenv("DSTACK_SQL_ECHO_ENABLED") is not None
Expand Down
Loading