From 5d4b862512ff7b389e7e9dd16bac257934ea37f5 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 6 Mar 2026 13:40:07 +0100 Subject: [PATCH 01/10] Add per-project templates repo support in server and UI. Allow project settings to store and validate a templates Git repository, use it as template source with cache invalidation/fallback behavior, and surface clear launch/settings UX when templates are unavailable. Made-with: Cursor --- frontend/src/locale/en.json | 23 +- .../Project/Details/Settings/constants.tsx | 21 + .../pages/Project/Details/Settings/index.tsx | 391 ++++++++++++++---- .../Details/Settings/styles.module.scss | 21 + frontend/src/pages/Runs/Launch/index.tsx | 29 +- frontend/src/pages/Runs/List/index.tsx | 3 - frontend/src/services/project.ts | 9 +- frontend/src/types/project.d.ts | 2 + src/dstack/_internal/core/models/projects.py | 1 + ...b55af01_add_projectmodel_templates_repo.py | 26 ++ src/dstack/_internal/server/models.py | 1 + .../_internal/server/routers/projects.py | 4 + .../_internal/server/routers/templates.py | 3 +- .../_internal/server/schemas/projects.py | 4 +- .../_internal/server/services/projects.py | 50 ++- .../_internal/server/services/templates.py | 85 ++-- src/dstack/_internal/server/testing/common.py | 2 + .../_internal/server/routers/test_projects.py | 151 +++++++ .../server/routers/test_templates.py | 67 ++- .../server/services/test_templates.py | 123 ++++-- 20 files changed, 836 insertions(+), 180 deletions(-) create mode 100644 src/dstack/_internal/server/migrations/versions/2026/03_06_1200_a13f5b55af01_add_projectmodel_templates_repo.py diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 8364b48422..f59f6cf289 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -49,7 +49,7 @@ "tutorial_other": "Take a tour", "docs": "Docs", "discord": "Discord", - "danger_zone": "Danger Zone", + "danger_zone": "Danger zone", "control_plane": "Control plane", "refresh": "Refresh", "quickstart": "Quickstart", @@ -223,10 +223,26 @@ "members_empty_message_text": "Select project's members", "update_members_success": "Members are updated", "update_visibility_success": "Project visibility updated successfully", + "update_templates_repo_success": "Templates updated successfully", "update_visibility_confirm_title": "Change project visibility", "update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.", "change_visibility": "Change", "project_visibility": "Visibility", + "project_visibility_settings": "Change project visibility", + "templates_repo": "Templates", + "override_project_templates": "Configure project templates", + "transfer_ownership": "Transfer ownership", + "templates_repo_description": "Set a project-level templates repository URL", + "templates_repo_placeholder": "https://github.com/org/templates.git", + "templates_repo_not_set": "not set", + "templates_repo_required": "Templates repo URL cannot be empty", + "save_templates_repo": "Save", + "configure_templates_repo": "Configure", + "change_templates_repo_title": "Override project templates", + "change_templates_repo_message": "Specify a new templates Git repo URL:", + "reset_templates_repo": "Reset", + "reset_templates_repo_title": "Reset templates", + "reset_templates_repo_message": "Are you sure you want to reset templates for this project?", "project_visibility_description": "Control who can access this project", "make_project_public": "Make project public", "delete_project_confirm_title": "Delete project", @@ -472,6 +488,11 @@ }, "runs": { "launch_button": "Launch", + "no_templates_alert": { + "title": "No templates configured", + "description": "The selected project has no templates available for Launch.", + "action": "Settings" + }, "launch": { "wizard": { "title": "Launch", diff --git a/frontend/src/pages/Project/Details/Settings/constants.tsx b/frontend/src/pages/Project/Details/Settings/constants.tsx index 426dcb44da..195c684889 100644 --- a/frontend/src/pages/Project/Details/Settings/constants.tsx +++ b/frontend/src/pages/Project/Details/Settings/constants.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import Link from '@cloudscape-design/components/link'; export const CLI_INFO = { header:

CLI

, @@ -21,3 +22,23 @@ export const CLI_INFO = { ), }; + +export const TEMPLATES_REPO_INFO = { + header:

Templates

, + body: ( + <> +

+ Specify a project-level templates Git repository URL. Templates from this repo are shown on the Launch page in + Runs, and setting it enables the Launch button when templates are available. +

+

If set, project templates override global templates configured on the server.

+

+ See official examples in{' '} + + dstackai/dstack-templates + + . +

+ + ), +}; diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 45e5bb3a90..59c8e21e77 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -1,18 +1,24 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { debounce } from 'lodash'; import { ExpandableSection, Tabs } from '@cloudscape-design/components'; import Wizard from '@cloudscape-design/components/wizard'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { Box, Button, ButtonWithConfirmation, Code, + ConfirmationDialog, Container, + FormField, Header, Hotspot, + InfoLink, + InputCSD, Loader, Popover, SelectCSD, @@ -21,13 +27,14 @@ import { } from 'components'; import { HotspotIds } from 'layouts/AppLayout/TutorialPanel/constants'; -import { useBreadcrumbs, useNotifications } from 'hooks'; +import { useBreadcrumbs, useHelpPanel, useNotifications } from 'hooks'; import { useCheckingForFleetsInProjects } from 'hooks/useCheckingForFleetsInProjectsOfMember'; import { riseRouterException } from 'libs'; import { copyToClipboard } from 'libs'; import { ROUTES } from 'routes'; import { useGetProjectQuery, useUpdateProjectMembersMutation, useUpdateProjectMutation } from 'services/project'; import { useGetRunsQuery } from 'services/run'; +import { templateApi } from 'services/templates'; import { useGetUserDataQuery } from 'services/user'; import { useCheckAvailableProjectPermission } from 'pages/Project/hooks/useCheckAvailableProjectPermission'; @@ -42,13 +49,20 @@ import { NoFleetProjectAlert } from '../../components/NoFleetProjectAlert'; import { GatewaysTable } from '../../Gateways'; import { useGatewaysTable } from '../../Gateways/hooks'; import { ProjectSecrets } from '../../Secrets'; +import { TEMPLATES_REPO_INFO } from './constants'; import styles from './styles.module.scss'; +type ApiErrorResponse = { detail?: string | { msg?: string } | Array<{ msg?: string }> }; + +const isFetchBaseQueryError = (error: unknown): error is FetchBaseQueryError => + typeof error === 'object' && error !== null && 'status' in error; + export const ProjectSettings: React.FC = () => { const { t } = useTranslation(); const params = useParams(); const navigate = useNavigate(); + const location = useLocation(); const paramProjectName = params.projectName ?? ''; const [isExpandedCliSection, setIsExpandedCliSection] = React.useState(false); const [configCliCommand, copyCliCommand] = useConfigProjectCliCommand({ projectName: paramProjectName }); @@ -57,6 +71,8 @@ export const ProjectSettings: React.FC = () => { useCheckAvailableProjectPermission(); const [pushNotification] = useNotifications(); + const [openHelpPanel] = useHelpPanel(); + const dispatch = useDispatch(); const [updateProjectMembers] = useUpdateProjectMembersMutation(); const [updateProject] = useUpdateProjectMutation(); const { deleteProject, isDeleting } = useDeleteProject(); @@ -97,11 +113,31 @@ export const ProjectSettings: React.FC = () => { ]; const [selectedVisibility, setSelectedVisibility] = useState(data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]); + const [templatesRepoValue, setTemplatesRepoValue] = useState(''); + const [templatesRepoError, setTemplatesRepoError] = useState(null); + const [isChangeTemplatesRepoVisible, setIsChangeTemplatesRepoVisible] = useState(false); + const [isResetTemplatesRepoVisible, setIsResetTemplatesRepoVisible] = useState(false); + const changeTemplatesRepoInputWrapperRef = React.useRef(null); + const dangerZoneRef = React.useRef(null); useEffect(() => { setSelectedVisibility(data?.isPublic ? visibilityOptions[1] : visibilityOptions[0]); }, [data]); + useEffect(() => { + setTemplatesRepoValue(data?.templates_repo ?? ''); + }, [data?.templates_repo]); + + useEffect(() => { + if (!isChangeTemplatesRepoVisible) { + return; + } + const timer = setTimeout(() => { + changeTemplatesRepoInputWrapperRef.current?.querySelector('input')?.focus(); + }, 10); + return () => clearTimeout(timer); + }, [isChangeTemplatesRepoVisible]); + const { data: backendsData, isDeleting: isDeletingBackend, @@ -114,6 +150,14 @@ export const ProjectSettings: React.FC = () => { const isLoadingPage = isLoading || !data || isLoadingGateways; + useEffect(() => { + if (location.hash === '#danger-zone') { + setTimeout(() => { + dangerZoneRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + } + }, [location.hash, isLoadingPage]); + useBreadcrumbs([ { text: t('navigation.project_other'), @@ -169,6 +213,94 @@ export const ProjectSettings: React.FC = () => { }); }; + const getApiErrorMessage = (error: unknown): string => { + const detail = isFetchBaseQueryError(error) ? (error.data as ApiErrorResponse | undefined)?.detail : undefined; + if (Array.isArray(detail)) { + return detail[0]?.msg ?? t('common.server_error', { error: 'Unknown error' }); + } + if (typeof detail === 'string') { + return detail; + } + if (detail?.msg) { + return detail.msg; + } + return t('common.server_error', { error: 'Unknown error' }); + }; + + const updateTemplatesRepoHandler = async (): Promise => { + const templates_repo = templatesRepoValue.trim() === '' ? null : templatesRepoValue.trim(); + try { + await updateProject({ + project_name: paramProjectName, + templates_repo, + }).unwrap(); + dispatch(templateApi.util.invalidateTags(['Templates'])); + pushNotification({ + type: 'success', + content: t('projects.edit.update_templates_repo_success'), + }); + return true; + } catch (error: unknown) { + const errorMessage = getApiErrorMessage(error); + setTemplatesRepoError(errorMessage); + return false; + } + }; + + const openChangeTemplatesRepoDialog = () => { + setTemplatesRepoValue(data?.templates_repo ?? ''); + setTemplatesRepoError(null); + setIsChangeTemplatesRepoVisible(true); + }; + + const closeChangeTemplatesRepoDialog = () => { + setTemplatesRepoError(null); + setIsChangeTemplatesRepoVisible(false); + }; + + const openResetTemplatesRepoDialog = () => { + setIsResetTemplatesRepoVisible(true); + }; + + const closeResetTemplatesRepoDialog = () => { + setIsResetTemplatesRepoVisible(false); + }; + + const confirmChangeTemplatesRepo = async () => { + if (templatesRepoValue.trim() === '') { + setTemplatesRepoError(t('projects.edit.templates_repo_required')); + return; + } + const isUpdated = await updateTemplatesRepoHandler(); + if (isUpdated) { + closeChangeTemplatesRepoDialog(); + } + }; + + const confirmResetTemplatesRepo = () => { + setTemplatesRepoValue(''); + updateProject({ + project_name: paramProjectName, + templates_repo: null, + }) + .unwrap() + .then(() => { + dispatch(templateApi.util.invalidateTags(['Templates'])); + pushNotification({ + type: 'success', + content: t('projects.edit.update_templates_repo_success'), + }); + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .catch((error: any) => { + pushNotification({ + type: 'error', + content: t('common.server_error', { error: error?.data?.detail?.msg }), + }); + }); + closeResetTemplatesRepoDialog(); + }; + const isDisabledButtons = useMemo(() => { return isDeleting || !data || !isAvailableDeletingPermission(data); }, [data, isDeleting, isAvailableDeletingPermission]); @@ -385,94 +517,183 @@ export const ProjectSettings: React.FC = () => { - {t('common.danger_zone')}}> - -
- {isAvailableProjectManaging && ( - <> - - {t('projects.edit.delete_this_project')} - - -
- - {t('common.delete')} - -
- - )} - - {isAvailableProjectManaging && ( - <> - - {t('projects.edit.project_visibility')} - - -
- changeVisibilityHandler(selectedVisibility.value === 'public')} - confirmTitle={t('projects.edit.update_visibility_confirm_title')} - confirmButtonLabel={t('projects.edit.change_visibility')} - confirmContent={ - - - {t('projects.edit.update_visibility_confirm_message')} - -
- - setSelectedVisibility( - event.detail.selectedOption as { - label: string; - value: string; - }, - ) - } - expandToViewport={true} - filteringType="auto" - /> -
-
- } - > - {t('projects.edit.change_visibility')} -
+
+ {t('common.danger_zone')}}> + +
+ {isAvailableProjectManaging && ( + <> + + {t('projects.edit.delete_this_project')} + + +
+ + {t('common.delete')} + +
+ + )} + + {isAvailableProjectManaging && ( + <> + + {t('projects.edit.project_visibility_settings')} + + +
+ + changeVisibilityHandler(selectedVisibility.value === 'public') + } + confirmTitle={t('projects.edit.update_visibility_confirm_title')} + confirmButtonLabel={t('projects.edit.change_visibility')} + confirmContent={ + + + {t('projects.edit.update_visibility_confirm_message')} + +
+ + setSelectedVisibility( + event.detail.selectedOption as { + label: string; + value: string; + }, + ) + } + expandToViewport={true} + filteringType="auto" + /> +
+
+ } + > + {t('projects.edit.change_visibility')} +
+
+ + )} + + + {t('projects.edit.transfer_ownership')} + + +
+
+
- - )} - - - {t('projects.edit.owner')} - - -
-
-
+ + {isAvailableProjectManaging && ( + <> +
+ + {t('projects.edit.override_project_templates')} + + openHelpPanel(TEMPLATES_REPO_INFO)} /> +
+ +
+ {data.templates_repo && ( + + )} + + + + +
+ + )}
-
- - + + +
)} + + + + {t('projects.edit.change_templates_repo_message')} + +
+ + { + setTemplatesRepoValue(detail.value); + if (templatesRepoError) { + setTemplatesRepoError(null); + } + }} + onKeyDown={({ detail }) => { + if (detail.key === 'Enter') { + void confirmChangeTemplatesRepo(); + } + }} + placeholder={t('projects.edit.templates_repo_placeholder')} + /> + +
+ + } + /> + + {t('projects.edit.reset_templates_repo_message')}} + /> ); }; diff --git a/frontend/src/pages/Project/Details/Settings/styles.module.scss b/frontend/src/pages/Project/Details/Settings/styles.module.scss index ee218dcbe0..e4c4e127c8 100644 --- a/frontend/src/pages/Project/Details/Settings/styles.module.scss +++ b/frontend/src/pages/Project/Details/Settings/styles.module.scss @@ -8,6 +8,27 @@ width: 300px; } +.templatesRepoRow { + display: flex; + align-items: center; + gap: 12px; +} + +.templatesRepoTitle { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.templatesRepoInput { + width: 300px; + max-width: 100%; +} + +.templatesRepoActions { + flex-shrink: 0; +} + .codeWrapper { position: relative; diff --git a/frontend/src/pages/Runs/Launch/index.tsx b/frontend/src/pages/Runs/Launch/index.tsx index 217511cf7e..48705a3ef7 100644 --- a/frontend/src/pages/Runs/Launch/index.tsx +++ b/frontend/src/pages/Runs/Launch/index.tsx @@ -7,6 +7,7 @@ import { WizardProps } from '@cloudscape-design/components'; import { CardsProps } from '@cloudscape-design/components/cards'; import { + Button, Container, FormCards, FormCodeEditor, @@ -234,6 +235,10 @@ export const Launch: React.FC = () => { onSubmitWizard().catch(console.log); } }; + const openProjectSettings = () => { + if (!formValues.project) return; + navigate(`${ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(formValues.project)}#danger-zone`); + }; const envParam = selectedTemplate?.parameters?.find((p) => p.type === 'env'); const yaml = useGenerateYaml({ @@ -320,6 +325,20 @@ export const Launch: React.FC = () => { }, ], }} + empty={ + formValues.project ? ( + +
{t('runs.no_templates_alert.description')}
+
+ +
+
+ ) : ( + t('runs.launch.wizard.template_placeholder') + ) + } cardsPerRow={[{ cards: 1 }, { minWidth: 400, cards: 2 }, { minWidth: 800, cards: 3 }]} onSelectionChange={onChangeTemplate} /> @@ -346,11 +365,7 @@ export const Launch: React.FC = () => { defaultValue={false} toggleLabel={t('runs.launch.wizard.gpu')} toggleDescription={t('runs.launch.wizard.gpu_description')} - errorText={ - formValues.gpu_enabled - ? formState.errors.offer?.message - : undefined - } + errorText={formValues.gpu_enabled ? formState.errors.offer?.message : undefined} name={FORM_FIELD_NAMES.gpu_enabled} /> } @@ -371,9 +386,7 @@ export const Launch: React.FC = () => { control={control} label={t('runs.launch.wizard.configuration_label')} description={t('runs.launch.wizard.configuration_description')} - info={ - openHelpPanel(CONFIGURATION_INFO)} /> - } + info={ openHelpPanel(CONFIGURATION_INFO)} />} name={FORM_FIELD_NAMES.config_yaml} language="yaml" loading={loading} diff --git a/frontend/src/pages/Runs/List/index.tsx b/frontend/src/pages/Runs/List/index.tsx index ad4c63ef94..631eb07c82 100644 --- a/frontend/src/pages/Runs/List/index.tsx +++ b/frontend/src/pages/Runs/List/index.tsx @@ -50,7 +50,6 @@ export const RunList: React.FC = () => { filteringStatusType, handleLoadItems, } = useFilters(); - const projectHavingFleetMap = useCheckingForFleetsInProjects({}); const { data, isLoading, refreshList, isLoadingMore } = useInfiniteScroll({ @@ -120,7 +119,6 @@ export const RunList: React.FC = () => { }`, ); }; - const projectDontHasFleet = Object.keys(projectHavingFleetMap).find((project) => !projectHavingFleetMap[project]); return ( @@ -143,7 +141,6 @@ export const RunList: React.FC = () => { show={!!projectDontHasFleet} dismissible={true} /> -
['ProjectRepos'], }), - updateProject: builder.mutation({ - query: ({ project_name, is_public }) => ({ + updateProject: builder.mutation< + IProject, + { project_name: string; is_public?: boolean; templates_repo?: string | null } + >({ + query: ({ project_name, ...body }) => ({ url: API.PROJECTS.UPDATE(project_name), method: 'POST', - body: { is_public }, + body, }), transformResponse: transformProjectResponse, invalidatesTags: (result, error, params) => [{ type: 'Projects' as const, id: params?.project_name }], diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index 8853a1a839..915bc4d902 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -30,6 +30,7 @@ declare interface IProject { owner: IUser | { username: string }; created_at: string; isPublic: boolean; + templates_repo?: string | null; } declare interface IProjectMember { @@ -55,4 +56,5 @@ declare interface IProjectSecret { declare type IProjectCreateRequestParams = Pick & { is_public: boolean; + templates_repo?: string | null; }; diff --git a/src/dstack/_internal/core/models/projects.py b/src/dstack/_internal/core/models/projects.py index 63adf91962..deb9ce3790 100644 --- a/src/dstack/_internal/core/models/projects.py +++ b/src/dstack/_internal/core/models/projects.py @@ -26,6 +26,7 @@ class Project(CoreModel): backends: List[BackendInfo] members: List[Member] is_public: bool = False + templates_repo: Optional[str] = None class ProjectsInfoList(CoreModel): diff --git a/src/dstack/_internal/server/migrations/versions/2026/03_06_1200_a13f5b55af01_add_projectmodel_templates_repo.py b/src/dstack/_internal/server/migrations/versions/2026/03_06_1200_a13f5b55af01_add_projectmodel_templates_repo.py new file mode 100644 index 0000000000..f5271ddc11 --- /dev/null +++ b/src/dstack/_internal/server/migrations/versions/2026/03_06_1200_a13f5b55af01_add_projectmodel_templates_repo.py @@ -0,0 +1,26 @@ +"""Add ProjectModel.templates_repo + +Revision ID: a13f5b55af01 +Revises: 5e8c7a9202bc +Create Date: 2026-03-06 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a13f5b55af01" +down_revision = "c7b0a8e57294" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("projects", schema=None) as batch_op: + batch_op.add_column(sa.Column("templates_repo", sa.Text(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("projects", schema=None) as batch_op: + batch_op.drop_column("templates_repo") diff --git a/src/dstack/_internal/server/models.py b/src/dstack/_internal/server/models.py index da733054fa..2f82b5e593 100644 --- a/src/dstack/_internal/server/models.py +++ b/src/dstack/_internal/server/models.py @@ -250,6 +250,7 @@ class ProjectModel(BaseModel): name: Mapped[str] = mapped_column(String(50), unique=True) created_at: Mapped[datetime] = mapped_column(NaiveDateTime, default=get_current_datetime) is_public: Mapped[bool] = mapped_column(Boolean, default=False) + templates_repo: Mapped[Optional[str]] = mapped_column(Text, nullable=True) deleted: Mapped[bool] = mapped_column(Boolean, default=False) original_name: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) """`original_name` stores the deleted project's original name while `name` is changed to a unique diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index aaf08809b7..92744eaca3 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -99,6 +99,7 @@ async def create_project( user=user, project_name=body.project_name, is_public=body.is_public, + templates_repo=body.templates_repo, ) ) @@ -200,6 +201,9 @@ async def update_project( user=user, project=project, is_public=body.is_public, + templates_repo=body.templates_repo, + update_is_public="is_public" in body.__fields_set__, + update_templates_repo="templates_repo" in body.__fields_set__, ) await session.refresh(project) return CustomORJSONResponse(projects.project_model_to_project(project)) diff --git a/src/dstack/_internal/server/routers/templates.py b/src/dstack/_internal/server/routers/templates.py index 92f5d05018..6df2659c16 100644 --- a/src/dstack/_internal/server/routers/templates.py +++ b/src/dstack/_internal/server/routers/templates.py @@ -18,4 +18,5 @@ async def list_templates( user_project: Tuple[UserModel, ProjectModel] = Depends(ProjectMember()), ): - return CustomORJSONResponse(await templates_service.list_templates()) + _, project = user_project + return CustomORJSONResponse(await templates_service.list_templates(project)) diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index 5f0133ab72..79ad58ecc3 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -51,10 +51,12 @@ class ListProjectsRequest(CoreModel): class CreateProjectRequest(CoreModel): project_name: str is_public: bool = False + templates_repo: Optional[str] = None class UpdateProjectRequest(CoreModel): - is_public: bool + is_public: Optional[bool] = None + templates_repo: Optional[str] = None class DeleteProjectsRequest(CoreModel): diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 2383594690..0962543376 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -36,6 +36,7 @@ ) from dstack._internal.server.schemas.projects import MemberSetting from dstack._internal.server.services import events, users +from dstack._internal.server.services import templates as templates_service from dstack._internal.server.services.backends import ( get_backend_config_without_creds_from_backend_model, ) @@ -164,6 +165,7 @@ async def create_project( user: UserModel, project_name: str, is_public: bool = False, + templates_repo: Optional[str] = None, config: Optional[ProjectHookConfig] = None, ) -> Project: user_permissions = users.get_user_permissions(user) @@ -180,6 +182,7 @@ async def create_project( owner=user, project_name=project_name, is_public=is_public, + templates_repo=templates_repo, ) await add_project_member( session=session, @@ -205,12 +208,28 @@ async def update_project( session: AsyncSession, user: UserModel, project: ProjectModel, - is_public: bool, + is_public: Optional[bool] = None, + templates_repo: Optional[str] = None, + update_is_public: bool = True, + update_templates_repo: bool = False, ): updated_fields = [] - if is_public != project.is_public: + if update_is_public and is_public is not None and is_public != project.is_public: project.is_public = is_public updated_fields.append(f"is_public={is_public}") + if update_templates_repo: + normalized_templates_repo = _normalize_templates_repo_url(templates_repo) + should_update_templates_repo = normalized_templates_repo != project.templates_repo + else: + normalized_templates_repo = project.templates_repo + should_update_templates_repo = False + if should_update_templates_repo: + previous_templates_repo = project.templates_repo + project.templates_repo = normalized_templates_repo + templates_service.invalidate_templates_cache( + project.id, previous_templates_repo, project.templates_repo + ) + updated_fields.append(f"templates_repo={normalized_templates_repo}") events.emit( session, f"Project updated. Updated fields: {', '.join(updated_fields) or ''}", @@ -575,9 +594,14 @@ async def get_project_model_by_id_or_error( async def create_project_model( - session: AsyncSession, owner: UserModel, project_name: str, is_public: bool = False + session: AsyncSession, + owner: UserModel, + project_name: str, + is_public: bool = False, + templates_repo: Optional[str] = None, ) -> ProjectModel: validate_project_name(project_name) + templates_repo = _normalize_templates_repo_url(templates_repo) private_bytes, public_bytes = await run_async( generate_rsa_key_pair_bytes, f"{project_name}@dstack" ) @@ -588,6 +612,7 @@ async def create_project_model( ssh_private_key=private_bytes.decode(), ssh_public_key=public_bytes.decode(), is_public=is_public, + templates_repo=templates_repo, ) session.add(project) events.emit( @@ -666,6 +691,11 @@ def project_model_to_project( backends=backends, members=members, is_public=project_model.is_public, + **( + {"templates_repo": project_model.templates_repo} + if project_model.templates_repo is not None + else {} + ), ) @@ -693,6 +723,20 @@ def is_valid_project_name(project_name: str) -> bool: return re.match("^[a-zA-Z0-9-_]{1,50}$", project_name) is not None +def _normalize_templates_repo_url(templates_repo: Optional[str]) -> Optional[str]: + if templates_repo is None: + return None + templates_repo = templates_repo.strip() + if templates_repo == "": + return None + if templates_repo is not None: + try: + templates_service.validate_templates_repo_access(templates_repo) + except ValueError as e: + raise ServerClientError(str(e)) + return templates_repo + + _CREATE_PROJECT_HOOKS = [] diff --git a/src/dstack/_internal/server/services/templates.py b/src/dstack/_internal/server/services/templates.py index 0f75648517..c78061f957 100644 --- a/src/dstack/_internal/server/services/templates.py +++ b/src/dstack/_internal/server/services/templates.py @@ -1,5 +1,6 @@ import shutil import threading +import uuid from pathlib import Path from typing import List, Optional @@ -9,6 +10,7 @@ from dstack._internal.core.models.templates import UITemplate from dstack._internal.server import settings +from dstack._internal.server.models import ProjectModel from dstack._internal.utils.common import run_async from dstack._internal.utils.logging import get_logger @@ -17,62 +19,62 @@ 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_cache: TTLCache = TTLCache(maxsize=1024, ttl=CACHE_TTL_SECONDS) _templates_lock = threading.Lock() -async def list_templates() -> List[UITemplate]: +async def list_templates(project: ProjectModel) -> 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: + repo_url = project.templates_repo or settings.SERVER_TEMPLATES_REPO + if not repo_url: return [] - return await run_async(_list_templates_sync) + repo_key = _repo_key(project.id, repo_url) + return await run_async(_list_templates_sync, repo_key, repo_url) @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" +def _list_templates_sync(repo_key: str, repo_url: str) -> List[UITemplate]: + try: + repo_path = _fetch_templates_repo(repo_key, repo_url) + except git.GitCommandError as e: + status = getattr(e, "status", "unknown") + stderr = (getattr(e, "stderr", "") or "").strip().splitlines() + reason = stderr[-1] if stderr else "git command failed" + logger.warning( + "Failed to fetch templates repo %s (exit_code=%s): %s", repo_url, status, reason + ) + return [] + return _parse_templates(repo_path) - if _repo_path is not None and _repo_path.exists(): - repo = git.Repo(str(_repo_path)) - repo.remotes.origin.pull() - return +def _fetch_templates_repo(repo_key: str, repo_url: str) -> Path: + repo_dir = settings.SERVER_DATA_DIR_PATH / "templates-repos" / repo_key if repo_dir.exists(): try: repo = git.Repo(str(repo_dir)) - repo.remotes.origin.pull() - _repo_path = repo_dir - return + remote_url = next(repo.remote().urls, None) + if remote_url != repo_url: + logger.info("Templates repo URL changed for key %s, re-cloning", repo_key) + shutil.rmtree(repo_dir) + else: + repo.remotes.origin.pull() + return repo_dir 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 + repo_dir.parent.mkdir(parents=True, exist_ok=True) git.Repo.clone_from( - settings.SERVER_TEMPLATES_REPO, + repo_url, str(repo_dir), depth=1, ) - _repo_path = repo_dir - + return repo_dir -def _parse_templates() -> List[UITemplate]: - if _repo_path is None: - return [] - templates_dir = _repo_path / TEMPLATES_DIR_NAME +def _parse_templates(repo_path: Path) -> List[UITemplate]: + 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 [] @@ -97,3 +99,22 @@ def _parse_templates() -> List[UITemplate]: continue return templates + + +def _repo_key(project_id: uuid.UUID, repo_url: str) -> str: + key_source = f"{project_id}:{repo_url}" + return uuid.uuid5(uuid.NAMESPACE_URL, key_source).hex + + +def validate_templates_repo_access(repo_url: str) -> None: + try: + git.cmd.Git().ls_remote("--exit-code", repo_url, "HEAD") + except git.GitCommandError: + raise ValueError(f"Cannot access templates repo: {repo_url}") + + +def invalidate_templates_cache(project_id: uuid.UUID, *repo_urls: Optional[str]) -> None: + unique_repo_urls = {repo_url for repo_url in repo_urls if repo_url} + with _templates_lock: + for repo_url in unique_repo_urls: + _templates_cache.pop((_repo_key(project_id, repo_url), repo_url), None) diff --git a/src/dstack/_internal/server/testing/common.py b/src/dstack/_internal/server/testing/common.py index 2893418a0d..c15d098597 100644 --- a/src/dstack/_internal/server/testing/common.py +++ b/src/dstack/_internal/server/testing/common.py @@ -170,6 +170,7 @@ async def create_project( ssh_private_key: str = "", ssh_public_key: str = "", is_public: bool = False, + templates_repo: Optional[str] = None, ) -> ProjectModel: if owner is None: owner = await create_user(session=session, name="test_owner") @@ -180,6 +181,7 @@ async def create_project( ssh_private_key=ssh_private_key, ssh_public_key=ssh_public_key, is_public=is_public, + templates_repo=templates_repo, ) session.add(project) await session.commit() diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index 2da65c7d5c..a72ce93fd5 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -86,6 +86,7 @@ async def test_returns_projects(self, test_db, session: AsyncSession, client: As "backends": [], "members": [], "is_public": False, + "templates_repo": None, } ] @@ -265,6 +266,7 @@ async def test_returns_paginated_projects( "backends": [], "members": [], "is_public": False, + "templates_repo": None, } ] response = await client.post( @@ -297,6 +299,7 @@ async def test_returns_paginated_projects( "backends": [], "members": [], "is_public": False, + "templates_repo": None, } ] response = await client.post( @@ -329,6 +332,7 @@ async def test_returns_paginated_projects( "backends": [], "members": [], "is_public": False, + "templates_repo": None, } ] @@ -380,6 +384,7 @@ async def test_returns_total_count(self, test_db, session: AsyncSession, client: "backends": [], "members": [], "is_public": False, + "templates_repo": None, } ], } @@ -937,6 +942,7 @@ async def test_creates_project(self, test_db, session: AsyncSession, client: Asy } ], "is_public": False, + "templates_repo": None, } @pytest.mark.asyncio @@ -1401,6 +1407,7 @@ async def test_returns_project(self, test_db, session: AsyncSession, client: Asy } ], "is_public": False, + "templates_repo": None, } @pytest.mark.asyncio @@ -2041,3 +2048,147 @@ async def test_global_admin_can_update_any_project_visibility( ) assert response.status_code == 200 assert response.json()["is_public"] == True + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_can_update_templates_repo( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + with patch( + "dstack._internal.server.services.projects.templates_service.validate_templates_repo_access" + ): + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"templates_repo": "https://github.com/org/templates.git"}, + ) + assert response.status_code == 200 + assert response.json()["templates_repo"] == "https://github.com/org/templates.git" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_omitted_templates_repo_does_not_clear_existing_value( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project( + session=session, + owner=admin_user, + is_public=False, + templates_repo="https://github.com/org/templates.git", + ) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"is_public": True}, + ) + assert response.status_code == 200 + assert response.json()["templates_repo"] == "https://github.com/org/templates.git" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_can_clear_templates_repo_with_null( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project( + session=session, + owner=admin_user, + is_public=False, + templates_repo="https://github.com/org/templates.git", + ) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"templates_repo": None}, + ) + assert response.status_code == 200 + assert response.json().get("templates_repo") is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_normalizes_empty_templates_repo_to_null( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"templates_repo": " "}, + ) + assert response.status_code == 200 + assert response.json().get("templates_repo") is None + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_trims_templates_repo_url( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + with patch( + "dstack._internal.server.services.projects.templates_service.validate_templates_repo_access" + ): + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"templates_repo": " https://github.com/org/templates.git "}, + ) + assert response.status_code == 200 + assert response.json()["templates_repo"] == "https://github.com/org/templates.git" + + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_rejects_unreachable_templates_repo( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project(session=session, owner=admin_user, is_public=False) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + with patch( + "dstack._internal.server.services.projects.templates_service.validate_templates_repo_access", + side_effect=ValueError( + "Cannot access templates repo: https://github.com/dstackai/dstack-sky-templates11" + ), + ): + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"templates_repo": "https://github.com/dstackai/dstack-sky-templates11"}, + ) + + assert response.status_code == 400 + assert response.json() == { + "detail": [ + { + "code": "error", + "msg": "Cannot access templates repo: https://github.com/dstackai/dstack-sky-templates11", + } + ] + } diff --git a/src/tests/_internal/server/routers/test_templates.py b/src/tests/_internal/server/routers/test_templates.py index a3abb1b74d..7418c7224a 100644 --- a/src/tests/_internal/server/routers/test_templates.py +++ b/src/tests/_internal/server/routers/test_templates.py @@ -3,6 +3,7 @@ import pytest import yaml +from git import GitCommandError from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession @@ -20,10 +21,8 @@ def _reset_cache(): """Reset the templates cache before each test.""" templates_service._templates_cache.clear() - templates_service._repo_path = None yield templates_service._templates_cache.clear() - templates_service._repo_path = None class TestListTemplates: @@ -49,6 +48,42 @@ async def test_returns_empty_list_when_no_repo( assert response.status_code == 200 assert response.json() == [] + @pytest.mark.asyncio + async def test_uses_project_templates_repo_when_set( + self, test_db, session: AsyncSession, client: AsyncClient, tmp_path: Path + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project( + session=session, owner=user, name="project-with-templates", is_public=False + ) + project.templates_repo = "https://project.example/repo.git" + await session.commit() + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + templates_dir = tmp_path / ".dstack" / "templates" + templates_dir.mkdir(parents=True) + with open(templates_dir / "desktop-ide.yml", "w") as f: + yaml.dump( + { + "type": "template", + "name": "desktop-ide", + "title": "Desktop IDE", + "parameters": [{"type": "name"}], + "configuration": {"type": "dev-environment"}, + }, + f, + ) + + with patch.object(templates_service, "_fetch_templates_repo", return_value=tmp_path): + response = await client.post( + f"/api/project/{project.name}/templates/list", + headers=get_auth_headers(user.token), + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["name"] == "desktop-ide" + @pytest.mark.asyncio async def test_returns_templates( self, test_db, session: AsyncSession, client: AsyncClient, tmp_path: Path @@ -98,9 +133,8 @@ async def test_returns_templates( patch.object( templates_service.settings, "SERVER_TEMPLATES_REPO", "https://example.com" ), - patch.object(templates_service, "_fetch_templates_repo"), + patch.object(templates_service, "_fetch_templates_repo", return_value=tmp_path), ): - templates_service._repo_path = tmp_path response = await client.post( f"/api/project/{project.name}/templates/list", headers=get_auth_headers(user.token), @@ -116,3 +150,28 @@ async def test_returns_templates( assert data[1]["parameters"][1]["type"] == "env" assert data[1]["parameters"][1]["name"] == "PASSWORD" assert data[1]["configuration"]["port"] == 8080 + + @pytest.mark.asyncio + async def test_returns_empty_when_repo_fetch_fails( + self, test_db, session: AsyncSession, client: AsyncClient + ): + user = await create_user(session=session, global_role=GlobalRole.USER) + project = await create_project(session=session, owner=user) + project.templates_repo = "https://github.com/dstackai/dstack-sky" + await session.commit() + await add_project_member( + session=session, project=project, user=user, project_role=ProjectRole.USER + ) + + with patch.object( + templates_service, + "_fetch_templates_repo", + side_effect=GitCommandError(["git", "clone"], 128, stderr="not found"), + ): + response = await client.post( + f"/api/project/{project.name}/templates/list", + headers=get_auth_headers(user.token), + ) + + assert response.status_code == 200 + assert response.json() == [] diff --git a/src/tests/_internal/server/services/test_templates.py b/src/tests/_internal/server/services/test_templates.py index e90badd1d4..45fcaf5628 100644 --- a/src/tests/_internal/server/services/test_templates.py +++ b/src/tests/_internal/server/services/test_templates.py @@ -1,8 +1,10 @@ +import uuid from pathlib import Path from unittest.mock import patch import pytest import yaml +from git import GitCommandError from dstack._internal.core.models.templates import ( EnvUITemplateParameter, @@ -15,10 +17,8 @@ def _reset_cache(): """Reset the templates cache before each test.""" templates_service._templates_cache.clear() - templates_service._repo_path = None yield templates_service._templates_cache.clear() - templates_service._repo_path = None def _create_template_file(templates_dir: Path, filename: str, data: dict) -> Path: @@ -39,18 +39,14 @@ class TestListTemplates: @pytest.mark.asyncio async def test_returns_empty_when_no_repo_configured(self): with patch.object(templates_service.settings, "SERVER_TEMPLATES_REPO", None): - result = await templates_service.list_templates() + project = type("Project", (), {"templates_repo": None, "id": "project-id"})() + result = await templates_service.list_templates(project) assert result == [] class TestParseTemplates: - def test_returns_empty_when_repo_path_is_none(self): - result = templates_service._parse_templates() - assert result == [] - def test_returns_empty_when_templates_dir_missing(self, tmp_path: Path): - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert result == [] def test_parses_valid_template(self, tmp_path: Path): @@ -66,8 +62,7 @@ def test_parses_valid_template(self, tmp_path: Path): "configuration": {"type": "dev-environment"}, }, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 1 assert result[0].name == "test-template" assert isinstance(result[0].parameters[0], NameUITemplateParameter) @@ -87,8 +82,7 @@ def test_parses_template_with_env_parameter(self, tmp_path: Path): "configuration": {"type": "service"}, }, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 1 param = result[0].parameters[0] assert isinstance(param, EnvUITemplateParameter) @@ -109,8 +103,7 @@ def test_skips_non_yaml_files(self, tmp_path: Path): }, ) (templates_dir / "readme.txt").write_text("not a template") - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 1 assert result[0].name == "valid" @@ -121,8 +114,7 @@ def test_skips_non_template_type(self, tmp_path: Path): "other.yml", {"type": "something-else", "name": "other", "title": "Other"}, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert result == [] def test_skips_invalid_yaml(self, tmp_path: Path): @@ -138,8 +130,7 @@ def test_skips_invalid_yaml(self, tmp_path: Path): "configuration": {"type": "task"}, }, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 1 assert result[0].name == "good" @@ -166,8 +157,7 @@ def test_skips_template_with_unknown_parameter_type(self, tmp_path: Path): "configuration": {"type": "task"}, }, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 1 assert result[0].name == "good" @@ -183,8 +173,7 @@ def test_parses_yaml_extension(self, tmp_path: Path): "configuration": {"type": "task"}, }, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 1 assert result[0].name == "yaml-ext" @@ -210,14 +199,24 @@ def test_returns_templates_sorted_by_filename(self, tmp_path: Path): "configuration": {"type": "task"}, }, ) - templates_service._repo_path = tmp_path - result = templates_service._parse_templates() + result = templates_service._parse_templates(tmp_path) assert len(result) == 2 assert result[0].name == "a" assert result[1].name == "b" class TestListTemplatesSync: + def test_returns_empty_if_repo_fetch_fails(self): + with patch.object( + templates_service, + "_fetch_templates_repo", + side_effect=GitCommandError(["git", "clone"], 128, stderr="not found"), + ): + result = templates_service._list_templates_sync( + "project-key", "https://github.com/dstackai/dstack-sky" + ) + assert result == [] + def test_caches_result(self, tmp_path: Path): templates_dir = _create_templates_repo(tmp_path) _create_template_file( @@ -232,18 +231,14 @@ def test_caches_result(self, tmp_path: Path): ) with ( - patch.object( - templates_service.settings, "SERVER_TEMPLATES_REPO", "https://example.com" - ), - patch.object(templates_service, "_fetch_templates_repo"), + patch.object(templates_service, "_fetch_templates_repo", return_value=tmp_path), ): - templates_service._repo_path = tmp_path - result1 = templates_service._list_templates_sync() + result1 = templates_service._list_templates_sync("project-key", "https://example.com") assert len(result1) == 1 (templates_dir / "test.yml").unlink() - result2 = templates_service._list_templates_sync() + result2 = templates_service._list_templates_sync("project-key", "https://example.com") assert len(result2) == 1 assert result2[0].name == "cached" @@ -261,13 +256,9 @@ def test_refreshes_after_cache_clear(self, tmp_path: Path): ) with ( - patch.object( - templates_service.settings, "SERVER_TEMPLATES_REPO", "https://example.com" - ), - patch.object(templates_service, "_fetch_templates_repo"), + patch.object(templates_service, "_fetch_templates_repo", return_value=tmp_path), ): - templates_service._repo_path = tmp_path - result1 = templates_service._list_templates_sync() + result1 = templates_service._list_templates_sync("project-key", "https://example.com") assert result1[0].name == "original" _create_template_file( @@ -282,5 +273,59 @@ def test_refreshes_after_cache_clear(self, tmp_path: Path): ) templates_service._templates_cache.clear() - result2 = templates_service._list_templates_sync() + result2 = templates_service._list_templates_sync("project-key", "https://example.com") assert result2[0].name == "updated" + + def test_refreshes_after_cache_ttl_expiration(self, tmp_path: Path): + templates_dir = _create_templates_repo(tmp_path) + _create_template_file( + templates_dir, + "test.yml", + { + "type": "template", + "name": "original", + "title": "Original", + "configuration": {"type": "task"}, + }, + ) + + with patch.object(templates_service, "_fetch_templates_repo", return_value=tmp_path): + result1 = templates_service._list_templates_sync("project-key", "https://example.com") + assert result1[0].name == "original" + + _create_template_file( + templates_dir, + "test.yml", + { + "type": "template", + "name": "updated-after-expire", + "title": "Updated", + "configuration": {"type": "task"}, + }, + ) + + templates_service._templates_cache.expire( + time=templates_service._templates_cache.timer() + + templates_service.CACHE_TTL_SECONDS + + 1 + ) + + result2 = templates_service._list_templates_sync("project-key", "https://example.com") + assert result2[0].name == "updated-after-expire" + + +class TestInvalidateTemplatesCache: + def test_removes_cache_entries_for_project_repo_keys(self): + templates_service._templates_cache.clear() + project_id = uuid.UUID("00000000-0000-0000-0000-000000000001") + repo1 = "https://example.com/templates-1.git" + repo2 = "https://example.com/templates-2.git" + key1 = templates_service._repo_key(project_id=project_id, repo_url=repo1) + key2 = templates_service._repo_key(project_id=project_id, repo_url=repo2) + templates_service._templates_cache[(key1, repo1)] = ["a"] + templates_service._templates_cache[(key2, repo2)] = ["b"] + + templates_service.invalidate_templates_cache(project_id, repo1, repo2) + + assert (key1, repo1) not in templates_service._templates_cache + assert (key2, repo2) not in templates_service._templates_cache From 0fa7feea6ac0e4d66250523f380f34cd0d540bcc Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 6 Mar 2026 13:51:32 +0100 Subject: [PATCH 02/10] Format templates service docstring for CI pre-commit. Apply ruff-format normalization required by GitHub Actions so lint hooks pass without modifying tracked files in CI. Made-with: Cursor --- src/dstack/_internal/server/services/templates.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/dstack/_internal/server/services/templates.py b/src/dstack/_internal/server/services/templates.py index c78061f957..3eeb930b56 100644 --- a/src/dstack/_internal/server/services/templates.py +++ b/src/dstack/_internal/server/services/templates.py @@ -24,8 +24,7 @@ async def list_templates(project: ProjectModel) -> List[UITemplate]: - """Return templates available for the UI. - """ + """Return templates available for the UI.""" repo_url = project.templates_repo or settings.SERVER_TEMPLATES_REPO if not repo_url: return [] From c9937c6ca8156eb02edfcbb9538af83c23bffafc Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 6 Mar 2026 14:26:34 +0100 Subject: [PATCH 03/10] Use explicit reset flag for project templates repo updates. Keep update semantics straightforward: omitted or null values do not mutate `is_public`/`templates_repo`, and only `reset_templates_repo=true` clears templates for a project. Made-with: Cursor --- .../pages/Project/Details/Settings/index.tsx | 2 +- frontend/src/services/project.ts | 7 ++++- .../_internal/server/routers/projects.py | 3 +- .../_internal/server/schemas/projects.py | 1 + .../_internal/server/services/projects.py | 18 ++++++------ .../_internal/server/routers/test_projects.py | 28 +++++++++++++++++-- 6 files changed, 45 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 59c8e21e77..a477527e2e 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -281,7 +281,7 @@ export const ProjectSettings: React.FC = () => { setTemplatesRepoValue(''); updateProject({ project_name: paramProjectName, - templates_repo: null, + reset_templates_repo: true, }) .unwrap() .then(() => { diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index ad42ee8220..f8f8563c13 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -196,7 +196,12 @@ export const projectApi = createApi({ updateProject: builder.mutation< IProject, - { project_name: string; is_public?: boolean; templates_repo?: string | null } + { + project_name: string; + is_public?: boolean; + templates_repo?: string | null; + reset_templates_repo?: boolean; + } >({ query: ({ project_name, ...body }) => ({ url: API.PROJECTS.UPDATE(project_name), diff --git a/src/dstack/_internal/server/routers/projects.py b/src/dstack/_internal/server/routers/projects.py index 92744eaca3..32c14c5713 100644 --- a/src/dstack/_internal/server/routers/projects.py +++ b/src/dstack/_internal/server/routers/projects.py @@ -202,8 +202,7 @@ async def update_project( project=project, is_public=body.is_public, templates_repo=body.templates_repo, - update_is_public="is_public" in body.__fields_set__, - update_templates_repo="templates_repo" in body.__fields_set__, + reset_templates_repo=body.reset_templates_repo, ) await session.refresh(project) return CustomORJSONResponse(projects.project_model_to_project(project)) diff --git a/src/dstack/_internal/server/schemas/projects.py b/src/dstack/_internal/server/schemas/projects.py index 79ad58ecc3..c45624f668 100644 --- a/src/dstack/_internal/server/schemas/projects.py +++ b/src/dstack/_internal/server/schemas/projects.py @@ -57,6 +57,7 @@ class CreateProjectRequest(CoreModel): class UpdateProjectRequest(CoreModel): is_public: Optional[bool] = None templates_repo: Optional[str] = None + reset_templates_repo: bool = False class DeleteProjectsRequest(CoreModel): diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index 0962543376..dd8506c6d3 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -210,19 +210,21 @@ async def update_project( project: ProjectModel, is_public: Optional[bool] = None, templates_repo: Optional[str] = None, - update_is_public: bool = True, - update_templates_repo: bool = False, + reset_templates_repo: bool = False, ): updated_fields = [] - if update_is_public and is_public is not None and is_public != project.is_public: + if is_public is not None and is_public != project.is_public: project.is_public = is_public updated_fields.append(f"is_public={is_public}") - if update_templates_repo: + normalized_templates_repo = project.templates_repo + should_update_templates_repo = False + if reset_templates_repo: + normalized_templates_repo = None + should_update_templates_repo = project.templates_repo is not None + elif templates_repo is not None: normalized_templates_repo = _normalize_templates_repo_url(templates_repo) - should_update_templates_repo = normalized_templates_repo != project.templates_repo - else: - normalized_templates_repo = project.templates_repo - should_update_templates_repo = False + if normalized_templates_repo is not None: + should_update_templates_repo = normalized_templates_repo != project.templates_repo if should_update_templates_repo: previous_templates_repo = project.templates_repo project.templates_repo = normalized_templates_repo diff --git a/src/tests/_internal/server/routers/test_projects.py b/src/tests/_internal/server/routers/test_projects.py index a72ce93fd5..6903e7d3df 100644 --- a/src/tests/_internal/server/routers/test_projects.py +++ b/src/tests/_internal/server/routers/test_projects.py @@ -2097,7 +2097,7 @@ async def test_omitted_templates_repo_does_not_clear_existing_value( @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) - async def test_can_clear_templates_repo_with_null( + async def test_can_reset_templates_repo_with_explicit_flag( self, test_db, session: AsyncSession, client: AsyncClient ): admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) @@ -2114,11 +2114,35 @@ async def test_can_clear_templates_repo_with_null( response = await client.post( f"/api/projects/{project.name}/update", headers=get_auth_headers(admin_user.token), - json={"templates_repo": None}, + json={"reset_templates_repo": True}, ) assert response.status_code == 200 assert response.json().get("templates_repo") is None + @pytest.mark.asyncio + @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) + async def test_null_templates_repo_without_reset_does_not_clear_existing_value( + self, test_db, session: AsyncSession, client: AsyncClient + ): + admin_user = await create_user(session=session, name="admin", global_role=GlobalRole.USER) + project = await create_project( + session=session, + owner=admin_user, + is_public=False, + templates_repo="https://github.com/org/templates.git", + ) + await add_project_member( + session=session, project=project, user=admin_user, project_role=ProjectRole.ADMIN + ) + + response = await client.post( + f"/api/projects/{project.name}/update", + headers=get_auth_headers(admin_user.token), + json={"templates_repo": None}, + ) + assert response.status_code == 200 + assert response.json()["templates_repo"] == "https://github.com/org/templates.git" + @pytest.mark.asyncio @pytest.mark.parametrize("test_db", ["sqlite", "postgres"], indirect=True) async def test_normalizes_empty_templates_repo_to_null( From 9c7fed2f6dfa3079e2b22c6fcf5ccd8bc8ef002c Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Fri, 6 Mar 2026 14:39:37 +0100 Subject: [PATCH 04/10] Use public GitPython API for templates repo access check. Switch `git.cmd.Git` to `git.Git` so pyright accepts the access while preserving the same runtime behavior for `ls_remote` validation. Made-with: Cursor --- src/dstack/_internal/server/services/templates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dstack/_internal/server/services/templates.py b/src/dstack/_internal/server/services/templates.py index 3eeb930b56..1ac1f357c4 100644 --- a/src/dstack/_internal/server/services/templates.py +++ b/src/dstack/_internal/server/services/templates.py @@ -107,7 +107,7 @@ def _repo_key(project_id: uuid.UUID, repo_url: str) -> str: def validate_templates_repo_access(repo_url: str) -> None: try: - git.cmd.Git().ls_remote("--exit-code", repo_url, "HEAD") + git.Git().ls_remote("--exit-code", repo_url, "HEAD") except git.GitCommandError: raise ValueError(f"Cannot access templates repo: {repo_url}") From c8760ea1ffb10b6d29321b532ddb5310b927fa38 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 9 Mar 2026 09:47:48 +0100 Subject: [PATCH 05/10] Minor edit --- frontend/src/locale/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index f59f6cf289..96eb77f436 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -230,7 +230,7 @@ "project_visibility": "Visibility", "project_visibility_settings": "Change project visibility", "templates_repo": "Templates", - "override_project_templates": "Configure project templates", + "override_project_templates": "Configure templates", "transfer_ownership": "Transfer ownership", "templates_repo_description": "Set a project-level templates repository URL", "templates_repo_placeholder": "https://github.com/org/templates.git", From 58cd345ce34257279eaf298eecbed21dd2844031 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 9 Mar 2026 09:48:36 +0100 Subject: [PATCH 06/10] Align danger zone settings controls with admin-only update permissions. Restrict visibility and templates controls in project settings to project admins to match backend ProjectAdmin enforcement for /update, and use a templates-specific action label. Made-with: Cursor --- frontend/src/pages/Project/Details/Settings/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index a477527e2e..04205a30bc 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -551,7 +551,7 @@ export const ProjectSettings: React.FC = () => {
changeVisibilityHandler(selectedVisibility.value === 'public') @@ -628,16 +628,16 @@ export const ProjectSettings: React.FC = () => { > From fad5d793371daaef07bc0462d4b05360d3860e3b Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 9 Mar 2026 10:03:51 +0100 Subject: [PATCH 07/10] Run templates repo access validation off the event loop. Make templates repo URL normalization async and call git reachability validation via run_async so project create/update paths do not block request handling. Made-with: Cursor --- src/dstack/_internal/server/services/projects.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/dstack/_internal/server/services/projects.py b/src/dstack/_internal/server/services/projects.py index dd8506c6d3..bb77146783 100644 --- a/src/dstack/_internal/server/services/projects.py +++ b/src/dstack/_internal/server/services/projects.py @@ -222,7 +222,7 @@ async def update_project( normalized_templates_repo = None should_update_templates_repo = project.templates_repo is not None elif templates_repo is not None: - normalized_templates_repo = _normalize_templates_repo_url(templates_repo) + normalized_templates_repo = await _normalize_templates_repo_url(templates_repo) if normalized_templates_repo is not None: should_update_templates_repo = normalized_templates_repo != project.templates_repo if should_update_templates_repo: @@ -603,7 +603,7 @@ async def create_project_model( templates_repo: Optional[str] = None, ) -> ProjectModel: validate_project_name(project_name) - templates_repo = _normalize_templates_repo_url(templates_repo) + templates_repo = await _normalize_templates_repo_url(templates_repo) private_bytes, public_bytes = await run_async( generate_rsa_key_pair_bytes, f"{project_name}@dstack" ) @@ -725,7 +725,7 @@ def is_valid_project_name(project_name: str) -> bool: return re.match("^[a-zA-Z0-9-_]{1,50}$", project_name) is not None -def _normalize_templates_repo_url(templates_repo: Optional[str]) -> Optional[str]: +async def _normalize_templates_repo_url(templates_repo: Optional[str]) -> Optional[str]: if templates_repo is None: return None templates_repo = templates_repo.strip() @@ -733,7 +733,7 @@ def _normalize_templates_repo_url(templates_repo: Optional[str]) -> Optional[str return None if templates_repo is not None: try: - templates_service.validate_templates_repo_access(templates_repo) + await run_async(templates_service.validate_templates_repo_access, templates_repo) except ValueError as e: raise ServerClientError(str(e)) return templates_repo From 18114f49a1eb7c9f4d9b01015b7dec1c53308f10 Mon Sep 17 00:00:00 2001 From: Andrey Cheptsov Date: Mon, 9 Mar 2026 10:06:05 +0100 Subject: [PATCH 08/10] Minor edits --- frontend/src/locale/en.json | 4 ++-- frontend/src/pages/Project/Details/Settings/index.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 96eb77f436..ac3ccbe7de 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -224,11 +224,11 @@ "update_members_success": "Members are updated", "update_visibility_success": "Project visibility updated successfully", "update_templates_repo_success": "Templates updated successfully", - "update_visibility_confirm_title": "Change project visibility", + "update_visibility_confirm_title": "Change visibility", "update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.", "change_visibility": "Change", "project_visibility": "Visibility", - "project_visibility_settings": "Change project visibility", + "project_visibility_settings": "Change visibility", "templates_repo": "Templates", "override_project_templates": "Configure templates", "transfer_ownership": "Transfer ownership", diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx index 04205a30bc..32e5cbb17d 100644 --- a/frontend/src/pages/Project/Details/Settings/index.tsx +++ b/frontend/src/pages/Project/Details/Settings/index.tsx @@ -631,7 +631,7 @@ export const ProjectSettings: React.FC = () => { disabled={!isProjectAdmin(data)} > {data.templates_repo - ? t('common.change') + ? t('projects.edit.change_visibility') : t('projects.edit.configure_templates_repo')}