Skip to content

Commit feb82fc

Browse files
Add server API for UI templates
Add POST /api/project/{project_name}/templates/list endpoint that serves UI templates from an external git repo configured via DSTACK_SERVER_TEMPLATES_REPO. Templates are YAML files under .dstack/templates/ in the repo, parsed into typed pydantic models with a discriminated union for parameter types. Results are cached with a 3-minute TTL using cachetools.TTLCache. Currently returns only server-wide templates; project-specific templates will be added in a future iteration. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7729127 commit feb82fc

File tree

8 files changed

+801
-0
lines changed

8 files changed

+801
-0
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
2+
3+
from pydantic import Field
4+
5+
from dstack._internal.core.models.common import CoreModel
6+
7+
8+
class BaseUITemplateParameter(CoreModel):
9+
"""Base for all UI template parameters."""
10+
11+
pass
12+
13+
14+
class NameUITemplateParameter(BaseUITemplateParameter):
15+
type: Annotated[Literal["name"], Field(description="The parameter type")]
16+
17+
18+
class IDEUITemplateParameter(BaseUITemplateParameter):
19+
type: Annotated[Literal["ide"], Field(description="The parameter type")]
20+
21+
22+
class ResourcesUITemplateParameter(BaseUITemplateParameter):
23+
type: Annotated[Literal["resources"], Field(description="The parameter type")]
24+
25+
26+
class PythonOrDockerUITemplateParameter(BaseUITemplateParameter):
27+
type: Annotated[Literal["python_or_docker"], Field(description="The parameter type")]
28+
29+
30+
class RepoUITemplateParameter(BaseUITemplateParameter):
31+
type: Annotated[Literal["repo"], Field(description="The parameter type")]
32+
33+
34+
class WorkingDirUITemplateParameter(BaseUITemplateParameter):
35+
type: Annotated[Literal["working_dir"], Field(description="The parameter type")]
36+
37+
38+
class EnvUITemplateParameter(BaseUITemplateParameter):
39+
type: Annotated[Literal["env"], Field(description="The parameter type")]
40+
title: Annotated[Optional[str], Field(description="The display title")] = None
41+
name: Annotated[Optional[str], Field(description="The environment variable name")] = None
42+
value: Annotated[Optional[str], Field(description="The default value")] = None
43+
44+
45+
AnyUITemplateParameter = Annotated[
46+
Union[
47+
NameUITemplateParameter,
48+
IDEUITemplateParameter,
49+
ResourcesUITemplateParameter,
50+
PythonOrDockerUITemplateParameter,
51+
RepoUITemplateParameter,
52+
WorkingDirUITemplateParameter,
53+
EnvUITemplateParameter,
54+
],
55+
Field(discriminator="type"),
56+
]
57+
58+
59+
class UITemplate(CoreModel):
60+
type: Annotated[Literal["ui-template"], Field(description="The template type")]
61+
id: Annotated[str, Field(description="The unique template identifier")]
62+
title: Annotated[str, Field(description="The human-readable template name")]
63+
parameters: Annotated[
64+
List[AnyUITemplateParameter],
65+
Field(description="The template parameters"),
66+
] = []
67+
template: Annotated[
68+
Dict[str, Any],
69+
Field(description="The dstack run configuration"),
70+
]

src/dstack/_internal/server/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
runs,
4444
secrets,
4545
server,
46+
templates,
4647
users,
4748
volumes,
4849
)
@@ -240,6 +241,7 @@ def register_routes(app: FastAPI, ui: bool = True):
240241
app.include_router(prometheus.router)
241242
app.include_router(files.router)
242243
app.include_router(events.root_router)
244+
app.include_router(templates.router)
243245

