diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 8364b4842..605353071 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_visibility_confirm_title": "Change project visibility",
+ "update_templates_repo_success": "Templates updated successfully",
+ "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": "Visibility",
+ "templates_repo": "Templates",
+ "override_project_templates": "Templates",
+ "transfer_ownership": "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 426dcb44d..195c68488 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 45e5bb3a9..32e5cbb17 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,
+ reset_templates_repo: true,
+ })
+ .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 && (
+
+ )}
+
+
+ {data.templates_repo
+ ? t('projects.edit.change_visibility')
+ : t('projects.edit.configure_templates_repo')}
+
+
+ {t('projects.edit.reset_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 ee218dcbe..e4c4e127c 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 217511cf7..48705a3ef 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.no_templates_alert.action')}
+
+
+
+ ) : (
+ 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 ad4c63ef9..631eb07c8 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;
+ reset_templates_repo?: boolean;
+ }
+ >({
+ 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 8853a1a83..915bc4d90 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 63adf9196..deb9ce379 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 000000000..f5271ddc1
--- /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 da733054f..2f82b5e59 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 aaf08809b..32c14c571 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,8 @@ async def update_project(
user=user,
project=project,
is_public=body.is_public,
+ templates_repo=body.templates_repo,
+ 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/routers/templates.py b/src/dstack/_internal/server/routers/templates.py
index 92f5d0501..6df2659c1 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 5f0133ab7..c45624f66 100644
--- a/src/dstack/_internal/server/schemas/projects.py
+++ b/src/dstack/_internal/server/schemas/projects.py
@@ -51,10 +51,13 @@ 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
+ 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 238359469..d5aa6fb14 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,26 @@ async def update_project(
session: AsyncSession,
user: UserModel,
project: ProjectModel,
- is_public: bool,
+ is_public: Optional[bool] = None,
+ templates_repo: Optional[str] = None,
+ reset_templates_repo: bool = False,
):
updated_fields = []
- if 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}")
+
+ update_templates_repo, new_templates_repo = await _resolve_new_templates_repo(
+ project=project,
+ templates_repo=templates_repo,
+ reset_templates_repo=reset_templates_repo,
+ )
+ if update_templates_repo:
+ templates_service.invalidate_templates_cache(
+ project.id, project.templates_repo, new_templates_repo
+ )
+ project.templates_repo = new_templates_repo
+ updated_fields.append(f"templates_repo={new_templates_repo}")
events.emit(
session,
f"Project updated. Updated fields: {', '.join(updated_fields) or ''}",
@@ -575,9 +592,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 = await _normalize_templates_repo_url(templates_repo)
private_bytes, public_bytes = await run_async(
generate_rsa_key_pair_bytes, f"{project_name}@dstack"
)
@@ -588,6 +610,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 +689,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 +721,36 @@ def is_valid_project_name(project_name: str) -> bool:
return re.match("^[a-zA-Z0-9-_]{1,50}$", project_name) is not None
+async 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
+ try:
+ await run_async(templates_service.validate_templates_repo_access, templates_repo)
+ except ValueError as e:
+ raise ServerClientError(str(e))
+ return templates_repo
+
+
+async def _resolve_new_templates_repo(
+ project: ProjectModel,
+ templates_repo: Optional[str],
+ reset_templates_repo: bool,
+) -> Tuple[bool, Optional[str]]:
+ if reset_templates_repo:
+ return project.templates_repo is not None, None
+ if templates_repo is None:
+ return False, None
+ normalized_templates_repo = await _normalize_templates_repo_url(templates_repo)
+ if normalized_templates_repo is None:
+ return False, None
+ if normalized_templates_repo == project.templates_repo:
+ return False, None
+ return True, normalized_templates_repo
+
+
_CREATE_PROJECT_HOOKS = []
diff --git a/src/dstack/_internal/server/services/templates.py b/src/dstack/_internal/server/services/templates.py
index 0f7564851..1ac1f357c 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,61 @@
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]:
- """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:
+async def list_templates(project: ProjectModel) -> List[UITemplate]:
+ """Return templates available for the UI."""
+ 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 +98,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.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 2893418a0..c15d09859 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 2da65c7d5..6903e7d3d 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,171 @@ 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_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)
+ 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={"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(
+ 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 a3abb1b74..7418c7224 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 e90badd1d..45fcaf562 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