diff --git a/contributing/FRONTEND.md b/contributing/FRONTEND.md index 19b623b1d4..afecdaa29d 100644 --- a/contributing/FRONTEND.md +++ b/contributing/FRONTEND.md @@ -54,3 +54,18 @@ The `webpack` dev server expects the API to be running on `http://127.0.0.1:8000 ```shell dstack server --port 8000 ``` + +## Product flavors + +The shared frontend supports OSS, Enterprise, Factory, and Sky. From `frontend/`, use +`npm run build`, `npm run build-enterprise`, `npm run build-factory`, or +`npm run build-sky`; the corresponding development commands are `start`, +`start-enterprise`, `start-factory`, and `start-sky`. + +`frontend/product.config.cjs` defines product names and inherited UI features for both +webpack and the application. Components use `product` from `src/product.ts` instead of +checking `UI_VERSION` directly. Factory and Sky share billing and presets; hosted-service +content such as Sky terms and onboarding uses `product.isSky`. + +All flavors use the same login component. It displays supported providers enabled by +`/api/auth/list_providers`, independently of the UI flavor, and always offers token login. diff --git a/frontend/jest.auth.config.cjs b/frontend/jest.auth.config.cjs index fc57723338..c8616f64a9 100644 --- a/frontend/jest.auth.config.cjs +++ b/frontend/jest.auth.config.cjs @@ -3,7 +3,11 @@ module.exports = { clearMocks: true, testEnvironment: 'node', moduleDirectories: ['node_modules', 'src'], - testMatch: ['/src/App/auth.test.tsx', '/src/services/preset.test.tsx'], + testMatch: [ + '/src/App/auth.test.tsx', + '/src/App/login.test.tsx', + '/src/services/preset.test.tsx', + ], transform: { '\\.[jt]sx?$': 'babel-jest', }, diff --git a/frontend/package.json b/frontend/package.json index de869ace8e..9b55459c1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,9 +10,11 @@ "start": "cross-env NODE_ENV=development webpack serve --config webpack.config.js", "start-sky": "cross-env NODE_ENV=development UI_VERSION=sky webpack serve --config webpack.config.js", "start-factory": "cross-env NODE_ENV=development UI_VERSION=factory webpack serve --config webpack.config.js", + "start-enterprise": "cross-env NODE_ENV=development UI_VERSION=enterprise webpack serve --config webpack.config.js", "build": "cross-env NODE_ENV=production webpack build --config webpack.config.js", "build-sky": "cross-env NODE_ENV=production UI_VERSION=sky webpack build --config webpack.config.js", "build-factory": "cross-env NODE_ENV=production UI_VERSION=factory webpack build --config webpack.config.js", + "build-enterprise": "cross-env NODE_ENV=production UI_VERSION=enterprise webpack build --config webpack.config.js", "eslint": "eslint ./src --ext .js,.jsx,.ts,.tsx", "eslint-fix": "eslint ./src --ext .js,.jsx,.ts,.tsx --fix", "test": "jest", diff --git a/frontend/product.config.cjs b/frontend/product.config.cjs new file mode 100644 index 0000000000..da846b9f15 --- /dev/null +++ b/frontend/product.config.cjs @@ -0,0 +1,27 @@ +/* global module */ +const oss = { + id: 'oss', + name: 'dstack', + hasEvents: false, + hasBilling: false, + hasPresets: false, + isSky: false, +}; +const enterprise = { ...oss, id: 'enterprise', name: 'dstack Enterprise', hasEvents: true }; +const factory = { ...enterprise, id: 'factory', name: 'dstack Factory', hasBilling: true, hasPresets: true }; +const sky = { ...factory, id: 'sky', name: 'dstack Sky', isSky: true }; + +function getProductConfig(version) { + switch (version) { + case 'enterprise': + return enterprise; + case 'factory': + return factory; + case 'sky': + return sky; + default: + return oss; + } +} + +module.exports = { getProductConfig }; diff --git a/frontend/src/App/Login/LoginByGithub/index.tsx b/frontend/src/App/Login/LoginByGithub/index.tsx index 6748af523b..84f23be85c 100644 --- a/frontend/src/App/Login/LoginByGithub/index.tsx +++ b/frontend/src/App/Login/LoginByGithub/index.tsx @@ -1,17 +1,9 @@ import React from 'react'; -import { Navigate } from 'react-router-dom'; -import { colorBackgroundHomeHeader } from '@cloudscape-design/design-tokens'; -import { Alert, Box, Button, Container, ContentLayout, Header, Link, NavigateLink, SpaceBetween } from 'components'; +import { Alert, Button } from 'components'; -import { useAppSelector } from 'hooks'; import { goToUrl } from 'libs'; -import { ROUTES } from 'routes'; import { useGithubAuthorizeMutation } from 'services/auth'; -import { useGetUserDataQuery } from 'services/user'; - -import { Loading } from 'App/Loading'; -import { selectAuthToken } from 'App/slice'; const GitHubIcon: React.FC = () => ( @@ -25,71 +17,26 @@ const GitHubIcon: React.FC = () => ( ); export const LoginByGithub: React.FC = () => { - const token = useAppSelector(selectAuthToken); - const localStorageIsAvailable = 'localStorage' in window; - const { - currentData: userData, - error, - isFetching, - } = useGetUserDataQuery({ token }, { skip: !token || !localStorageIsAvailable }); const [githubAuthorize, { isLoading, isError }] = useGithubAuthorizeMutation(); - const signInClick = () => { githubAuthorize() .unwrap() .then((data) => goToUrl(data.authorization_url)) .catch(() => undefined); }; - - if (token && localStorageIsAvailable && isFetching && !userData) return ; - if (token && localStorageIsAvailable && userData?.username && !error) { - return ; - } - return ( - - Welcome to dstack Sky - - } - > - -
Sign in
- - } + <> + {isError && Unable to start GitHub authentication} + - - By continuing with GitHub, you agree to the{' '} - - Terms - {' '} - and{' '} - - Privacy policy - - - Sign in with a token - -
-
+ Continue with GitHub + + ); }; diff --git a/frontend/src/App/Login/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx index cc97cb88fa..07eac4ee51 100644 --- a/frontend/src/App/Login/LoginByGithubCallback/index.tsx +++ b/frontend/src/App/Login/LoginByGithubCallback/index.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useSearchParams } from 'react-router-dom'; +import { product } from 'product'; import { NavigateLink } from 'components'; import { UnauthorizedLayout } from 'layouts/UnauthorizedLayout'; @@ -41,7 +42,7 @@ export const LoginByGithubCallback: React.FC = () => { .unwrap() .then(async ({ creds: { token } }) => { dispatch(setAuthData({ token })); - if (process.env.UI_VERSION === 'sky') { + if (product.hasPresets) { const result = await getProjects({}).unwrap(); if (result.data.length === 0) { navigate(ROUTES.PROJECT.ADD); diff --git a/frontend/src/App/Login/SelfHostedLogin/index.tsx b/frontend/src/App/Login/SelfHostedLogin/index.tsx deleted file mode 100644 index 406e591ec0..0000000000 --- a/frontend/src/App/Login/SelfHostedLogin/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { PublicApp } from 'PublicApp'; -import { colorBackgroundHomeHeader } from '@cloudscape-design/design-tokens'; - -import { Box, Container, ContentLayout, Header, NavigateLink, SpaceBetween, Spinner } from 'components'; - -import { ROUTES } from 'routes'; -import { useGetEntraInfoQuery, useGetGoogleInfoQuery, useGetOktaInfoQuery } from 'services/auth'; - -import { LoginByEntraID } from '../EntraID/LoginByEntraID'; -import { LoginByGoogle } from '../LoginByGoogle'; -import { LoginByOkta } from '../LoginByOkta'; -import { LoginByTokenForm } from '../LoginByTokenForm'; - -export const SelfHostedLogin: React.FC<{ tokenOnly?: boolean }> = ({ tokenOnly = false }) => { - const { t } = useTranslation(); - const { data: oktaData, isLoading: isLoadingOkta } = useGetOktaInfoQuery(); - const { data: entraData, isLoading: isLoadingEntra } = useGetEntraInfoQuery(); - const { data: googleData, isLoading: isLoadingGoogle } = useGetGoogleInfoQuery(); - - const oktaEnabled = oktaData?.enabled; - const entraEnabled = entraData?.enabled; - const googleEnabled = googleData?.enabled; - const isLoading = isLoadingOkta || isLoadingEntra || isLoadingGoogle; - const hasSSO = oktaEnabled || entraEnabled || googleEnabled; - const showTokenForm = tokenOnly || (!isLoading && !hasSSO); - - return ( - - - {t('auth.sign_in_to_dstack')} - - } - > - -
{showTokenForm ? 'Sign in with a token' : t('common.login')}
- - } - > - - {showTokenForm && } - {!tokenOnly && isLoading && } - {!tokenOnly && !isLoading && oktaEnabled && } - {!tokenOnly && !isLoading && entraEnabled && } - {!tokenOnly && !isLoading && googleEnabled && } - {!isLoading && hasSSO && ( - - {tokenOnly ? t('auth.another_login_methods') : 'Sign in with a token'} - - )} - -
-
-
- ); -}; diff --git a/frontend/src/App/Login/TokenLogin/index.tsx b/frontend/src/App/Login/TokenLogin/index.tsx index fbf45622af..8c033dd469 100644 --- a/frontend/src/App/Login/TokenLogin/index.tsx +++ b/frontend/src/App/Login/TokenLogin/index.tsx @@ -1,45 +1,5 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { colorBackgroundHomeHeader } from '@cloudscape-design/design-tokens'; -import { Box, Container, ContentLayout, Header, NavigateLink, SpaceBetween } from 'components'; +import { Login } from '../index'; -import { ROUTES } from 'routes'; - -import { LoginByTokenForm } from '../LoginByTokenForm'; -import { SelfHostedLogin } from '../SelfHostedLogin'; - -export const TokenLogin: React.FC = () => { - const { t } = useTranslation(); - - if (process.env.UI_VERSION === 'sky') { - return ( - - {t('auth.sign_in_to_dstack_sky')} - - } - > - -
Sign in with a token
- - } - > - - - {t('auth.another_login_methods')} - -
-
- ); - } - - return ; -}; +export const TokenLogin: React.FC = () => ; diff --git a/frontend/src/App/Login/index.tsx b/frontend/src/App/Login/index.tsx new file mode 100644 index 0000000000..98b242070d --- /dev/null +++ b/frontend/src/App/Login/index.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate } from 'react-router-dom'; +import { product } from 'product'; +import { PublicApp } from 'PublicApp'; +import { colorBackgroundHomeHeader } from '@cloudscape-design/design-tokens'; + +import { Box, Container, ContentLayout, Header, Link, NavigateLink, SpaceBetween, Spinner } from 'components'; + +import { useAppSelector } from 'hooks'; +import { ROUTES } from 'routes'; +import { useGetAuthProvidersQuery } from 'services/auth'; +import { useGetUserDataQuery } from 'services/user'; + +import { Loading } from 'App/Loading'; +import { selectAuthToken } from 'App/slice'; + +import { LoginByEntraID } from './EntraID/LoginByEntraID'; +import { LoginByGithub } from './LoginByGithub'; +import { LoginByGoogle } from './LoginByGoogle'; +import { LoginByOkta } from './LoginByOkta'; +import { LoginByTokenForm } from './LoginByTokenForm'; + +const providerButtons = { + github: LoginByGithub, + okta: LoginByOkta, + entra: LoginByEntraID, + google: LoginByGoogle, +}; + +export const Login: React.FC<{ tokenOnly?: boolean }> = ({ tokenOnly = false }) => { + const { t } = useTranslation(); + const token = useAppSelector(selectAuthToken); + const localStorageIsAvailable = 'localStorage' in window; + const { + currentData: userData, + error, + isFetching, + } = useGetUserDataQuery({ token }, { skip: tokenOnly || !token || !localStorageIsAvailable }); + const { data: providers, isLoading } = useGetAuthProvidersQuery(undefined, { skip: tokenOnly }); + const enabledProviders = Object.entries(providerButtons).filter(([name]) => + providers?.some((provider) => provider.name === name && provider.enabled), + ); + const showTokenForm = tokenOnly || (!isLoading && enabledProviders.length === 0); + + if (!tokenOnly && token && localStorageIsAvailable) { + if (isFetching && !userData) return ; + if (userData?.username && !error) return ; + } + + const content = ( + + {t('auth.welcome', { product: product.name })} + + } + > + +
{showTokenForm ? 'Sign in with a token' : t('common.login')}
+ + } + > + + {showTokenForm && } + {!tokenOnly && isLoading && } + {!tokenOnly && + !isLoading && + enabledProviders.map(([name, ProviderButton]) => )} + {!tokenOnly && !isLoading && enabledProviders.length > 0 && product.isSky && ( + + By continuing, you agree to the{' '} + + Terms + {' '} + and{' '} + + Privacy policy + + + )} + {tokenOnly ? ( + {t('auth.another_login_methods')} + ) : ( + !showTokenForm && Sign in with a token + )} + +
+
+ ); + return product.hasPresets ? content : {content}; +}; diff --git a/frontend/src/App/auth.test.tsx b/frontend/src/App/auth.test.tsx index ebc49993cf..67f195e06e 100644 --- a/frontend/src/App/auth.test.tsx +++ b/frontend/src/App/auth.test.tsx @@ -6,7 +6,7 @@ import { act, create, ReactTestRenderer } from 'react-test-renderer'; import { ROUTES } from 'routes'; import { useGetUserDataQuery } from 'services/user'; -import { LoginByGithub } from 'App/Login/LoginByGithub'; +import { Login } from 'App/Login'; const mockDispatch = jest.fn(); const mockPrivateQuery = jest.fn(); @@ -26,6 +26,7 @@ jest.mock('hooks', () => ({ jest.mock('libs', () => ({ goToUrl: jest.fn() })); jest.mock('services/auth', () => ({ + useGetAuthProvidersQuery: () => ({ data: [{ name: 'github', enabled: true }], isLoading: false }), useGithubAuthorizeMutation: () => [jest.fn(), { isLoading: false }], })); @@ -38,7 +39,11 @@ jest.mock('./slice', () => ({ setUserData: (payload: unknown) => ({ type: 'app/setUserData', payload }), })); -jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { product: string }) => (options ? `Welcome to ${options.product}` : key), + }), +})); jest.mock('layouts/AppLayout', () => ({ __esModule: true, @@ -61,13 +66,24 @@ jest.mock('components', () => { Container: Wrapper, Header: Wrapper, NavigateLink: Wrapper, - ContentLayout: ({ header }: { header: React.ReactNode }) => <>{header}, + ContentLayout: ({ header, children }: { header: React.ReactNode; children: React.ReactNode }) => ( + <> + {header} + {children} + + ), + Spinner: Wrapper, Link: Wrapper, SpaceBetween: Wrapper, }; }); -jest.mock('./Login/SelfHostedLogin', () => ({ SelfHostedLogin: () =>

Server login

})); +jest.mock('product', () => ({ product: { ...jest.requireActual('../../product.config.cjs').getProductConfig('sky') } })); +jest.mock('PublicApp', () => ({ PublicApp: ({ children }: { children: React.ReactNode }) => <>{children} })); +jest.mock('./Login/EntraID/LoginByEntraID', () => ({ LoginByEntraID: () => })); +jest.mock('./Login/LoginByGoogle', () => ({ LoginByGoogle: () => })); +jest.mock('./Login/LoginByOkta', () => ({ LoginByOkta: () => })); +jest.mock('./Login/LoginByTokenForm', () => ({ LoginByTokenForm: () =>
Token
})); jest.mock('./Loading', () => ({ Loading: () =>

Loading

})); jest.mock('./AuthErrorMessage', () => ({ AuthErrorMessage: () =>

Storage unavailable

})); @@ -82,7 +98,7 @@ const renderApp = (path: string) => { rendered = create( - } /> + } /> }> Runs} /> diff --git a/frontend/src/App/index.tsx b/frontend/src/App/index.tsx index e6b6de8f80..78afd5bc15 100644 --- a/frontend/src/App/index.tsx +++ b/frontend/src/App/index.tsx @@ -1,16 +1,17 @@ import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { product } from 'product'; import AppLayout from 'layouts/AppLayout'; import { useAppDispatch, useAppSelector } from 'hooks'; import { useGetUserDataQuery } from 'services/user'; -import { SelfHostedLogin } from './Login/SelfHostedLogin'; import { ROUTES } from '../routes'; import { AuthErrorMessage } from './AuthErrorMessage'; import { Loading } from './Loading'; +import { Login } from './Login'; import { selectAuthToken, setUserData } from './slice'; const IGNORED_AUTH_PATHS = [ @@ -21,7 +22,7 @@ const IGNORED_AUTH_PATHS = [ ROUTES.AUTH.TOKEN, ]; -const LoginFormComponent = process.env.UI_VERSION === 'sky' ? () => : SelfHostedLogin; +const LoginFormComponent = product.hasPresets ? () => : Login; const App: React.FC = () => { const { t } = useTranslation(); diff --git a/frontend/src/App/login.test.tsx b/frontend/src/App/login.test.tsx new file mode 100644 index 0000000000..39e127d7f2 --- /dev/null +++ b/frontend/src/App/login.test.tsx @@ -0,0 +1,156 @@ +/** @jest-environment node */ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { act, create, ReactTestRenderer } from 'react-test-renderer'; +import { product } from 'product'; + +import { ROUTES } from 'routes'; +import { useGetAuthProvidersQuery } from 'services/auth'; + +import { getProductConfig } from '../../product.config.cjs'; +import { Login } from './Login'; + +type ProvidersResult = { + data?: { name: string; enabled: boolean }[]; + isLoading: boolean; + isError?: boolean; +}; +let mockProviders: ProvidersResult; +let rendered: ReactTestRenderer | undefined; +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + +jest.mock('product', () => ({ product: { ...jest.requireActual('../../product.config.cjs').getProductConfig('factory') } })); +jest.mock('hooks', () => ({ useAppSelector: () => undefined })); +jest.mock('services/auth', () => ({ useGetAuthProvidersQuery: jest.fn(() => mockProviders) })); +jest.mock('services/user', () => ({ useGetUserDataQuery: () => ({ isFetching: false }) })); +jest.mock('./slice', () => ({ selectAuthToken: jest.fn() })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { product: string }) => (options ? `Welcome to ${options.product}` : key), + }), +})); +jest.mock('PublicApp', () => ({ PublicApp: ({ children }: { children: React.ReactNode }) => <>{children} })); +jest.mock('@cloudscape-design/design-tokens', () => ({ colorBackgroundHomeHeader: 'transparent' })); +jest.mock('components', () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => <>{children}; + const Anchor = ({ href, children }: { href: string; children: React.ReactNode }) => {children}; + return { + Box: ({ variant, children }: { variant?: string; children: React.ReactNode }) => + variant === 'h1' ?

{children}

: <>{children}, + Container: Wrapper, + Header: Wrapper, + ContentLayout: ({ header, children }: { header: React.ReactNode; children: React.ReactNode }) => ( + <> + {header} + {children} + + ), + Link: Anchor, + NavigateLink: Anchor, + SpaceBetween: Wrapper, + Spinner: () =>

Loading providers

, + }; +}); +jest.mock('./Login/LoginByGithub', () => ({ LoginByGithub: () => })); +jest.mock('./Login/EntraID/LoginByEntraID', () => ({ LoginByEntraID: () => })); +jest.mock('./Login/LoginByGoogle', () => ({ LoginByGoogle: () => })); +jest.mock('./Login/LoginByOkta', () => ({ LoginByOkta: () => })); +jest.mock('./Login/LoginByTokenForm', () => ({ LoginByTokenForm: () =>
Token
})); +jest.mock('./Loading', () => ({ Loading: () =>

Loading user

})); + +const renderLogin = (tokenOnly = false) => { + act(() => { + rendered = create( + + + , + ); + }); + return rendered!.root; +}; + +beforeEach(() => { + Object.defineProperty(globalThis, 'window', { configurable: true, value: { localStorage: {} } }); + Object.assign(product, getProductConfig('factory')); + mockProviders = { data: [], isLoading: false }; +}); +afterEach(() => { + act(() => rendered?.unmount()); + rendered = undefined; +}); +afterAll(() => { + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); +}); + +test.each([ + ['oss', 'dstack'], + ['enterprise', 'dstack Enterprise'], + ['factory', 'dstack Factory'], + ['sky', 'dstack Sky'], +])('%s login uses its branding and the server provider list', (version, name) => { + Object.assign(product, getProductConfig(version)); + mockProviders.data = [ + { name: 'google', enabled: true }, + { name: 'github', enabled: false }, + ]; + const view = renderLogin(); + expect(view.findByType('h1').children).toEqual([`Welcome to ${name}`]); + expect(view.findAllByType('button').map((button) => button.children)).toEqual([['Google']]); + expect(view.findByProps({ href: ROUTES.AUTH.TOKEN })).toBeDefined(); +}); + +test('Factory supports all configured providers without Sky terms', () => { + mockProviders.data = ['github', 'okta', 'entra', 'google'].map((name) => ({ name, enabled: true })); + const view = renderLogin(); + expect(view.findAllByType('button').map((button) => button.children)).toEqual([ + ['GitHub'], + ['Okta'], + ['Entra'], + ['Google'], + ]); + expect(view.findAllByProps({ href: 'https://dstack.ai/terms/' })).toHaveLength(0); +}); + +test('Sky keeps its terms with GitHub login', () => { + Object.assign(product, getProductConfig('sky')); + mockProviders.data = [{ name: 'github', enabled: true }]; + const view = renderLogin(); + expect(view.findByType('button').children).toEqual(['GitHub']); + expect(view.findByProps({ href: 'https://dstack.ai/terms/' })).toBeDefined(); +}); + +test.each([ + { data: [], isLoading: false }, + { data: [{ name: 'github', enabled: false }], isLoading: false }, + { data: [{ name: 'unknown', enabled: true }], isLoading: false }, + { isError: true, isLoading: false }, +])('falls back to token login when no supported provider is available: %j', (result) => { + mockProviders = result; + const view = renderLogin(); + expect(view.findByType('form').children).toEqual(['Token']); + expect(view.findAllByType('button')).toHaveLength(0); +}); + +test('token login remains accessible while provider discovery loads', () => { + mockProviders = { isLoading: true }; + const view = renderLogin(); + expect(view.findByProps({ role: 'status' }).children).toEqual(['Loading providers']); + expect(view.findByProps({ href: ROUTES.AUTH.TOKEN })).toBeDefined(); +}); + +test('the token page does not wait for provider discovery', () => { + mockProviders = { isLoading: true }; + const view = renderLogin(true); + expect(view.findByType('form').children).toEqual(['Token']); + expect(useGetAuthProvidersQuery).toHaveBeenCalledWith(undefined, { skip: true }); + expect(view.findByProps({ href: ROUTES.BASE })).toBeDefined(); +}); + +test('Factory inherits Enterprise features and Sky inherits Factory features', () => { + expect(getProductConfig('enterprise')).toMatchObject({ hasEvents: true, hasBilling: false, hasPresets: false }); + for (const version of ['factory', 'sky']) { + expect(getProductConfig(version)).toMatchObject({ hasEvents: true, hasBilling: true, hasPresets: true }); + } + expect(getProductConfig(undefined)).toMatchObject({ id: 'oss', hasEvents: false, hasBilling: false, hasPresets: false }); +}); diff --git a/frontend/src/PublicApp/index.tsx b/frontend/src/PublicApp/index.tsx index 17fd98ba24..33f29c95c7 100644 --- a/frontend/src/PublicApp/index.tsx +++ b/frontend/src/PublicApp/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { product } from 'product'; import enMessages from '@cloudscape-design/components/i18n/messages/all.en.json'; import { applyMode, Mode } from '@cloudscape-design/global-styles'; @@ -46,7 +47,6 @@ const HeaderPortal = ({ children }: PortalProps) => { }; export const PublicApp: React.FC = ({ children }) => { - const isSky = process.env.UI_VERSION === 'sky'; const { t } = useTranslation(); const dispatch = useAppDispatch(); const navigate = useNavigate(); @@ -115,7 +115,7 @@ export const PublicApp: React.FC = ({ children }) => { }, ]} /> - {isSky && !isAuth && ( + {product.hasPresets && !isAuth && (
- ) - } - > +
{t('common.full_view')}}> {t('navigation.events')}
} diff --git a/frontend/src/pages/Instances/Details/Events/index.tsx b/frontend/src/pages/Instances/Details/Events/index.tsx index 52294acbc9..dc328954cf 100644 --- a/frontend/src/pages/Instances/Details/Events/index.tsx +++ b/frontend/src/pages/Instances/Details/Events/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; +import { product } from 'product'; import Button from '@cloudscape-design/components/button'; import { Header, Loader, Table } from 'components'; @@ -46,13 +47,7 @@ export const EventsList = () => { loading={isLoading} loadingText={t('common.loading')} header={ -
{t('common.full_view')} - ) - } - > +
{t('common.full_view')}}> {t('navigation.events')}
} diff --git a/frontend/src/pages/Project/Backends/Table/constants.tsx b/frontend/src/pages/Project/Backends/Table/constants.tsx index 67e22a0f79..1a118dc6f8 100644 --- a/frontend/src/pages/Project/Backends/Table/constants.tsx +++ b/frontend/src/pages/Project/Backends/Table/constants.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { product } from 'product'; export const BACKENDS_HELP_SKY = { header:

Backends

, @@ -9,8 +10,8 @@ export const BACKENDS_HELP_SKY = {

Marketplace

- By default, dstack Sky includes a preset of backends that let you access compute from the{' '} - dstack marketplace and pay through your dstack Sky user billing. + By default, {product.name} includes a preset of backends that let you access compute from the{' '} + dstack marketplace and pay through your {product.name} user billing.

Your own cloud accounts

diff --git a/frontend/src/pages/Project/Backends/Table/index.tsx b/frontend/src/pages/Project/Backends/Table/index.tsx index fcac9b446a..850b5a6cec 100644 --- a/frontend/src/pages/Project/Backends/Table/index.tsx +++ b/frontend/src/pages/Project/Backends/Table/index.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; +import { product } from 'product'; import { Button, ButtonWithConfirmation, Header, InfoLink, ListEmptyMessage, SpaceBetween, Table } from 'components'; @@ -9,7 +10,7 @@ import { BACKENDS_HELP_SELF_HOSTED, BACKENDS_HELP_SKY } from './constants'; import { useColumnsDefinitions } from './hooks'; import { IProps } from './types'; -const INFO = process.env.UI_VERSION === 'sky' ? BACKENDS_HELP_SKY : BACKENDS_HELP_SELF_HOSTED; +const INFO = product.hasBilling ? BACKENDS_HELP_SKY : BACKENDS_HELP_SELF_HOSTED; export const BackendsTable: React.FC = ({ backends, diff --git a/frontend/src/pages/Project/Backends/YAMLForm/constants.tsx b/frontend/src/pages/Project/Backends/YAMLForm/constants.tsx index f8686b246e..2ec1377c29 100644 --- a/frontend/src/pages/Project/Backends/YAMLForm/constants.tsx +++ b/frontend/src/pages/Project/Backends/YAMLForm/constants.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { product } from 'product'; export const CONFIG_YAML_HELP_SKY = { header:

Backend config

, @@ -11,7 +12,8 @@ export const CONFIG_YAML_HELP_SKY = {

Marketplace

If you set creds's type to dstack, you'll get compute from{' '} - dstack's marketplace and will pay for it via your dstack Sky user billing. Example: + dstack's marketplace and will pay for it via your {product.name} user billing. + Example:

diff --git a/frontend/src/pages/Project/Backends/YAMLForm/index.tsx b/frontend/src/pages/Project/Backends/YAMLForm/index.tsx
index f14e8b1f3a..8f5b1c3e39 100644
--- a/frontend/src/pages/Project/Backends/YAMLForm/index.tsx
+++ b/frontend/src/pages/Project/Backends/YAMLForm/index.tsx
@@ -1,6 +1,7 @@
 import React, { useState } from 'react';
 import { useForm } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
+import { product } from 'product';
 
 import { Button, FormCodeEditor, FormUI, InfoLink, SpaceBetween } from 'components';
 
@@ -11,7 +12,7 @@ import { CONFIG_YAML_HELP_SELF_HOSTED, CONFIG_YAML_HELP_SKY } from './constants'
 
 import { FieldPath } from 'react-hook-form/dist/types/path';
 
-const INFO = process.env.UI_VERSION === 'sky' ? CONFIG_YAML_HELP_SKY : CONFIG_YAML_HELP_SELF_HOSTED;
+const INFO = product.hasBilling ? CONFIG_YAML_HELP_SKY : CONFIG_YAML_HELP_SELF_HOSTED;
 
 export interface IProps {
     initialValues?: IBackendConfigYaml;
diff --git a/frontend/src/pages/Project/Details/Settings/index.tsx b/frontend/src/pages/Project/Details/Settings/index.tsx
index db3a6fe339..a2f0b5f4e8 100644
--- a/frontend/src/pages/Project/Details/Settings/index.tsx
+++ b/frontend/src/pages/Project/Details/Settings/index.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { useDispatch } from 'react-redux';
 import { useLocation, useNavigate, useParams } from 'react-router-dom';
 import { debounce } from 'lodash';
+import { product } from 'product';
 import { ExpandableSection, Tabs } from '@cloudscape-design/components';
 import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
 
@@ -115,7 +116,6 @@ export const ProjectSettings: React.FC = () => {
         value: data?.owner.username,
     };
 
-    const isSky = process.env.UI_VERSION === 'sky';
     const visibilityOptions = [
         { label: t('projects.edit.visibility.private'), value: 'private' },
         { label: t('projects.edit.visibility.public'), value: 'public' },
@@ -213,7 +213,7 @@ export const ProjectSettings: React.FC = () => {
     };
 
     const openChangeVisibilityDialog = () => {
-        setVisibilityEnabled(isSky ? (data?.public_presets ?? false) : !!data?.isPublic);
+        setVisibilityEnabled(product.hasPresets ? (data?.public_presets ?? false) : !!data?.isPublic);
         setIsChangeVisibilityVisible(true);
     };
 
@@ -221,7 +221,7 @@ export const ProjectSettings: React.FC = () => {
         if (!data || !isProjectAdmin(data) || isUpdatingVisibility) return;
 
         try {
-            if (isSky) {
+            if (product.hasPresets) {
                 await updateProjectPublicPresets({
                     project_name: paramProjectName,
                     public_presets: visibilityEnabled,
@@ -233,7 +233,9 @@ export const ProjectSettings: React.FC = () => {
             setIsChangeVisibilityVisible(false);
             pushNotification({
                 type: 'success',
-                content: t(isSky ? 'projects.edit.update_presets_success' : 'projects.edit.update_visibility_success'),
+                content: t(
+                    product.hasPresets ? 'projects.edit.update_presets_success' : 'projects.edit.update_visibility_success',
+                ),
             });
         } catch (error: unknown) {
             pushNotification({ type: 'error', content: getApiErrorMessage(error) });
@@ -511,18 +513,20 @@ export const ProjectSettings: React.FC = () => {
                                         
                                     )}
 
-                                    {(isSky || isAvailableProjectManaging) && (
+                                    {(product.hasPresets || isAvailableProjectManaging) && (
                                         <>
                                             
{t( - isSky + product.hasPresets ? 'projects.edit.presets_settings' : 'projects.edit.project_visibility_settings', )} openHelpPanel(isSky ? PRESETS_INFO : VISIBILITY_INFO)} + onFollow={() => + openHelpPanel(product.hasPresets ? PRESETS_INFO : VISIBILITY_INFO) + } />
@@ -607,12 +611,12 @@ export const ProjectSettings: React.FC = () => { visible={isChangeVisibilityVisible} onDiscard={() => setIsChangeVisibilityVisible(false)} onConfirm={confirmChangeVisibility} - title={t(isSky ? 'projects.edit.presets_settings' : 'projects.edit.project_visibility_settings')} + title={t(product.hasPresets ? 'projects.edit.presets_settings' : 'projects.edit.project_visibility_settings')} confirmButtonLabel={t('projects.edit.change_visibility')} content={ { id: 'settings', href: ROUTES.PROJECT.DETAILS.SETTINGS.FORMAT(paramProjectName), }, - (process.env.UI_VERSION === 'factory' || process.env.UI_VERSION === 'sky') && { + product.hasEvents && { label: t('projects.events'), id: 'events', href: ROUTES.PROJECT.DETAILS.EVENTS.FORMAT(paramProjectName), diff --git a/frontend/src/pages/Runs/Details/Events/List/index.tsx b/frontend/src/pages/Runs/Details/Events/List/index.tsx index 178ad717c7..05c3015a10 100644 --- a/frontend/src/pages/Runs/Details/Events/List/index.tsx +++ b/frontend/src/pages/Runs/Details/Events/List/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useListener } from 'react-bus'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; +import { product } from 'product'; import Button from '@cloudscape-design/components/button'; import { Header, Loader, Table } from 'components'; @@ -51,13 +52,7 @@ export const EventsList = () => { loading={isLoading} loadingText={t('common.loading')} header={ -
{t('common.full_view')} - ) - } - > +
{t('common.full_view')}}> {t('navigation.events')}
} diff --git a/frontend/src/pages/Runs/Details/Jobs/Events/index.tsx b/frontend/src/pages/Runs/Details/Jobs/Events/index.tsx index 3693f067d5..3f79de57c5 100644 --- a/frontend/src/pages/Runs/Details/Jobs/Events/index.tsx +++ b/frontend/src/pages/Runs/Details/Jobs/Events/index.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; +import { product } from 'product'; import Button from '@cloudscape-design/components/button'; import { Header, Loader, Table } from 'components'; @@ -64,7 +65,7 @@ export const EventsList = () => { header={
{t('common.full_view')} diff --git a/frontend/src/pages/User/Details/index.tsx b/frontend/src/pages/User/Details/index.tsx index 62cff381e4..aaaca99f5f 100644 --- a/frontend/src/pages/User/Details/index.tsx +++ b/frontend/src/pages/User/Details/index.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Outlet, useNavigate, useParams } from 'react-router-dom'; +import { product } from 'product'; import { Box, ConfirmationDialog, ContentLayout, Tabs } from 'components'; import { DetailsHeader } from 'components'; @@ -72,12 +73,12 @@ export const UserDetails: React.FC = () => { id: UserDetailsTabTypeEnum.PROJECTS, href: ROUTES.USER.PROJECTS.FORMAT(paramUserName), }, - (process.env.UI_VERSION === 'factory' || process.env.UI_VERSION === 'sky') && { + product.hasEvents && { label: t('users.events'), id: UserDetailsTabTypeEnum.EVENTS, href: ROUTES.USER.EVENTS.FORMAT(paramUserName), }, - process.env.UI_VERSION === 'sky' && { + product.hasBilling && { label: t('billing.title'), id: UserDetailsTabTypeEnum.BILLING, href: ROUTES.USER.BILLING.LIST.FORMAT(paramUserName), diff --git a/frontend/src/pages/User/List/hooks.tsx b/frontend/src/pages/User/List/hooks.tsx index 7f9f24549c..25bb74b849 100644 --- a/frontend/src/pages/User/List/hooks.tsx +++ b/frontend/src/pages/User/List/hooks.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { format } from 'date-fns'; +import { product } from 'product'; import { Link, NavigateLink } from 'components'; @@ -29,7 +30,7 @@ export const useColumnDefinitions = () => { header: t('users.global_role'), cell: (item: IUser) => t(`roles.${item.global_role}`), }, - process.env.UI_VERSION === 'sky' && { + product.hasBilling && { id: 'created_at', header: t('users.created_at'), cell: (item: IUser) => format(new Date(item.created_at), DATE_TIME_FORMAT), diff --git a/frontend/src/product.ts b/frontend/src/product.ts new file mode 100644 index 0000000000..562e5fd65a --- /dev/null +++ b/frontend/src/product.ts @@ -0,0 +1,3 @@ +import { getProductConfig } from '../product.config.cjs'; + +export const product = getProductConfig(process.env.UI_VERSION); diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 702bc6193d..0617436182 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -2,12 +2,13 @@ import React from 'react'; import type { RouteObject } from 'react-router-dom'; import { createBrowserRouter } from 'react-router-dom'; import { Navigate } from 'react-router-dom'; +import { product } from 'product'; import { PublicApp } from 'PublicApp'; import { PresetApp } from 'PublicApp/PresetApp'; import App from 'App'; +import { Login } from 'App/Login'; import { LoginByEntraIDCallback } from 'App/Login/EntraID/LoginByEntraIDCallback'; -import { LoginByGithub } from 'App/Login/LoginByGithub'; import { LoginByGithubCallback } from 'App/Login/LoginByGithubCallback'; import { LoginByGoogleCallback } from 'App/Login/LoginByGoogleCallback'; import { LoginByOktaCallback } from 'App/Login/LoginByOktaCallback'; @@ -42,13 +43,13 @@ import { VolumeList } from './pages/Volumes'; import { ROUTES } from './routes'; export const router = createBrowserRouter([ - ...(process.env.UI_VERSION === 'sky' + ...(product.hasPresets ? [ { element: , errorElement: , children: [ - { path: ROUTES.BASE, element: }, + { path: ROUTES.BASE, element: }, { path: ROUTES.AUTH.TOKEN, element: }, ], }, @@ -96,11 +97,9 @@ export const router = createBrowserRouter([ path: ROUTES.AUTH.GOOGLE_CALLBACK, element: , }, - ...(process.env.UI_VERSION !== 'sky' ? [{ path: ROUTES.AUTH.TOKEN, element: }] : []), + ...(!product.hasPresets ? [{ path: ROUTES.AUTH.TOKEN, element: }] : []), // hubs - ...(process.env.UI_VERSION !== 'sky' - ? [{ path: ROUTES.BASE, element: }] - : []), + ...(!product.hasPresets ? [{ path: ROUTES.BASE, element: }] : []), { path: ROUTES.PROJECT.LIST, element: , @@ -113,7 +112,7 @@ export const router = createBrowserRouter([ index: true, element: , }, - (process.env.UI_VERSION === 'factory' || process.env.UI_VERSION === 'sky') && { + product.hasEvents && { path: ROUTES.PROJECT.DETAILS.EVENTS.TEMPLATE, element: , }, @@ -185,11 +184,11 @@ export const router = createBrowserRouter([ }, ...([ - process.env.UI_VERSION !== 'sky' && { + !product.hasBilling && { path: ROUTES.PROJECT.ADD, element: , }, - process.env.UI_VERSION === 'sky' && { + product.hasBilling && { path: ROUTES.PROJECT.ADD, element: , }, @@ -222,9 +221,9 @@ export const router = createBrowserRouter([ element: , }, - // Events, Factory and Sky only + // Events, Enterprise and above ...([ - (process.env.UI_VERSION === 'factory' || process.env.UI_VERSION === 'sky') && { + product.hasEvents && { path: ROUTES.EVENTS.LIST, element: , }, @@ -309,7 +308,7 @@ export const router = createBrowserRouter([ path: ROUTES.USER.PROJECTS.TEMPLATE, element: , }, - (process.env.UI_VERSION === 'factory' || process.env.UI_VERSION === 'sky') && { + product.hasEvents && { path: ROUTES.USER.EVENTS.TEMPLATE, element: , }, @@ -318,7 +317,7 @@ export const router = createBrowserRouter([ path: ROUTES.USER.PUBLIC_KEYS.TEMPLATE, element: , }, - process.env.UI_VERSION === 'sky' && { + product.hasBilling && { path: ROUTES.USER.BILLING.LIST.TEMPLATE, element: , }, diff --git a/frontend/src/services/auth.ts b/frontend/src/services/auth.ts index 2512ed0a7d..7a8e29b110 100644 --- a/frontend/src/services/auth.ts +++ b/frontend/src/services/auth.ts @@ -12,6 +12,9 @@ export const authApi = createApi({ tagTypes: ['Auth'], endpoints: (builder) => ({ + getAuthProviders: builder.query<{ name: string; enabled: boolean }[], void>({ + query: () => ({ url: API.AUTH.LIST_PROVIDERS(), method: 'POST' }), + }), getNextRedirect: builder.mutation<{ redirect_url?: string }, { code: string; state: string }>({ query: (body) => ({ url: API.AUTH.NEXT_REDIRECT(), @@ -111,6 +114,7 @@ export const authApi = createApi({ }); export const { + useGetAuthProvidersQuery, useGetNextRedirectMutation, useGithubAuthorizeMutation, useGithubCallbackMutation, diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts index 0709a1bd4d..a35aa9f9d4 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -30,7 +30,7 @@ declare namespace NodeJS { interface ProcessEnv { readonly NODE_ENV: 'development' | 'production' | 'test'; readonly GA_MEASUREMENT_ID: string; - readonly UI_VERSION: 'sky' | 'factory' | 'oss'; + readonly UI_VERSION: 'sky' | 'factory' | 'enterprise' | 'oss'; readonly PUBLIC_URL: string; readonly API_URL: string; } diff --git a/frontend/webpack/env.js b/frontend/webpack/env.js index f87077c2cb..76703da6a4 100644 --- a/frontend/webpack/env.js +++ b/frontend/webpack/env.js @@ -1,4 +1,5 @@ const { join } = require('path'); +const { getProductConfig } = require('../product.config.cjs'); const apiURLs = '/api'; @@ -16,9 +17,9 @@ const publicDir = join(__dirname, '../public'); const apiUrl = process.env.API_URL || apiURLs; const publicUrl = process.env.PUBLIC_URL || publicURLs; const gaMeasurementId = process.env.GA_MEASUREMENT_ID || ''; -const uiVersion = ['sky', 'factory'].includes(process.env.UI_VERSION) ? process.env.UI_VERSION : 'oss'; - -const title = uiVersion === 'sky' ? 'dstack Sky' : 'dstack'; +const product = getProductConfig(process.env.UI_VERSION); +const uiVersion = product.id; +const title = product.name; const description = 'Get GPUs at the best prices and availability from a wide range of providers. No cloud ' + 'account of your own is required.\n'; diff --git a/src/dstack/_internal/server/app.py b/src/dstack/_internal/server/app.py index 904cb3285d..083435cb32 100644 --- a/src/dstack/_internal/server/app.py +++ b/src/dstack/_internal/server/app.py @@ -4,7 +4,6 @@ import time from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager -from pathlib import Path from typing import Annotated, Awaitable, Callable, List, Optional import sentry_sdk @@ -245,7 +244,14 @@ def add_no_api_version_check_routes(paths: List[str]): _NO_API_VERSION_CHECK_ROUTES.extend(paths) -def register_routes(app: FastAPI, ui: bool = True): +def register_routes(app: FastAPI, statics_package: Optional[str] = "dstack._internal.server"): + """ + Registers dstack server routes on `app`. + + `statics_package` is the package whose `statics` directory holds the frontend build. + Passing `None` or a package without `statics` disables the UI and redirects `/` + to the API docs. + """ app.include_router(server.router) app.include_router(users.router) app.include_router(auth.router) @@ -338,10 +344,8 @@ async def profile_request(request: Request, call_next): async def healthcheck(): return CustomJSONResponse(content={"status": "running"}) - if ui and Path(__file__).parent.joinpath("statics").exists(): - app.mount( - "/", CustomStaticFiles(packages=["dstack._internal.server"], html=True), name="statics" - ) + if statics_package is not None and _statics_exist(statics_package): + app.mount("/", CustomStaticFiles(packages=[statics_package], html=True), name="statics") @app.exception_handler(404) async def custom_http_exception_handler(request, exc): @@ -356,7 +360,7 @@ async def custom_http_exception_handler(request, exc): ) else: return HTMLResponse( - importlib.resources.files("dstack._internal.server") + importlib.resources.files(statics_package) .joinpath("statics/index.html") .read_text() ) @@ -381,6 +385,10 @@ def _check_client_version( ) +def _statics_exist(statics_package: str) -> bool: + return importlib.resources.files(statics_package).joinpath("statics").is_dir() + + def _is_proxy_request(request: Request) -> bool: if request.url.path.startswith("/proxy"): return True diff --git a/src/dstack/_internal/server/migrations/env.py b/src/dstack/_internal/server/migrations/env.py index 0ab4b713a8..3c7eacb9bf 100644 --- a/src/dstack/_internal/server/migrations/env.py +++ b/src/dstack/_internal/server/migrations/env.py @@ -9,11 +9,6 @@ from dstack._internal.server.models import BaseModel, EnumAsString from dstack._internal.server.settings import init_server_data_dir -config = context.config - -if config.config_file_name is not None and config.attributes.get("configure_logging", True): - fileConfig(config.config_file_name) - target_metadata = BaseModel.metadata @@ -57,7 +52,7 @@ def run_migrations_online(): In this scenario we need to create an Engine and associate a connection with the context. """ - connection = config.attributes.get("connection", None) + connection = context.config.attributes.get("connection", None) if connection is None: asyncio.run(run_async_migrations()) else: @@ -103,6 +98,11 @@ async def run_async_migrations(): def main(): + # Extending servers import this module once and reuse it across Alembic commands. + # Read the active configuration on each invocation rather than caching it at import. + config = context.config + if config.config_file_name is not None and config.attributes.get("configure_logging", True): + fileConfig(config.config_file_name) if context.is_offline_mode(): run_migrations_offline() else: diff --git a/src/dstack/_internal/server/services/plugins.py b/src/dstack/_internal/server/services/plugins.py index d40b84b36d..9e3915923d 100644 --- a/src/dstack/_internal/server/services/plugins.py +++ b/src/dstack/_internal/server/services/plugins.py @@ -13,6 +13,10 @@ _PLUGINS: list[Plugin] = [] +# Plugins registered by extending servers. Unlike `_PLUGINS`, they are not affected by +# `load_plugins`, so they stay active regardless of the plugins enabled in the server config. +_REGISTERED_PLUGINS: list[Plugin] = [] + _BUILTIN_PLUGINS: Dict[str, str] = {"rest_plugin": "dstack.plugins.builtin.rest_plugin:RESTPlugin"} @@ -91,6 +95,14 @@ def load_plugins(enabled_plugins: list[str]): logger.warning("Enabled plugins not found: %s", plugins_to_load) +def register_plugin(plugin: Plugin): + """ + Extension point for alternative dstack versions. + Registers a plugin that is always loaded, independently of `load_plugins`. + """ + _REGISTERED_PLUGINS.append(plugin) + + async def apply_plugin_policies(user: str, project: str, spec: ApplySpec) -> ApplySpec: policies = _get_apply_policies() for policy in policies: @@ -105,4 +117,4 @@ async def apply_plugin_policies(user: str, project: str, spec: ApplySpec) -> App def _get_apply_policies() -> list[ApplyPolicy]: - return list(itertools.chain(*[p.get_apply_policies() for p in _PLUGINS])) + return list(itertools.chain(*[p.get_apply_policies() for p in _REGISTERED_PLUGINS + _PLUGINS]))