244246
@app.exception_handler(ForbiddenError)
245247
async def forbidden_error_handler(request: Request, exc: ForbiddenError):
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from typing import List, Tuple
2+
3+
from fastapi import APIRouter, Depends
4+
5+
from dstack._internal.core.models.templates import UITemplate
6+
from dstack._internal.server.models import ProjectModel, UserModel
7+
from dstack._internal.server.security.permissions import ProjectMember
8+
from dstack._internal.server.services import templates as templates_service
9+
from dstack._internal.server.utils.routers import CustomORJSONResponse
10+
11+
router = APIRouter(
12+
prefix="/api/project/{project_name}/templates",
13+
tags=["templates"],
14+
)
15+
16+
17+
@router.post("/list", response_model=List[UITemplate])
18+
async def list_templates(
19+
user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()),
20+
):
21+
return CustomORJSONResponse(await templates_service.list_templates())
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import shutil
2+
import threading
3+
from pathlib import Path
4+
from typing import List, Optional
5+
6+
import git
7+
import yaml
8+
from cachetools import TTLCache, cached
9+
10+
from dstack._internal.core.models.templates import UITemplate
11+
from dstack._internal.server import settings
12+
from dstack._internal.utils.common import run_async
13+
from dstack._internal.utils.logging import get_logger
14+
15+
logger = get_logger(__name__)
16+
17+
TEMPLATES_DIR_NAME = ".dstack/templates"
18+
CACHE_TTL_SECONDS = 180
19+
20+
_repo_path: Optional[Path] = None
21+
_templates_cache: TTLCache = TTLCache(maxsize=1, ttl=CACHE_TTL_SECONDS)
22+
_templates_lock = threading.Lock()
23+
24+
25+
async def list_templates() -> List[UITemplate]:
26+
"""Return templates available for the UI.
27+
28+
Currently returns only server-wide templates configured via DSTACK_SERVER_TEMPLATES_REPO.
29+
Project-specific templates will be included once implemented.
30+
"""
31+
if not settings.SERVER_TEMPLATES_REPO:
32+
return []
33+
return await run_async(_list_templates_sync)
34+
35+
36+
@cached(cache=_templates_cache, lock=_templates_lock)
37+
def _list_templates_sync() -> List[UITemplate]:
38+
_fetch_templates_repo()
39+
return _parse_templates()
40+
41+
42+
def _fetch_templates_repo() -> None:
43+
global _repo_path
44+
45+
repo_dir = settings.SERVER_DATA_DIR_PATH / "templates-repo"
46+
47+
if _repo_path is not None and _repo_path.exists():
48+
repo = git.Repo(str(_repo_path))
49+
repo.remotes.origin.pull()
50+
return
51+
52+
if repo_dir.exists():
53+
try:
54+
repo = git.Repo(str(repo_dir))
55+
repo.remotes.origin.pull()
56+
_repo_path = repo_dir
57+
return
58+
except (git.InvalidGitRepositoryError, git.GitCommandError):
59+
logger.warning("Invalid templates repo at %s, re-cloning", repo_dir)
60+
shutil.rmtree(repo_dir)
61+
62+
assert settings.SERVER_TEMPLATES_REPO is not None
63+
git.Repo.clone_from(
64+
settings.SERVER_TEMPLATES_REPO,
65+
str(repo_dir),
66+
depth=1,
67+
)
68+
_repo_path = repo_dir
69+
70+
71+
def _parse_templates() -> List[UITemplate]:
72+
if _repo_path is None:
73+
return []
74+
75+
templates_dir = _repo_path / TEMPLATES_DIR_NAME
76+
if not templates_dir.is_dir():
77+
logger.warning("Templates directory %s not found in repo", TEMPLATES_DIR_NAME)
78+
return []
79+
80+
templates: List[UITemplate] = []
81+
for entry in sorted(templates_dir.iterdir()):
82+
if entry.suffix not in (".yml", ".yaml"):
83+
continue
84+
try:
85+
with open(entry) as f:
86+
data = yaml.safe_load(f)
87+
if not isinstance(data, dict):
88+
logger.warning("Skipping %s: not a valid YAML mapping", entry.name)
89+
continue
90+
if data.get("type") != "ui-template":
91+
logger.debug("Skipping %s: type is not 'ui-template'", entry.name)
92+
continue
93+
template = UITemplate.parse_obj(data)
94+
templates.append(template)
95+
except Exception:
96+
logger.warning("Skipping invalid template %s", entry.name, exc_info=True)
97+
continue
98+
99+
return templates

src/dstack/_internal/server/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@
131131

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

134+
SERVER_TEMPLATES_REPO = os.getenv("DSTACK_SERVER_TEMPLATES_REPO")
135+
134136
# Development settings
135137

136138
SQL_ECHO_ENABLED = os.getenv("DSTACK_SQL_ECHO_ENABLED") is not None

0 commit comments

Comments
 (0)