From ec7b35d77e992f56195fb7c810d37a2f44c93ee6 Mon Sep 17 00:00:00 2001 From: Chris Suich <> Date: Mon, 28 Apr 2025 11:15:34 -0400 Subject: [PATCH 01/49] feat: add techdocs-entity-path annotation for techdocs deep linking This annotation enables specifying a path within another entities techdocs to use as the root techdocs page. Signed-off-by: Chris Suich --- .changeset/smooth-eels-think.md | 7 ++ .../well-known-annotations.md | 14 ++++ docs/features/techdocs/how-to-guides.md | 29 +++++++++ plugins/catalog/package.json | 1 + .../components/AboutCard/AboutCard.test.tsx | 58 +++++++++++++++++ .../src/components/AboutCard/AboutCard.tsx | 30 +-------- plugins/techdocs-common/src/constants.ts | 3 + plugins/techdocs/src/EntityPageDocs.tsx | 4 ++ plugins/techdocs/src/helpers.ts | 65 +++++++++++++++++++ plugins/techdocs/src/index.ts | 3 + .../TechDocsReaderPageContent.test.tsx | 32 ++++++++- .../TechDocsReaderPageContent.tsx | 7 +- .../TechDocsReaderPageContent/dom.tsx | 28 +++++++- yarn.lock | 1 + 14 files changed, 251 insertions(+), 31 deletions(-) create mode 100644 .changeset/smooth-eels-think.md diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md new file mode 100644 index 0000000000..33e711045c --- /dev/null +++ b/.changeset/smooth-eels-think.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-techdocs': minor +'@backstage/plugin-catalog': minor +'@backstage/plugin-techdocs-common': patch +--- + +Introduced "backstage.io/techdocs-entity-path" annotation which allows deep linking into another entities TechDocs in conjunction with "backstage.io/techdocs-entity". diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 974fa80993..4e2801197f 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -115,6 +115,20 @@ the TechDocs in the TechDocs page or needing multiple builds of the same docs. This is for situations where you have complex systems where they share a single repo, and likely a single TechDoc location. +### backstage.io/techdocs-entity-path + +```yaml +# Example: +metadata: + annotations: + backstage.io/techdocs-entity: component:default/example + backstage.io/techdocs-entity-path: /path/to/this/component +``` + +The value of this annotation informs of the path to this components TechDocs within an external entity that owns the TechDocs. +In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of +another entity, not just linking to the root of another entities TechDocs. + ### backstage.io/view-url, backstage.io/edit-url ```yaml diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 8a95c86533..2ebda55de2 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -942,6 +942,35 @@ metadata: backstage.io/techdocs-entity: system:default/example ``` +### Deep linking into another components TechDocs + +In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a +specific page within another component's TechDocs. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: example + namespace: default + title: Example + description: This is the parent entity + annotations: + backstage.io/techdocs-ref: dir:. + +--- +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: example-platfrom + title: Example Application Platform + namespace: default + description: This is the child entity + annotations: + backstage.io/techdocs-entity: system:default/example + backstage.io/techdocs-entity-path: /path/to/component/docs +``` + ## How to resolve broken links from moved or renamed pages in your documentation site TechDocs supports using the [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects/tree/master) plugin to create a redirect map for any TechDocs site. This allows broken links from renamed or moved pages in your site to be redirected to their specified replacement. diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index b046eea88d..d9124f0077 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -72,6 +72,7 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", + "@backstage/plugin-techdocs": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index 0255339238..f8bebcf037 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -589,6 +589,64 @@ describe('', () => { ); }); + it('renders techdocs link to specific path', async () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/techdocs-entity': 'system:default/example', + 'backstage.io/techdocs-entity-path': '/path/to/component', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + + await renderInTestApp( + + + + + , + { + mountedRoutes: { + '/docs/:namespace/:kind/:name': viewTechDocRouteRef, + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(screen.getByText('View TechDocs').closest('a')).toHaveAttribute( + 'href', + '/docs/default/system/example/path/to/component', + ); + }); + it('renders techdocs link', async () => { const entity = { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index bf5be95a08..4d8421932b 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -16,10 +16,8 @@ import { ANNOTATION_EDIT_URL, ANNOTATION_LOCATION, - CompoundEntityRef, DEFAULT_NAMESPACE, stringifyEntityRef, - parseEntityRef, } from '@backstage/catalog-model'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; @@ -66,6 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha' import { usePermission } from '@backstage/plugin-permission-react'; import { catalogTranslationRef } from '../../alpha/translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { buildTechDocsURL } from '@backstage/plugin-techdocs'; const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; @@ -135,19 +134,6 @@ export function AboutCard(props: AboutCardProps) { const entityMetadataEditUrl = entity.metadata.annotations?.[ANNOTATION_EDIT_URL]; - let techdocsRef: CompoundEntityRef | undefined; - - if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { - try { - techdocsRef = parseEntityRef( - entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION], - ); - // not a fan of this but we don't care if the parseEntityRef fails - } catch { - techdocsRef = undefined; - } - } - const viewInSource: IconLinkVerticalProps = { label: t('aboutCard.viewSource'), disabled: !entitySourceLocation, @@ -162,19 +148,7 @@ export function AboutCard(props: AboutCardProps) { entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION] ) || !viewTechdocLink, icon: , - href: - viewTechdocLink && - (techdocsRef - ? viewTechdocLink({ - namespace: techdocsRef.namespace || DEFAULT_NAMESPACE, - kind: techdocsRef.kind, - name: techdocsRef.name, - }) - : viewTechdocLink({ - namespace: entity.metadata.namespace || DEFAULT_NAMESPACE, - kind: entity.kind, - name: entity.metadata.name, - })), + href: buildTechDocsURL(entity, viewTechdocLink), }; const subHeaderLinks = [viewInSource, viewInTechDocs]; diff --git a/plugins/techdocs-common/src/constants.ts b/plugins/techdocs-common/src/constants.ts index 40187f41ce..a31951204e 100644 --- a/plugins/techdocs-common/src/constants.ts +++ b/plugins/techdocs-common/src/constants.ts @@ -18,3 +18,6 @@ export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; /** @public */ export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity'; +/** @public */ +export const TECHDOCS_EXTERNAL_PATH_ANNOTATION = + 'backstage.io/techdocs-entity-path'; diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index 68c26cd1c3..a88ec8275d 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -25,6 +25,7 @@ import { TechDocsReaderPage } from './plugin'; import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent'; import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader'; import { useEntityPageTechDocsRedirect } from './search/hooks/useTechDocsLocation'; +import { getEntityRootTechDocsPath } from './helpers'; type EntityPageDocsProps = { entity: Entity; @@ -52,12 +53,15 @@ export const EntityPageDocs = ({ } } + const defaultPath = getEntityRootTechDocsPath(entity); + return ( ); diff --git a/plugins/techdocs/src/helpers.ts b/plugins/techdocs/src/helpers.ts index 7ff4dec6e7..4b525445d7 100644 --- a/plugins/techdocs/src/helpers.ts +++ b/plugins/techdocs/src/helpers.ts @@ -14,7 +14,23 @@ * limitations under the License. */ +import { + DEFAULT_NAMESPACE, + Entity, + parseEntityRef, +} from '@backstage/catalog-model'; import { Config } from '@backstage/config'; +import { + TECHDOCS_EXTERNAL_ANNOTATION, + TECHDOCS_EXTERNAL_PATH_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; +import { RouteFunc } from '@backstage/core-plugin-api'; + +export type TechDocsRouteFunc = RouteFunc<{ + namespace: string; + kind: string; + name: string; +}>; // Lower-case entity triplets by default, but allow override. export function toLowerMaybe(str: string, config: Config) { @@ -24,3 +40,52 @@ export function toLowerMaybe(str: string, config: Config) { ? str : str.toLocaleLowerCase('en-US'); } + +export function getEntityRootTechDocsPath(entity: Entity): string { + let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION]; + if (!path) { + return ''; + } + if (!path.startsWith('/')) { + path = `/${path}`; + } + return path; +} + +export const buildTechDocsURL = ( + entity: Entity, + routeFunc: TechDocsRouteFunc | undefined, +) => { + if (!routeFunc) { + return undefined; + } + + let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE; + let kind = entity.kind; + let name = entity.metadata.name; + + if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { + try { + const techdocsRef = parseEntityRef( + entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION], + ); + namespace = techdocsRef.namespace; + kind = techdocsRef.kind; + name = techdocsRef.name; + } catch { + // not a fan of this but we don't care if the parseEntityRef fails + } + } + + const url = routeFunc({ + namespace, + kind, + name, + }); + + // Add on the external entity path to the url if one exists. This allows deep linking into another + // entities TechDocs. + const path = getEntityRootTechDocsPath(entity); + + return `${url}${path}`; +}; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index 713efc17c6..e7cefa6437 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -46,6 +46,7 @@ export { LegacyEmbeddedDocsRouter as EmbeddedDocsRouter, Router, } from './Router'; +export { buildTechDocsURL, getEntityRootTechDocsPath } from './helpers'; export type { TechDocsSearchResultListItemProps } from './search/components/TechDocsSearchResultListItem'; @@ -69,3 +70,5 @@ export type { }; export * from './overridableComponents'; + +export type { TechDocsRouteFunc } from './helpers'; diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx index a8e7ae7287..77bb3d134f 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx @@ -16,7 +16,10 @@ import { ReactNode } from 'react'; import { waitFor } from '@testing-library/react'; -import { CompoundEntityRef } from '@backstage/catalog-model'; +import { + CompoundEntityRef, + getCompoundEntityRef, +} from '@backstage/catalog-model'; import { techdocsApiRef, TechDocsReaderPageProvider, @@ -121,6 +124,33 @@ describe('', () => { }); }); + it('should render techdocs page content with default path', async () => { + getEntityMetadata.mockResolvedValue(mockEntityMetadata); + getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata); + useTechDocsReaderDom.mockReturnValue(document.createElement('html')); + useReaderState.mockReturnValue({ state: 'cached' }); + + const defaultPath = '/some/path'; + + const rendered = await renderInTestApp( + + + , + ); + + await waitFor(() => { + expect( + rendered.getByTestId('techdocs-native-shadowroot'), + ).toBeInTheDocument(); + }); + + const entityRef = getCompoundEntityRef(mockEntityMetadata); + expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath); + }); + it('should not render techdocs content if entity metadata is missing', async () => { getEntityMetadata.mockResolvedValue(undefined); useTechDocsReaderDom.mockReturnValue(document.createElement('html')); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index 1db97a82b8..fe72b09073 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -61,6 +61,11 @@ export type TechDocsReaderPageContentProps = { * @deprecated No need to pass down entityRef as property anymore. Consumes the entityName from `TechDocsReaderPageContext`. Use the {@link @backstage/plugin-techdocs-react#useTechDocsReaderPage} hook for custom reader page content. */ entityRef?: CompoundEntityRef; + /** + * Path in the docs to render by default. This should be used when rendering docs for an entity that specifies the + * "backstage.io/techdocs-entity-path" annotation for deep linking into another entities docs. + */ + defaultPath?: string; /** * Show or hide the search bar, defaults to true. */ @@ -93,7 +98,7 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( setShadowRoot, } = useTechDocsReaderPage(); const { state } = useTechDocsReader(); - const dom = useTechDocsReaderDom(entityRef); + const dom = useTechDocsReaderDom(entityRef, props.defaultPath); const path = window.location.pathname; const hash = window.location.hash; const isStyleLoading = useShadowDomStylesLoading(dom); diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index c17d5fcac4..f441d1b472 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -47,10 +47,33 @@ import { handleMetaRedirects, } from '../../transformers'; import { useNavigateUrl } from './useNavigateUrl'; -import { useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)'; +// If a defaultPath is specified then we should navigate to that path replacing the +// current location in the history. This should only happen on the initial load so +// navigating to the root of the docs doesn't also redirect. +const useInitialRedirect = (defaultPath?: string) => { + const [hasRun, setHasRun] = useState(false); + + const location = useLocation(); + const navigate = useNavigate(); + const { '*': currPath = '' } = useParams(); + + useEffect(() => { + // Only run once + if (hasRun) { + return; + } + setHasRun(true); + + if (currPath === '' && defaultPath !== '') { + navigate(`${location.pathname}${defaultPath}`, { replace: true }); + } + }, [hasRun, currPath, defaultPath, location, navigate]); +}; + /** * Hook that encapsulates the behavior of getting raw HTML and applying * transforms to it in order to make it function at a basic level in the @@ -58,6 +81,7 @@ const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)'; */ export const useTechDocsReaderDom = ( entityRef: CompoundEntityRef, + defaultPath?: string, ): Element | null => { const navigate = useNavigateUrl(); const theme = useTheme(); @@ -76,6 +100,8 @@ export const useTechDocsReaderDom = ( const [dom, setDom] = useState(null); const isStyleLoading = useShadowDomStylesLoading(dom); + useInitialRedirect(defaultPath); + const updateSidebarPositionAndHeight = useCallback(() => { if (!dom) return; diff --git a/yarn.lock b/yarn.lock index a8f9207723..7e61c3ca48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6355,6 +6355,7 @@ __metadata: "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^" + "@backstage/plugin-techdocs": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" From ae717aa54c7681fb67fe1b861272d0036c78f2b6 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Mon, 28 Apr 2025 12:42:35 -0400 Subject: [PATCH 02/49] move helpers to techdocs-react Signed-off-by: Chris Suich --- .changeset/smooth-eels-think.md | 1 + plugins/catalog/package.json | 2 +- .../src/components/AboutCard/AboutCard.tsx | 2 +- plugins/techdocs-react/package.json | 1 + plugins/techdocs-react/src/helpers.ts | 67 ++++++++++++++++++- plugins/techdocs-react/src/index.ts | 7 +- plugins/techdocs/src/EntityPageDocs.tsx | 2 +- plugins/techdocs/src/helpers.ts | 65 ------------------ plugins/techdocs/src/index.ts | 3 - yarn.lock | 3 +- 10 files changed, 79 insertions(+), 74 deletions(-) diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md index 33e711045c..ddae6dc151 100644 --- a/.changeset/smooth-eels-think.md +++ b/.changeset/smooth-eels-think.md @@ -1,5 +1,6 @@ --- '@backstage/plugin-techdocs': minor +'@backstage/plugin-techdocs-react': minor '@backstage/plugin-catalog': minor '@backstage/plugin-techdocs-common': patch --- diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index d9124f0077..2f8ea66134 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -72,7 +72,7 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", - "@backstage/plugin-techdocs": "workspace:^", + "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 4d8421932b..46bc239ffa 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -64,7 +64,7 @@ import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha' import { usePermission } from '@backstage/plugin-permission-react'; import { catalogTranslationRef } from '../../alpha/translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; -import { buildTechDocsURL } from '@backstage/plugin-techdocs'; +import { buildTechDocsURL } from '@backstage/plugin-techdocs-react'; const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; diff --git a/plugins/techdocs-react/package.json b/plugins/techdocs-react/package.json index f6dc740ee7..6c7166f997 100644 --- a/plugins/techdocs-react/package.json +++ b/plugins/techdocs-react/package.json @@ -63,6 +63,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/plugin-techdocs-common": "workspace:^", "@backstage/version-bridge": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/styles": "^4.11.0", diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts index 3f10fff2ba..8e3a6b5e7d 100644 --- a/plugins/techdocs-react/src/helpers.ts +++ b/plugins/techdocs-react/src/helpers.ts @@ -15,7 +15,23 @@ */ import { Config } from '@backstage/config'; -import { CompoundEntityRef } from '@backstage/catalog-model'; +import { + CompoundEntityRef, + DEFAULT_NAMESPACE, + Entity, + parseEntityRef, +} from '@backstage/catalog-model'; +import { + TECHDOCS_EXTERNAL_ANNOTATION, + TECHDOCS_EXTERNAL_PATH_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; +import { RouteFunc } from '@backstage/core-plugin-api'; + +export type TechDocsRouteFunc = RouteFunc<{ + namespace: string; + kind: string; + name: string; +}>; /** * Lower-case entity triplets by default, but allow override. @@ -35,3 +51,52 @@ export function toLowercaseEntityRefMaybe( return entityRef; } + +export function getEntityRootTechDocsPath(entity: Entity): string { + let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION]; + if (!path) { + return ''; + } + if (!path.startsWith('/')) { + path = `/${path}`; + } + return path; +} + +export const buildTechDocsURL = ( + entity: Entity, + routeFunc: TechDocsRouteFunc | undefined, +) => { + if (!routeFunc) { + return undefined; + } + + let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE; + let kind = entity.kind; + let name = entity.metadata.name; + + if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { + try { + const techdocsRef = parseEntityRef( + entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION], + ); + namespace = techdocsRef.namespace; + kind = techdocsRef.kind; + name = techdocsRef.name; + } catch { + // not a fan of this but we don't care if the parseEntityRef fails + } + } + + const url = routeFunc({ + namespace, + kind, + name, + }); + + // Add on the external entity path to the url if one exists. This allows deep linking into another + // entities TechDocs. + const path = getEntityRootTechDocsPath(entity); + + return `${url}${path}`; +}; diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts index 5cbab1a326..6b0e228a27 100644 --- a/plugins/techdocs-react/src/index.ts +++ b/plugins/techdocs-react/src/index.ts @@ -52,4 +52,9 @@ export { useShadowRootElements, useShadowRootSelection, } from './hooks'; -export { toLowercaseEntityRefMaybe } from './helpers'; +export { + toLowercaseEntityRefMaybe, + getEntityRootTechDocsPath, + buildTechDocsURL, +} from './helpers'; +export type { TechDocsRouteFunc } from './helpers'; diff --git a/plugins/techdocs/src/EntityPageDocs.tsx b/plugins/techdocs/src/EntityPageDocs.tsx index a88ec8275d..14dcb00d29 100644 --- a/plugins/techdocs/src/EntityPageDocs.tsx +++ b/plugins/techdocs/src/EntityPageDocs.tsx @@ -20,12 +20,12 @@ import { parseEntityRef, } from '@backstage/catalog-model'; import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common'; +import { getEntityRootTechDocsPath } from '@backstage/plugin-techdocs-react'; import { TechDocsReaderPage } from './plugin'; import { TechDocsReaderPageContent } from './reader/components/TechDocsReaderPageContent'; import { TechDocsReaderPageSubheader } from './reader/components/TechDocsReaderPageSubheader'; import { useEntityPageTechDocsRedirect } from './search/hooks/useTechDocsLocation'; -import { getEntityRootTechDocsPath } from './helpers'; type EntityPageDocsProps = { entity: Entity; diff --git a/plugins/techdocs/src/helpers.ts b/plugins/techdocs/src/helpers.ts index 4b525445d7..7ff4dec6e7 100644 --- a/plugins/techdocs/src/helpers.ts +++ b/plugins/techdocs/src/helpers.ts @@ -14,23 +14,7 @@ * limitations under the License. */ -import { - DEFAULT_NAMESPACE, - Entity, - parseEntityRef, -} from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { - TECHDOCS_EXTERNAL_ANNOTATION, - TECHDOCS_EXTERNAL_PATH_ANNOTATION, -} from '@backstage/plugin-techdocs-common'; -import { RouteFunc } from '@backstage/core-plugin-api'; - -export type TechDocsRouteFunc = RouteFunc<{ - namespace: string; - kind: string; - name: string; -}>; // Lower-case entity triplets by default, but allow override. export function toLowerMaybe(str: string, config: Config) { @@ -40,52 +24,3 @@ export function toLowerMaybe(str: string, config: Config) { ? str : str.toLocaleLowerCase('en-US'); } - -export function getEntityRootTechDocsPath(entity: Entity): string { - let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION]; - if (!path) { - return ''; - } - if (!path.startsWith('/')) { - path = `/${path}`; - } - return path; -} - -export const buildTechDocsURL = ( - entity: Entity, - routeFunc: TechDocsRouteFunc | undefined, -) => { - if (!routeFunc) { - return undefined; - } - - let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE; - let kind = entity.kind; - let name = entity.metadata.name; - - if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { - try { - const techdocsRef = parseEntityRef( - entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION], - ); - namespace = techdocsRef.namespace; - kind = techdocsRef.kind; - name = techdocsRef.name; - } catch { - // not a fan of this but we don't care if the parseEntityRef fails - } - } - - const url = routeFunc({ - namespace, - kind, - name, - }); - - // Add on the external entity path to the url if one exists. This allows deep linking into another - // entities TechDocs. - const path = getEntityRootTechDocsPath(entity); - - return `${url}${path}`; -}; diff --git a/plugins/techdocs/src/index.ts b/plugins/techdocs/src/index.ts index e7cefa6437..713efc17c6 100644 --- a/plugins/techdocs/src/index.ts +++ b/plugins/techdocs/src/index.ts @@ -46,7 +46,6 @@ export { LegacyEmbeddedDocsRouter as EmbeddedDocsRouter, Router, } from './Router'; -export { buildTechDocsURL, getEntityRootTechDocsPath } from './helpers'; export type { TechDocsSearchResultListItemProps } from './search/components/TechDocsSearchResultListItem'; @@ -70,5 +69,3 @@ export type { }; export * from './overridableComponents'; - -export type { TechDocsRouteFunc } from './helpers'; diff --git a/yarn.lock b/yarn.lock index 7e61c3ca48..c54f62e4b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6355,7 +6355,7 @@ __metadata: "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^" - "@backstage/plugin-techdocs": "workspace:^" + "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -8504,6 +8504,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-techdocs-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/version-bridge": "workspace:^" From 3dfede520ad21d4376fe4048faf001c5d9725636 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Mon, 28 Apr 2025 14:07:39 -0400 Subject: [PATCH 03/49] update comments and api reports Signed-off-by: Chris Suich --- plugins/techdocs-common/report.api.md | 4 ++++ plugins/techdocs-react/report.api.md | 17 +++++++++++++++++ plugins/techdocs-react/src/helpers.ts | 17 +++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/plugins/techdocs-common/report.api.md b/plugins/techdocs-common/report.api.md index c7e1829a28..56a86778a8 100644 --- a/plugins/techdocs-common/report.api.md +++ b/plugins/techdocs-common/report.api.md @@ -8,4 +8,8 @@ export const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; // @public (undocumented) export const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity'; + +// @public (undocumented) +export const TECHDOCS_EXTERNAL_PATH_ANNOTATION = + 'backstage.io/techdocs-entity-path'; ``` diff --git a/plugins/techdocs-react/report.api.md b/plugins/techdocs-react/report.api.md index 23a9c9faaf..fd8de5803f 100644 --- a/plugins/techdocs-react/report.api.md +++ b/plugins/techdocs-react/report.api.md @@ -15,8 +15,15 @@ import { JSX as JSX_2 } from 'react/jsx-runtime'; import { MemoExoticComponent } from 'react'; import { PropsWithChildren } from 'react'; import { ReactNode } from 'react'; +import { RouteFunc } from '@backstage/core-plugin-api'; import { SetStateAction } from 'react'; +// @public +export const buildTechDocsURL: ( + entity: Entity, + routeFunc: TechDocsRouteFunc | undefined, +) => string | undefined; + // @public export function createTechDocsAddonExtension( options: TechDocsAddonOptions, @@ -27,6 +34,9 @@ export function createTechDocsAddonExtension( options: TechDocsAddonOptions, ): Extension<(props: TComponentProps) => JSX.Element | null>; +// @public +export function getEntityRootTechDocsPath(entity: Entity): string; + // @public export const SHADOW_DOM_STYLE_LOAD_EVENT = 'TECH_DOCS_SHADOW_DOM_STYLE_LOAD'; @@ -122,6 +132,13 @@ export type TechDocsReaderPageValue = { onReady?: () => void; }; +// @public +export type TechDocsRouteFunc = RouteFunc<{ + namespace: string; + kind: string; + name: string; +}>; + // @public export const TechDocsShadowDom: ( props: TechDocsShadowDomProps, diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts index 8e3a6b5e7d..7c3ca71553 100644 --- a/plugins/techdocs-react/src/helpers.ts +++ b/plugins/techdocs-react/src/helpers.ts @@ -27,6 +27,12 @@ import { } from '@backstage/plugin-techdocs-common'; import { RouteFunc } from '@backstage/core-plugin-api'; +/** + * The RouteFunc used by buildTechDocsURL() to build the TechDocs link for + * an entity. + * + * @public + */ export type TechDocsRouteFunc = RouteFunc<{ namespace: string; kind: string; @@ -52,6 +58,11 @@ export function toLowercaseEntityRefMaybe( return entityRef; } +/** + * Get the entity path annotation from the given entity and ensure it starts with a slash. + * + * @public + */ export function getEntityRootTechDocsPath(entity: Entity): string { let path = entity.metadata.annotations?.[TECHDOCS_EXTERNAL_PATH_ANNOTATION]; if (!path) { @@ -63,6 +74,12 @@ export function getEntityRootTechDocsPath(entity: Entity): string { return path; } +/** + * Build the TechDocs URL for the given entity. This helper should be used anywhere there + * is a link to an entities TechDocs. + * + * @public + */ export const buildTechDocsURL = ( entity: Entity, routeFunc: TechDocsRouteFunc | undefined, From 04fd4d2a0ce0f24bcda14993c2b0708ca59032c8 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Mon, 28 Apr 2025 14:21:35 -0400 Subject: [PATCH 04/49] update techdocs api report Signed-off-by: Chris Suich --- plugins/techdocs/report.api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/techdocs/report.api.md b/plugins/techdocs/report.api.md index bb7ff9e5a5..2369466674 100644 --- a/plugins/techdocs/report.api.md +++ b/plugins/techdocs/report.api.md @@ -431,6 +431,7 @@ export const TechDocsReaderPageContent: ( // @public export type TechDocsReaderPageContentProps = { entityRef?: CompoundEntityRef; + defaultPath?: string; withSearch?: boolean; searchResultUrlMapper?: (url: string) => string; onReady?: () => void; From 2314c507be97d1731e72058ba81034039643a500 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 5 May 2025 10:50:10 -0400 Subject: [PATCH 05/49] Apply suggestions from code review Co-authored-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com> Signed-off-by: Chris --- .changeset/smooth-eels-think.md | 2 +- docs/features/software-catalog/well-known-annotations.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/smooth-eels-think.md b/.changeset/smooth-eels-think.md index ddae6dc151..c5e2fe0c85 100644 --- a/.changeset/smooth-eels-think.md +++ b/.changeset/smooth-eels-think.md @@ -5,4 +5,4 @@ '@backstage/plugin-techdocs-common': patch --- -Introduced "backstage.io/techdocs-entity-path" annotation which allows deep linking into another entities TechDocs in conjunction with "backstage.io/techdocs-entity". +Introduced `backstage.io/techdocs-entity-path` annotation which allows deep linking into another entities TechDocs in conjunction with `backstage.io/techdocs-entity`. diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 4e2801197f..42cefd3adb 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -125,7 +125,7 @@ metadata: backstage.io/techdocs-entity-path: /path/to/this/component ``` -The value of this annotation informs of the path to this components TechDocs within an external entity that owns the TechDocs. +The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs. In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of another entity, not just linking to the root of another entities TechDocs. From 992da5cbdcfbaf3ce82bde326f9905b43b241a3c Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Mon, 5 May 2025 10:54:20 -0400 Subject: [PATCH 06/49] update docs to indicate this works standalone Signed-off-by: Chris Suich --- docs/features/techdocs/how-to-guides.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 2ebda55de2..7d8c99ffda 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -942,10 +942,10 @@ metadata: backstage.io/techdocs-entity: system:default/example ``` -### Deep linking into another components TechDocs +### Deep linking into TechDocs -In addition to linking to another component's TechDocs the `backstage.io/techdocs-entity-path` annotation can be used to link to a -specific page within another component's TechDocs. +The `backstage.io/techdocs-entity-path` annotation can be use to deep link into a specific page within the components TechDocs. +This can be used in conjunction with `backstage.io/techdocs-entity` or standalone. ```yaml apiVersion: backstage.io/v1alpha1 From d8fea3377b60e6c15afd4836cc8c197c97f182d5 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Wed, 7 May 2025 10:33:19 -0400 Subject: [PATCH 07/49] use constants in about card Signed-off-by: Chris Suich --- plugins/catalog/package.json | 1 + plugins/catalog/src/components/AboutCard/AboutCard.tsx | 8 ++++---- yarn.lock | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 2f8ea66134..eab74b74e0 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -72,6 +72,7 @@ "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/plugin-search-common": "workspace:^", "@backstage/plugin-search-react": "workspace:^", + "@backstage/plugin-techdocs-common": "workspace:^", "@backstage/plugin-techdocs-react": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 46bc239ffa..88933b2a66 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -65,10 +65,10 @@ import { usePermission } from '@backstage/plugin-permission-react'; import { catalogTranslationRef } from '../../alpha/translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { buildTechDocsURL } from '@backstage/plugin-techdocs-react'; - -const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; - -const TECHDOCS_EXTERNAL_ANNOTATION = 'backstage.io/techdocs-entity'; +import { + TECHDOCS_ANNOTATION, + TECHDOCS_EXTERNAL_ANNOTATION, +} from '@backstage/plugin-techdocs-common'; const useStyles = makeStyles({ gridItemCard: { diff --git a/yarn.lock b/yarn.lock index c54f62e4b6..7decf32970 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6355,6 +6355,7 @@ __metadata: "@backstage/plugin-scaffolder-common": "workspace:^" "@backstage/plugin-search-common": "workspace:^" "@backstage/plugin-search-react": "workspace:^" + "@backstage/plugin-techdocs-common": "workspace:^" "@backstage/plugin-techdocs-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" From 5c81e9e16593aafb7582cedfee847445c9c8ef32 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Wed, 7 May 2025 10:34:07 -0400 Subject: [PATCH 08/49] use getCompoundEntityRef Signed-off-by: Chris Suich --- plugins/techdocs-react/src/helpers.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts index 7c3ca71553..8c4c9ee8f1 100644 --- a/plugins/techdocs-react/src/helpers.ts +++ b/plugins/techdocs-react/src/helpers.ts @@ -17,8 +17,8 @@ import { Config } from '@backstage/config'; import { CompoundEntityRef, - DEFAULT_NAMESPACE, Entity, + getCompoundEntityRef, parseEntityRef, } from '@backstage/catalog-model'; import { @@ -88,9 +88,7 @@ export const buildTechDocsURL = ( return undefined; } - let namespace = entity.metadata.namespace || DEFAULT_NAMESPACE; - let kind = entity.kind; - let name = entity.metadata.name; + let { namespace, kind, name } = getCompoundEntityRef(entity); if (entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) { try { From 4654a78dc1e9c415d5de25f3a9d31039c3da8c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 14 May 2025 15:44:02 +0200 Subject: [PATCH 09/49] update refresh_state_references.id to big int MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/tough-heads-knock.md | 5 ++ ...refresh_state_references_big_increments.js | 53 ++++++++++++ plugins/catalog-backend/report.sql.md | 14 +-- .../database/DefaultProviderDatabase.test.ts | 16 ++-- .../src/tests/migrations.test.ts | 86 +++++++++++++++++++ 5 files changed, 161 insertions(+), 13 deletions(-) create mode 100644 .changeset/tough-heads-knock.md create mode 100644 plugins/catalog-backend/migrations/20250514000000_refresh_state_references_big_increments.js diff --git a/.changeset/tough-heads-knock.md b/.changeset/tough-heads-knock.md new file mode 100644 index 0000000000..da2e2c1163 --- /dev/null +++ b/.changeset/tough-heads-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Update `refresh_state_references.id` to be a big int diff --git a/plugins/catalog-backend/migrations/20250514000000_refresh_state_references_big_increments.js b/plugins/catalog-backend/migrations/20250514000000_refresh_state_references_big_increments.js new file mode 100644 index 0000000000..780f2f34a2 --- /dev/null +++ b/plugins/catalog-backend/migrations/20250514000000_refresh_state_references_big_increments.js @@ -0,0 +1,53 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @param {import('knex').Knex} knex + * @returns {Promise} + */ +exports.up = async function up(knex) { + if (knex.client.config.client.includes('pg')) { + await knex.schema.raw( + `ALTER TABLE refresh_state_references ALTER COLUMN id TYPE bigint;`, + ); + await knex.schema.raw( + `ALTER SEQUENCE refresh_state_references_id_seq AS bigint MAXVALUE 9223372036854775807;`, + ); + } else if (knex.client.config.client.includes('mysql')) { + await knex.schema.raw( + `ALTER TABLE refresh_state_references MODIFY id bigint AUTO_INCREMENT;`, + ); + } +}; + +/** + * @param {import('knex').Knex} knex + * @returns {Promise} + */ +exports.down = async function down(knex) { + if (knex.client.config.client.includes('pg')) { + await knex.schema.raw( + `ALTER SEQUENCE refresh_state_references_id_seq AS integer MAXVALUE 2147483647;`, + ); + await knex.schema.raw( + `ALTER TABLE refresh_state_references ALTER COLUMN id TYPE integer;`, + ); + } else if (knex.client.config.client.includes('mysql')) { + await knex.schema.raw( + `ALTER TABLE refresh_state_references MODIFY id integer AUTO_INCREMENT;`, + ); + } +}; diff --git a/plugins/catalog-backend/report.sql.md b/plugins/catalog-backend/report.sql.md index 3402b05063..4f76d2a3f5 100644 --- a/plugins/catalog-backend/report.sql.md +++ b/plugins/catalog-backend/report.sql.md @@ -8,7 +8,7 @@ ## Sequences - `location_update_log_id_seq` (bigint) -- `refresh_state_references_id_seq` (integer) +- `refresh_state_references_id_seq` (bigint) ## Table `final_entities` @@ -93,12 +93,12 @@ ## Table `refresh_state_references` -| Column | Type | Nullable | Max Length | Default | -| ------------------- | --------- | -------- | ---------- | ------------------------------------------------------ | -| `id` | `integer` | false | - | `nextval('refresh_state_references_id_seq'::regclass)` | -| `source_entity_ref` | `text` | true | - | - | -| `source_key` | `text` | true | - | - | -| `target_entity_ref` | `text` | false | - | - | +| Column | Type | Nullable | Max Length | Default | +| ------------------- | -------- | -------- | ---------- | ------------------------------------------------------ | +| `id` | `bigint` | false | - | `nextval('refresh_state_references_id_seq'::regclass)` | +| `source_entity_ref` | `text` | true | - | - | +| `source_key` | `text` | true | - | - | +| `target_entity_ref` | `text` | false | - | - | ### Indices diff --git a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts index 383e06100f..d9465b27a6 100644 --- a/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts +++ b/plugins/catalog-backend/src/database/DefaultProviderDatabase.test.ts @@ -610,16 +610,18 @@ describe('DefaultProviderDatabase', () => { ); let references = await knex( 'refresh_state_references', - ).select(); + ) + .select() + .orderBy('id'); expect(references).toEqual([ { - id: 1, + id: expect.anything(), source_key: 'lols', source_entity_ref: null, target_entity_ref: 'component:default/a', }, { - id: 2, + id: expect.anything(), source_key: 'lols', source_entity_ref: null, target_entity_ref: 'component:default/b', @@ -670,16 +672,18 @@ describe('DefaultProviderDatabase', () => { ); references = await knex( 'refresh_state_references', - ).select(); + ) + .select() + .orderBy('id'); expect(references).toEqual([ { - id: 2, + id: expect.anything(), source_key: 'lols', source_entity_ref: null, target_entity_ref: 'component:default/b', }, { - id: 3, + id: expect.anything(), source_key: 'lols', source_entity_ref: null, target_entity_ref: 'component:default/a', diff --git a/plugins/catalog-backend/src/tests/migrations.test.ts b/plugins/catalog-backend/src/tests/migrations.test.ts index f58b174f93..e2a86c5c71 100644 --- a/plugins/catalog-backend/src/tests/migrations.test.ts +++ b/plugins/catalog-backend/src/tests/migrations.test.ts @@ -521,6 +521,7 @@ describe('migrations', () => { await knex.destroy(); }, ); + it.each(databases.eachSupportedId())( '20250401200503_update_refresh_state_columns.js, %p', async databaseId => { @@ -604,4 +605,89 @@ describe('migrations', () => { await knex.destroy(); }, ); + + it.each(databases.eachSupportedId())( + '20250514000000_refresh_state_references_big_increments.js, %p', + async databaseId => { + const knex = await databases.init(databaseId); + + const read = async () => { + return await knex('refresh_state_references') + .orderBy('id') + .then(rs => rs.map(r => ({ ...r, id: String(r.id) }))); + }; + + // Run migrations up to just before the target migration + await migrateUntilBefore( + knex, + '20250514000000_refresh_state_references_big_increments.js', + ); + + await knex('refresh_state').insert({ + entity_id: 'a', + entity_ref: 'k:ns/a', + unprocessed_entity: '{}', + cache: '{}', + errors: '[]', + next_update_at: knex.fn.now(), + last_discovery_at: knex.fn.now(), + }); + await knex('refresh_state_references').insert({ + source_key: 'before', + target_entity_ref: 'k:ns/a', + }); + + await migrateUpOnce(knex); + + // can still insert with auto generated id in sequence + await knex('refresh_state_references').insert({ + source_key: 'after1', + target_entity_ref: 'k:ns/a', + }); + await expect(read()).resolves.toEqual([ + { + id: '1', + source_key: 'before', + source_entity_ref: null, + target_entity_ref: 'k:ns/a', + }, + { + id: '2', + source_key: 'after1', + source_entity_ref: null, + target_entity_ref: 'k:ns/a', + }, + ]); + + await migrateDownOnce(knex); + + // can still insert with auto generated id in sequence + await knex('refresh_state_references').insert({ + source_key: 'after2', + target_entity_ref: 'k:ns/a', + }); + await expect(read()).resolves.toEqual([ + { + id: '1', + source_key: 'before', + source_entity_ref: null, + target_entity_ref: 'k:ns/a', + }, + { + id: '2', + source_key: 'after1', + source_entity_ref: null, + target_entity_ref: 'k:ns/a', + }, + { + id: '3', + source_key: 'after2', + source_entity_ref: null, + target_entity_ref: 'k:ns/a', + }, + ]); + + await knex.destroy(); + }, + ); }); From 898a4ef8c7f2879d9ecacead028132909462ab27 Mon Sep 17 00:00:00 2001 From: mbruhin <47482924+mbruhin@users.noreply.github.com> Date: Wed, 14 May 2025 10:22:08 -0600 Subject: [PATCH 10/49] update filter and consolidate logic Signed-off-by: mbruhin <47482924+mbruhin@users.noreply.github.com> --- .../src/actions/bitbucketCloudPullRequest.ts | 5 ++--- .../src/actions/bitbucketServerPullRequest.ts | 5 ++--- plugins/scaffolder-node/src/actions/index.ts | 2 +- .../scaffolder-node/src/actions/util.test.ts | 22 +++++++++++++++++++ plugins/scaffolder-node/src/actions/util.ts | 9 ++++++++ 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts index eba9c74a6a..5a30df24f9 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -23,6 +23,7 @@ import { addFiles, cloneRepo, parseRepoUrl, + filterGitFiles, } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; import fs from 'fs-extra'; @@ -413,9 +414,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: { // copy files fs.cpSync(sourceDir, tempDir, { recursive: true, - filter: path => { - return !(path.indexOf('.git') > -1); - }, + filter: filterGitFiles, }); await addFiles({ diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts index 71c16beaa7..9176471913 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts @@ -26,6 +26,7 @@ import { addFiles, cloneRepo, parseRepoUrl, + filterGitFiles, } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; import fs from 'fs-extra'; @@ -453,9 +454,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { // copy files fs.cpSync(sourceDir, tempDir, { recursive: true, - filter: path => { - return !(path.indexOf('.git') > -1); - }, + filter: filterGitFiles, }); await addFiles({ diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index c1c8551c53..041d57328a 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -33,4 +33,4 @@ export { createBranch, cloneRepo, } from './gitHelpers'; -export { parseRepoUrl, getRepoSourceDirectory } from './util'; +export { parseRepoUrl, getRepoSourceDirectory, filterGitFiles } from './util'; diff --git a/plugins/scaffolder-node/src/actions/util.test.ts b/plugins/scaffolder-node/src/actions/util.test.ts index 4b1e787bbc..2e0a464304 100644 --- a/plugins/scaffolder-node/src/actions/util.test.ts +++ b/plugins/scaffolder-node/src/actions/util.test.ts @@ -244,4 +244,26 @@ describe('scaffolder action utils', () => { }); }); }); + + describe('filterGitFiles', () => { + it('should filter .git directory and its contents but keep other files', () => { + // Import the function to test + const { filterGitFiles } = require('./util'); + + // Should filter out .git directory + expect(filterGitFiles('.git')).toBe(false); + + // Should filter out .git directory in subdirectories + expect(filterGitFiles('subdir/.git')).toBe(false); + + // Should filter out files inside .git directory + expect(filterGitFiles('.git/config')).toBe(false); + expect(filterGitFiles('subdir/.git/config')).toBe(false); + + // Should keep .gitignore and other non-.git-directory files + expect(filterGitFiles('.gitignore')).toBe(true); + expect(filterGitFiles('src/components/GitHubIcon.js')).toBe(true); + expect(filterGitFiles('.github/workflows/ci.yml')).toBe(true); + }); + }); }); diff --git a/plugins/scaffolder-node/src/actions/util.ts b/plugins/scaffolder-node/src/actions/util.ts index 12f73b0c6c..da86befdc7 100644 --- a/plugins/scaffolder-node/src/actions/util.ts +++ b/plugins/scaffolder-node/src/actions/util.ts @@ -186,3 +186,12 @@ export const parseSchemas = ( outputSchema: action.schema.output, }; }; + +/** + * Filter function to exclude the .git directory and its contents + * while keeping other files like .gitignore + * @public + */ +export function filterGitFiles(path: string): boolean { + return !(path.endsWith('.git') || path.includes('.git/')); +} From 9c8ff0c42e72b09b7b4135b44300bbee87421cad Mon Sep 17 00:00:00 2001 From: mbruhin <47482924+mbruhin@users.noreply.github.com> Date: Wed, 14 May 2025 10:24:28 -0600 Subject: [PATCH 11/49] add changeset Signed-off-by: mbruhin <47482924+mbruhin@users.noreply.github.com> --- .changeset/honest-moles-bet.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/honest-moles-bet.md diff --git a/.changeset/honest-moles-bet.md b/.changeset/honest-moles-bet.md new file mode 100644 index 0000000000..22e0cb4533 --- /dev/null +++ b/.changeset/honest-moles-bet.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-server': patch +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +'@backstage/plugin-scaffolder-node': patch +--- + +Update pull request creation filter to include .gitignore files in the created pull request From 9aec88552697f09005924c6aace22bfccd9f2b1a Mon Sep 17 00:00:00 2001 From: mbruhin <47482924+mbruhin@users.noreply.github.com> Date: Wed, 14 May 2025 10:53:40 -0600 Subject: [PATCH 12/49] add API report Signed-off-by: mbruhin <47482924+mbruhin@users.noreply.github.com> --- plugins/scaffolder-node/report.api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index f2e8501527..c7cf681d33 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -286,6 +286,9 @@ export function fetchFile(options: { token?: string; }): Promise; +// @public +export function filterGitFiles(path: string): boolean; + // @public (undocumented) export const getRepoSourceDirectory: ( workspacePath: string, From 9e3868fbb08edee84eafeba6bd9cddf42480d65b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 May 2025 11:51:55 +0200 Subject: [PATCH 13/49] frontend-plugin-api: add support for declaring plugin info loaders Signed-off-by: Patrik Oldsberg --- .changeset/petite-candies-tan.md | 29 +++++++ .../src/convertLegacyPlugin.test.tsx | 2 + .../src/wiring/InternalFrontendPlugin.ts | 5 ++ packages/frontend-plugin-api/report.api.md | 26 ++++++ .../src/wiring/createFrontendPlugin.test.ts | 54 ++++++++++++ .../src/wiring/createFrontendPlugin.ts | 86 +++++++++++++++++++ .../frontend-plugin-api/src/wiring/index.ts | 2 + 7 files changed, 204 insertions(+) create mode 100644 .changeset/petite-candies-tan.md diff --git a/.changeset/petite-candies-tan.md b/.changeset/petite-candies-tan.md new file mode 100644 index 0000000000..507e348f0e --- /dev/null +++ b/.changeset/petite-candies-tan.md @@ -0,0 +1,29 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new optional `info` option to `createFrontendPlugin` that lets you provide a loaders for different sources of metadata information about the plugin. + +There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin.This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: '...', + info: { + packageJson: () => import('../package.json'), + }, +}); +``` + +The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful to add additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`. + +Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: '...', + info: { + manifest: () => import('../catalog-info.yaml'), + }, +}); +``` diff --git a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx index 68bb7cac3f..3f85e07ca5 100644 --- a/packages/core-compat-api/src/convertLegacyPlugin.test.tsx +++ b/packages/core-compat-api/src/convertLegacyPlugin.test.tsx @@ -41,6 +41,8 @@ describe('convertLegacyPlugin', () => { "featureFlags": [], "getExtension": [Function], "id": "test", + "info": [Function], + "infoOptions": undefined, "routes": {}, "toString": [Function], "version": "v1", diff --git a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts index a7be9614b6..af69370f76 100644 --- a/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts +++ b/packages/frontend-internal/src/wiring/InternalFrontendPlugin.ts @@ -19,6 +19,7 @@ import { FeatureFlagConfig, FrontendPlugin, } from '@backstage/frontend-plugin-api'; +import { JsonObject } from '@backstage/types'; import { OpaqueType } from '@internal/opaque'; export const OpaqueFrontendPlugin = OpaqueType.create<{ @@ -27,6 +28,10 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{ readonly version: 'v1'; readonly extensions: Extension[]; readonly featureFlags: FeatureFlagConfig[]; + readonly infoOptions?: { + packageJson?: () => Promise; + manifest?: () => Promise; + }; }; }>({ type: '@backstage/FrontendPlugin', diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index cd5d89401d..05a12184a3 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1322,14 +1322,38 @@ export interface FrontendPlugin< getExtension(id: TId): TExtensionMap[TId]; // (undocumented) readonly id: string; + info(): Promise; // (undocumented) readonly routes: TRoutes; // (undocumented) withOverrides(options: { extensions: Array; + info?: FrontendPluginInfoOptions; }): FrontendPlugin; } +// @public +export interface FrontendPluginInfo { + description?: string; + links?: Array<{ + title: string; + url: string; + }>; + ownerEntityRefs?: string[]; + packageName?: string; + version?: string; +} + +// @public +export type FrontendPluginInfoOptions = { + packageJson?: () => Promise< + { + name: string; + } & JsonObject + >; + manifest?: () => Promise; +}; + export { githubAuthApiRef }; export { gitlabAuthApiRef }; @@ -1514,6 +1538,8 @@ export interface PluginOptions< // (undocumented) featureFlags?: FeatureFlagConfig[]; // (undocumented) + info?: FrontendPluginInfoOptions; + // (undocumented) pluginId: TId; // (undocumented) routes?: TRoutes; diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts index 359f9da417..c97c60e89e 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.test.ts @@ -281,6 +281,60 @@ describe('createFrontendPlugin', () => { ).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3"); }); + describe('info', () => { + it('should support reading info from package.json', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { packageJson: () => Promise.resolve({ name: '@test/test' }) }, + }); + + await expect((plugin as any).infoOptions?.packageJson()).resolves.toEqual( + { name: '@test/test' }, + ); + }); + + it('should support reading info from actual package.json', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { packageJson: () => import('../../package.json') }, + }); + + await expect( + (plugin as any).infoOptions?.packageJson(), + ).resolves.toMatchObject({ name: '@backstage/frontend-plugin-api' }); + }); + + it('should support reading info from opaque manifest', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { manifest: () => Promise.resolve({ owner: 'me' }) }, + }); + + await expect((plugin as any).infoOptions?.manifest()).resolves.toEqual({ + owner: 'me', + }); + }); + + it('should throw when trying to load info without installing in an app', async () => { + await expect( + createFrontendPlugin({ + pluginId: 'test', + }).info(), + ).rejects.toThrow( + "Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app", + ); + + await expect( + createFrontendPlugin({ + pluginId: 'test', + info: { packageJson: () => Promise.resolve({ name: '@test/test' }) }, + }).info(), + ).rejects.toThrow( + "Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app", + ); + }); + }); + describe('overrides', () => { it('should return a plugin instance with the correct namespace', () => { const plugin = createFrontendPlugin({ diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index b7c530a0c0..13c39a37e9 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -25,6 +25,67 @@ import { } from './resolveExtensionDefinition'; import { AnyExternalRoutes, AnyRoutes, FeatureFlagConfig } from './types'; import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap'; +import { JsonObject } from '@backstage/types'; + +/** + * Information about the plugin. + * + * @public + * @remarks + * + * This interface is intended to be extended via [module + * augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) + * in order to add fields that are specific to each project. + * + * For example, one might add a `slackChannel` field that is read from the + * opaque manifest file. + * + * See the options for `createApp` for more information about how to + * customize the parsing of manifest files. + */ +export interface FrontendPluginInfo { + /** + * The name of the package that implements the plugin. + */ + packageName?: string; + + /** + * The version of the plugin, typically the version of the package.json file. + */ + version?: string; + + /** + * As short description of the plugin, typically the description field in + * package.json. + */ + description?: string; + + /** + * The owner entity references of the plugin. + */ + ownerEntityRefs?: string[]; + + /** + * Links related to the plugin. + */ + links?: Array<{ title: string; url: string }>; +} + +/** + * Options for providing information for a plugin. + * + * @public + */ +export type FrontendPluginInfoOptions = { + /** + * A loader function for the package.json file for the plugin. + */ + packageJson?: () => Promise<{ name: string } & JsonObject>; + /** + * A loader function for an opaque manifest file for the plugin. + */ + manifest?: () => Promise; +}; /** @public */ export interface FrontendPlugin< @@ -38,9 +99,21 @@ export interface FrontendPlugin< readonly id: string; readonly routes: TRoutes; readonly externalRoutes: TExternalRoutes; + + /** + * Loads the plugin info. + */ + info(): Promise; getExtension(id: TId): TExtensionMap[TId]; withOverrides(options: { extensions: Array; + + /** + * Overrides to merge with the original plugin info. The merging is done for + * each top-level field. Setting a field explicitly to `undefined` will + * remove it. + */ + info?: FrontendPluginInfoOptions; }): FrontendPlugin; } @@ -56,6 +129,7 @@ export interface PluginOptions< externalRoutes?: TExternalRoutes; extensions?: TExtensions; featureFlags?: FeatureFlagConfig[]; + info?: FrontendPluginInfoOptions; } /** @public */ @@ -150,6 +224,14 @@ export function createFrontendPlugin< externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes), featureFlags: options.featureFlags ?? [], extensions: extensions, + infoOptions: options.info, + + // This method is overridden when the plugin instance is installed in an app + async info() { + throw new Error( + `Attempted to load plugin info for plugin '${pluginId}', but the plugin instance is not installed in an app`, + ); + }, getExtension(id) { const ext = extensionDefinitionsById.get(id); if (!ext) { @@ -178,6 +260,10 @@ export function createFrontendPlugin< ...options, pluginId, extensions: [...nonOverriddenExtensions, ...overrides.extensions], + info: { + ...options.info, + ...overrides.info, + }, }); }, }); diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index b584fc9769..24a4e1653f 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -40,6 +40,8 @@ export { createFrontendPlugin, type FrontendPlugin, type PluginOptions, + type FrontendPluginInfo, + type FrontendPluginInfoOptions, } from './createFrontendPlugin'; export { createFrontendModule, From 18c64e9bd4153308931defb77fa6c657cb08401d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 14 May 2025 15:03:48 +0200 Subject: [PATCH 14/49] plugins: add info.packageJson loader for all plugins Signed-off-by: Patrik Oldsberg --- .changeset/evil-cooks-watch.md | 21 +++++++++++++++++++ .changeset/petite-candies-tan.md | 2 +- plugins/api-docs/src/alpha.tsx | 1 + plugins/app-visualizer/src/plugin.tsx | 1 + plugins/app/src/plugin.ts | 1 + plugins/catalog-graph/src/alpha.tsx | 1 + plugins/catalog-import/src/alpha.tsx | 1 + .../src/alpha/plugin.tsx | 1 + plugins/catalog/src/alpha/plugin.tsx | 1 + plugins/devtools/src/alpha/plugin.tsx | 1 + plugins/home/src/alpha.tsx | 1 + plugins/kubernetes/src/alpha/plugin.tsx | 1 + plugins/notifications/src/alpha.tsx | 1 + plugins/org/src/alpha.tsx | 1 + plugins/scaffolder/src/alpha/plugin.tsx | 1 + plugins/search/src/alpha.tsx | 1 + plugins/signals/src/alpha.tsx | 1 + plugins/techdocs/src/alpha.tsx | 1 + plugins/user-settings/src/alpha.tsx | 1 + 19 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 .changeset/evil-cooks-watch.md diff --git a/.changeset/evil-cooks-watch.md b/.changeset/evil-cooks-watch.md new file mode 100644 index 0000000000..0982f24634 --- /dev/null +++ b/.changeset/evil-cooks-watch.md @@ -0,0 +1,21 @@ +--- +'@backstage/plugin-catalog-unprocessed-entities': patch +'@backstage/plugin-app-visualizer': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-notifications': patch +'@backstage/plugin-user-settings': patch +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-api-docs': patch +'@backstage/plugin-devtools': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-signals': patch +'@backstage/plugin-search': patch +'@backstage/plugin-home': patch +'@backstage/plugin-app': patch +'@backstage/plugin-org': patch +--- + +Added the `info.packageJson` option to the plugin instance for the new frontend system. diff --git a/.changeset/petite-candies-tan.md b/.changeset/petite-candies-tan.md index 507e348f0e..e5a65f8066 100644 --- a/.changeset/petite-candies-tan.md +++ b/.changeset/petite-candies-tan.md @@ -10,7 +10,7 @@ There are two available loaders. The first one is `info.packageJson`, which can export default createFrontendPlugin({ pluginId: '...', info: { - packageJson: () => import('../package.json'), + info: { packageJson: () => import('../package.json') }, }, }); ``` diff --git a/plugins/api-docs/src/alpha.tsx b/plugins/api-docs/src/alpha.tsx index cb55f620a7..356328a61d 100644 --- a/plugins/api-docs/src/alpha.tsx +++ b/plugins/api-docs/src/alpha.tsx @@ -228,6 +228,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({ export default createFrontendPlugin({ pluginId: 'api-docs', + info: { packageJson: () => import('../package.json') }, routes: { root: convertLegacyRouteRef(rootRoute), }, diff --git a/plugins/app-visualizer/src/plugin.tsx b/plugins/app-visualizer/src/plugin.tsx index 92ccd00159..faaf2a5788 100644 --- a/plugins/app-visualizer/src/plugin.tsx +++ b/plugins/app-visualizer/src/plugin.tsx @@ -46,5 +46,6 @@ export const appVisualizerNavItem = NavItemBlueprint.make({ /** @public */ export const visualizerPlugin = createFrontendPlugin({ pluginId: 'app-visualizer', + info: { packageJson: () => import('../package.json') }, extensions: [appVisualizerPage, appVisualizerNavItem], }); diff --git a/plugins/app/src/plugin.ts b/plugins/app/src/plugin.ts index b166423fa1..a1e6602410 100644 --- a/plugins/app/src/plugin.ts +++ b/plugins/app/src/plugin.ts @@ -42,6 +42,7 @@ import { apis } from './defaultApis'; /** @public */ export const appPlugin = createFrontendPlugin({ pluginId: 'app', + info: { packageJson: () => import('../package.json') }, extensions: [ ...apis, App, diff --git a/plugins/catalog-graph/src/alpha.tsx b/plugins/catalog-graph/src/alpha.tsx index d70232e92a..aa1f6a05cf 100644 --- a/plugins/catalog-graph/src/alpha.tsx +++ b/plugins/catalog-graph/src/alpha.tsx @@ -87,6 +87,7 @@ const CatalogGraphPage = PageBlueprint.makeWithOverrides({ export default createFrontendPlugin({ pluginId: 'catalog-graph', + info: { packageJson: () => import('../package.json') }, routes: { catalogGraph: convertLegacyRouteRef(catalogGraphRouteRef), }, diff --git a/plugins/catalog-import/src/alpha.tsx b/plugins/catalog-import/src/alpha.tsx index 3fade2d92b..17260acc14 100644 --- a/plugins/catalog-import/src/alpha.tsx +++ b/plugins/catalog-import/src/alpha.tsx @@ -87,6 +87,7 @@ const catalogImportApi = ApiBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog-import', + info: { packageJson: () => import('../package.json') }, extensions: [catalogImportApi, catalogImportPage], routes: { importPage: convertLegacyRouteRef(rootRouteRef), diff --git a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx index 8ff83605c7..34a40b23d7 100644 --- a/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx +++ b/plugins/catalog-unprocessed-entities/src/alpha/plugin.tsx @@ -74,6 +74,7 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog-unprocessed-entities', + info: { packageJson: () => import('../../package.json') }, routes: { root: convertLegacyRouteRef(rootRouteRef), }, diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 6fd171ed54..c5dad31cf0 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -39,6 +39,7 @@ import contextMenuItems from './contextMenuItems'; /** @alpha */ export default createFrontendPlugin({ pluginId: 'catalog', + info: { packageJson: () => import('../../package.json') }, routes: convertLegacyRouteRefs({ catalogIndex: rootRouteRef, catalogEntity: entityRouteRef, diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx index 959670f3a5..52caaf4786 100644 --- a/plugins/devtools/src/alpha/plugin.tsx +++ b/plugins/devtools/src/alpha/plugin.tsx @@ -71,6 +71,7 @@ export const devToolsNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'devtools', + info: { packageJson: () => import('../../package.json') }, routes: { root: convertLegacyRouteRef(rootRouteRef), }, diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index 04d612ef1c..854a8a7dd3 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -68,6 +68,7 @@ const homePage = PageBlueprint.makeWithOverrides({ */ export default createFrontendPlugin({ pluginId: 'home', + info: { packageJson: () => import('../package.json') }, extensions: [homePage], routes: { root: rootRouteRef, diff --git a/plugins/kubernetes/src/alpha/plugin.tsx b/plugins/kubernetes/src/alpha/plugin.tsx index 35cb9e037d..db7b2439bf 100644 --- a/plugins/kubernetes/src/alpha/plugin.tsx +++ b/plugins/kubernetes/src/alpha/plugin.tsx @@ -28,6 +28,7 @@ import { export default createFrontendPlugin({ pluginId: 'kubernetes', + info: { packageJson: () => import('../../package.json') }, extensions: [ kubernetesPage, entityKubernetesContent, diff --git a/plugins/notifications/src/alpha.tsx b/plugins/notifications/src/alpha.tsx index d7c4ec27b6..4424659593 100644 --- a/plugins/notifications/src/alpha.tsx +++ b/plugins/notifications/src/alpha.tsx @@ -54,6 +54,7 @@ const api = ApiBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'notifications', + info: { packageJson: () => import('../package.json') }, routes: convertLegacyRouteRefs({ root: rootRouteRef, }), diff --git a/plugins/org/src/alpha.tsx b/plugins/org/src/alpha.tsx index beb4f573af..d762e8c270 100644 --- a/plugins/org/src/alpha.tsx +++ b/plugins/org/src/alpha.tsx @@ -73,6 +73,7 @@ const EntityUserProfileCard = EntityCardBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'org', + info: { packageJson: () => import('../package.json') }, extensions: [ EntityGroupProfileCard, EntityMembersListCard, diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index aa37f80493..ebad851901 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -39,6 +39,7 @@ import { formDecoratorsApi } from './api'; /** @alpha */ export default createFrontendPlugin({ pluginId: 'scaffolder', + info: { packageJson: () => import('../../package.json') }, routes: convertLegacyRouteRefs({ root: rootRouteRef, selectedTemplate: selectedTemplateRouteRef, diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx index 5f55b46028..136f95f39e 100644 --- a/plugins/search/src/alpha.tsx +++ b/plugins/search/src/alpha.tsx @@ -279,6 +279,7 @@ export const searchNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'search', + info: { packageJson: () => import('../package.json') }, extensions: [searchApi, searchPage, searchNavItem], routes: convertLegacyRouteRefs({ root: rootRouteRef, diff --git a/plugins/signals/src/alpha.tsx b/plugins/signals/src/alpha.tsx index ba7f72f420..90b78b05fd 100644 --- a/plugins/signals/src/alpha.tsx +++ b/plugins/signals/src/alpha.tsx @@ -45,5 +45,6 @@ const api = ApiBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'signals', + info: { packageJson: () => import('../package.json') }, extensions: [api], }); diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index b2bf794e73..21b594c82d 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -236,6 +236,7 @@ const techDocsNavItem = NavItemBlueprint.make({ /** @alpha */ export default createFrontendPlugin({ pluginId: 'techdocs', + info: { packageJson: () => import('../package.json') }, extensions: [ techDocsClientApi, techDocsStorageApi, diff --git a/plugins/user-settings/src/alpha.tsx b/plugins/user-settings/src/alpha.tsx index 228c6a23d2..f351dad40a 100644 --- a/plugins/user-settings/src/alpha.tsx +++ b/plugins/user-settings/src/alpha.tsx @@ -69,6 +69,7 @@ export const settingsNavItem = NavItemBlueprint.make({ */ export default createFrontendPlugin({ pluginId: 'user-settings', + info: { packageJson: () => import('../package.json') }, extensions: [userSettingsPage, settingsNavItem], routes: convertLegacyRouteRefs({ root: settingsRouteRef, From c38c9e816925f793ed1cb8b8c80d6192962182f2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:05:33 +0200 Subject: [PATCH 15/49] frontend-app-api: add support for plugin info resolution and overrides Signed-off-by: Patrik Oldsberg --- .changeset/stupid-flies-speak.md | 5 + packages/frontend-app-api/config.d.ts | 66 ++++ packages/frontend-app-api/report.api.md | 19 +- .../wiring/createPluginInfoAttacher.test.ts | 284 ++++++++++++++++++ .../src/wiring/createPluginInfoAttacher.ts | 247 +++++++++++++++ .../src/wiring/createSpecializedApp.test.tsx | 119 ++++++++ .../src/wiring/createSpecializedApp.tsx | 12 +- packages/frontend-app-api/src/wiring/index.ts | 1 + 8 files changed, 750 insertions(+), 3 deletions(-) create mode 100644 .changeset/stupid-flies-speak.md create mode 100644 packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts create mode 100644 packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts diff --git a/.changeset/stupid-flies-speak.md b/.changeset/stupid-flies-speak.md new file mode 100644 index 0000000000..cad07ae611 --- /dev/null +++ b/.changeset/stupid-flies-speak.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overriden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration. diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts index fc265e8833..3b86b5c1d4 100644 --- a/packages/frontend-app-api/config.d.ts +++ b/packages/frontend-app-api/config.d.ts @@ -51,5 +51,71 @@ export interface Config { }; } >; + + /** + * This section enables you to override certain properties of specific or + * groups of plugins. + * + * @remarks + * All matching entries will be applied to each plugin, with the later + * entries taking precedence. + * + * This configuration is intended to be used primarily to apply overrides + * for third-party plugins. + * + * @deepVisibility frontend + */ + pluginOverrides?: Array<{ + /** + * The criteria for matching plugins to override. + * + * @remarks + * If no match criteria are provided, the override will be applied to + * all plugins. + */ + match?: { + /** + * A pattern that is matched against the plugin ID. + * + * @remarks + * By default the string is interpreted as a glob pattern, but if the + * string is surrounded by '/' it is interpreted as a regex. + */ + pluginId?: string; + + /** + * A pattern that is matched against the package name. + * + * @remarks + * By default the string is interpreted as a glob pattern, but if the + * string is surrounded by '/' it is interpreted as a regex. + * + * Note that this will only work for plugins that provide a + * `package.json` info loader. + */ + packageName?: string; + }; + /** + * Overrides individual top-level fields of the plugin info. + */ + info: { + /** + * Override the description of the plugin. + */ + description?: string; + /** + * Override the owner entity references of the plugin. + * + * @remarks + * The provided values are interpreted as entity references defaulting + * to Group entities in the default namespace. + */ + ownerEntityRefs?: string[]; + /** + * Override the links of the plugin. + */ + links?: Array<{ title: string; url: string }>; + }; + }>; }; } diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index d2a3ac4d3b..7228777561 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -9,6 +9,8 @@ import { ConfigApi } from '@backstage/core-plugin-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api'; +import { FrontendPluginInfo } from '@backstage/frontend-plugin-api'; +import { JsonObject } from '@backstage/types'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SubRouteRef } from '@backstage/frontend-plugin-api'; @@ -27,7 +29,7 @@ export type CreateAppRouteBinder = < // @public export function createSpecializedApp(options?: { - features?: FrontendFeature[]; + features?: FrontendFeature_2[]; config?: ConfigApi; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; apis?: ApiHolder; @@ -37,6 +39,7 @@ export function createSpecializedApp(options?: { flags?: { allowUnknownExtensionConfig?: boolean; }; + pluginInfoResolver?: FrontendPluginInfoResolver; }): { apis: ApiHolder; tree: AppTree; @@ -44,4 +47,18 @@ export function createSpecializedApp(options?: { // @public @deprecated (undocumented) export type FrontendFeature = FrontendFeature_2; + +// @public +export type FrontendPluginInfoResolver = (ctx: { + packageJson(): Promise; + manifest(): Promise; + defaultResolver(sources: { + packageJson: JsonObject | undefined; + manifest: JsonObject | undefined; + }): Promise<{ + info: FrontendPluginInfo; + }>; +}) => Promise<{ + info: FrontendPluginInfo; +}>; ``` diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts new file mode 100644 index 0000000000..5595c04135 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts @@ -0,0 +1,284 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockApis } from '@backstage/test-utils'; +import { createPluginInfoAttacher } from './createPluginInfoAttacher'; +import { OpaqueFrontendPlugin } from '@internal/frontend'; +import { + createFrontendPlugin, + FrontendFeature, +} from '@backstage/frontend-plugin-api'; + +function getInfo(plugin: FrontendFeature) { + return OpaqueFrontendPlugin.toInternal(plugin).info(); +} + +describe('createPluginInfoAttacher', () => { + const mockConfig = mockApis.config({ + data: { + app: { + pluginOverrides: [ + { + match: { + pluginId: '/^.*-tester$/', + }, + info: { + description: 'Overridden description', + }, + }, + { + match: { + pluginId: '/^not-.*-tester$/', + }, + info: { + ownerEntityRefs: ['test-group'], + }, + }, + { + match: { + packageName: '@test/package', + }, + info: { + description: 'Package name matched', + }, + }, + { + match: { + pluginId: 'info-tester', + }, + info: { + links: [{ title: 'Custom Link', url: 'https://example.com' }], + }, + }, + ], + }, + }, + }); + + describe('with default resolver', () => { + const attacher = createPluginInfoAttacher(mockConfig); + + it('should return a new plugin instance', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + }); + + const newPlugin = attacher(plugin); + expect(newPlugin).not.toBe(plugin); + await expect(getInfo(newPlugin)).resolves.toEqual({}); + }); + + it('should return non-plugin features unchanged', () => { + const nonPluginFeature = { + type: 'not-a-plugin', + } as unknown as FrontendFeature; + + expect(attacher(nonPluginFeature)).toBe(nonPluginFeature); + }); + + it('should resolve plugin info from package.json and config overrides', async () => { + await expect( + getInfo( + attacher( + createFrontendPlugin({ + pluginId: 'other-tester', + info: { + packageJson: async () => ({ + name: '@test/package', + version: '1.0.0', + description: 'Original description', + homepage: 'https://homepage.com', + repository: { + url: 'https://github.com/test/project', + directory: 'packages/test', + }, + }), + }, + }), + ), + ), + ).resolves.toEqual({ + packageName: '@test/package', + version: '1.0.0', + description: 'Package name matched', + links: [ + { + title: 'Homepage', + url: 'https://homepage.com', + }, + { + title: 'Repository', + url: 'https://github.com/test/project/tree/master/packages/test', + }, + ], + }); + + await expect( + getInfo( + attacher( + createFrontendPlugin({ + pluginId: 'info-tester', + info: { + packageJson: async () => ({ + name: '@other/package', + description: 'Original description', + homepage: 'https://homepage.com', + }), + }, + }), + ), + ), + ).resolves.toEqual({ + packageName: '@other/package', + description: 'Overridden description', + links: [ + { + title: 'Custom Link', + url: 'https://example.com', + }, + ], + }); + + await expect( + getInfo( + attacher( + createFrontendPlugin({ + pluginId: 'not-info-tester', + info: { + packageJson: async () => ({ + name: '@other/package', + description: 'Original description', + repository: { + url: 'http://example.com', + directory: 'packages/test', + }, + }), + }, + }), + ), + ), + ).resolves.toEqual({ + packageName: '@other/package', + description: 'Overridden description', + ownerEntityRefs: ['group:default/test-group'], + links: [ + { + title: 'Repository', + url: 'http://example.com/', + }, + ], + }); + }); + }); + + describe('with custom resolver', () => { + const plugin = createFrontendPlugin({ + pluginId: 'custom-resolver', + info: { + packageJson: async () => ({ + name: '@test/resolver', + version: '1.0.0', + }), + manifest: async () => ({ + metadata: { + links: [{ title: 'Metadata link', url: 'https://example.com' }], + }, + }), + }, + }); + + it('should use the default resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, async ctx => + ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }), + ); + + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + packageName: '@test/resolver', + version: '1.0.0', + links: [ + { + title: 'Metadata link', + url: 'https://example.com', + }, + ], + }); + }); + + it('should override info sources passed to default resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, ctx => + ctx.defaultResolver({ + packageJson: { + name: '@test/resolver-other', + version: '2.0.0', + }, + manifest: { + metadata: { + links: [{ title: 'Other link', url: 'https://example.com' }], + }, + spec: { + owner: 'test-group', + }, + }, + }), + ); + + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + packageName: '@test/resolver-other', + version: '2.0.0', + links: [ + { + title: 'Other link', + url: 'https://example.com', + }, + ], + ownerEntityRefs: ['group:default/test-group'], + }); + }); + + it('should use a completely custom resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, async () => ({ + info: { version: '0.1.0' }, + })); + + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + version: '0.1.0', + }); + }); + + it('should handle unexpected input from the default resolver', async () => { + const attacher = createPluginInfoAttacher(mockConfig, ctx => + ctx.defaultResolver({ + packageJson: { + name: null, + version: {}, + }, + manifest: { + metadata: { + links: 'not an array', + }, + spec: [], + }, + }), + ); + await expect(getInfo(attacher(plugin))).resolves.toEqual({ + version: '[object Object]', + }); + }); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts new file mode 100644 index 0000000000..27a6ca73ac --- /dev/null +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -0,0 +1,247 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfigApi } from '@backstage/core-plugin-api'; +import { + FrontendFeature, + FrontendPluginInfo, +} from '@backstage/frontend-plugin-api'; +import { OpaqueFrontendPlugin } from '@internal/frontend'; +import { JsonObject, JsonValue } from '@backstage/types'; +import once from 'lodash/once'; +// Avoid full dependency on catalog-model +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + parseEntityRef, + stringifyEntityRef, +} from '../../../catalog-model/src/entity/ref'; + +/** + * A function that resolves plugin info from a plugin manifest and package.json. + * + * @public + */ +export type FrontendPluginInfoResolver = (ctx: { + packageJson(): Promise; + manifest(): Promise; + defaultResolver(sources: { + packageJson: JsonObject | undefined; + manifest: JsonObject | undefined; + }): Promise<{ info: FrontendPluginInfo }>; +}) => Promise<{ info: FrontendPluginInfo }>; + +export function createPluginInfoAttacher( + config: ConfigApi, + infoResolver: FrontendPluginInfoResolver = async ctx => + ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }), +): (feature: FrontendFeature) => FrontendFeature { + const applyInfoOverrides = createPluginInfoOverrider(config); + + return (feature: FrontendFeature) => { + if (!OpaqueFrontendPlugin.isType(feature)) { + return feature; + } + + const plugin = OpaqueFrontendPlugin.toInternal(feature); + + return { + ...plugin, + info: once(async () => { + const manifestLoader = plugin.infoOptions?.manifest; + const packageJsonLoader = plugin.infoOptions?.packageJson; + + const { info: resolvedInfo } = await infoResolver({ + manifest: async () => manifestLoader?.(), + packageJson: async () => packageJsonLoader?.(), + defaultResolver: async sources => ({ + info: { + ...resolvePackageInfo(sources.packageJson), + ...resolveManifestInfo(sources.manifest), + }, + }), + }); + + const infoWithOverrides = applyInfoOverrides(plugin.id, resolvedInfo); + return normalizePluginInfo(infoWithOverrides); + }), + }; + }; +} + +function normalizePluginInfo(info: FrontendPluginInfo) { + return { + ...info, + ownerEntityRefs: info.ownerEntityRefs?.map(ref => + stringifyEntityRef( + parseEntityRef(ref, { + defaultKind: 'group', + }), + ), + ), + }; +} + +function createPluginInfoOverrider(config: ConfigApi) { + const overrideConfigs = + config.getOptionalConfigArray('app.pluginOverrides') ?? []; + + const overrideMatchers = overrideConfigs.map(overrideConfig => { + const pluginIdMatcher = makeStringMatcher( + overrideConfig.getOptionalString('match.pluginId'), + ); + const packageNameMatcher = makeStringMatcher( + overrideConfig.getOptionalString('match.packageName'), + ); + const description = overrideConfig.getOptionalString('info.description'); + const ownerEntityRefs = overrideConfig.getOptionalStringArray( + 'info.ownerEntityRefs', + ); + const links = overrideConfig + .getOptionalConfigArray('info.links') + ?.map(linkConfig => ({ + title: linkConfig.getString('title'), + url: linkConfig.getString('url'), + })); + + return { + test(pluginId: string, packageName?: string) { + return packageNameMatcher(packageName) && pluginIdMatcher(pluginId); + }, + info: { + description, + ownerEntityRefs, + links, + }, + }; + }); + + return (pluginId: string, info: FrontendPluginInfo) => { + const { packageName } = info; + for (const matcher of overrideMatchers) { + if (matcher.test(pluginId, packageName)) { + if (matcher.info.description) { + info.description = matcher.info.description; + } + if (matcher.info.ownerEntityRefs) { + info.ownerEntityRefs = matcher.info.ownerEntityRefs; + } + if (matcher.info.links) { + info.links = matcher.info.links; + } + } + } + return info; + }; +} + +function resolveManifestInfo(manifest?: JsonValue) { + if (!isJsonObject(manifest) || !isJsonObject(manifest.metadata)) { + return undefined; + } + + const info: FrontendPluginInfo = {}; + + if (isJsonObject(manifest.spec) && typeof manifest.spec.owner === 'string') { + info.ownerEntityRefs = [ + stringifyEntityRef( + parseEntityRef(manifest.spec.owner, { + defaultKind: 'group', + defaultNamespace: manifest.metadata.namespace?.toString(), + }), + ), + ]; + } + + if (Array.isArray(manifest.metadata.links)) { + info.links = manifest.metadata.links.filter(isJsonObject).map(link => ({ + title: String(link.title), + url: String(link.url), + })); + } + + return info; +} + +function resolvePackageInfo(packageJson?: JsonObject) { + if (!packageJson) { + return undefined; + } + + const info: FrontendPluginInfo = { + packageName: packageJson?.name?.toString(), + version: packageJson?.version?.toString(), + description: packageJson?.description?.toString(), + }; + + const links: { title: string; url: string }[] = []; + + if (typeof packageJson.homepage === 'string') { + links.push({ + title: 'Homepage', + url: packageJson.homepage, + }); + } + + if ( + isJsonObject(packageJson.repository) && + typeof packageJson.repository?.url === 'string' + ) { + try { + const url = new URL(packageJson.repository?.url); + if (url.protocol === 'http:' || url.protocol === 'https:') { + // TODO(Rugvip): Support more variants + if ( + url.hostname === 'github.com' && + typeof packageJson.repository.directory === 'string' + ) { + const path = `${url.pathname}/tree/master/${packageJson.repository.directory}`; + url.pathname = path.replaceAll('//', '/'); + } + + links.push({ + title: 'Repository', + url: url.toString(), + }); + } + } catch { + /* ignored */ + } + } + + if (links.length > 0) { + info.links = links; + } + return info; +} + +function makeStringMatcher(pattern: string | undefined) { + if (!pattern) { + return () => true; + } + if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) { + const regex = new RegExp(pattern.slice(1, -1)); + return (str?: string) => (str ? regex.test(str) : false); + } + + return (str?: string) => str === pattern; +} + +function isJsonObject(value?: JsonValue): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx index 00551e16e3..7c6bb7958b 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.test.tsx @@ -671,4 +671,123 @@ describe('createSpecializedApp', () => { expect(render(root).container.textContent).toBe('1-2-test-1-2'); }); + + describe('plugin info', () => { + const testExtension = createExtension({ + attachTo: { id: 'root', input: 'app' }, + output: [coreExtensionData.reactElement], + factory: () => [coreExtensionData.reactElement(
Test
)], + }); + + it('should throw unless accessed via an app', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + extensions: [testExtension], + }); + + const errorMsg = + "Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app"; + await expect(plugin.info()).rejects.toThrow(errorMsg); + + const app = createSpecializedApp({ features: [plugin] }); + + await expect(plugin.info()).rejects.toThrow(errorMsg); + + const installedPlugin = app.tree.nodes.get('test')?.spec.source; + expect(installedPlugin).toBeDefined(); + const info = await installedPlugin?.info(); + expect(info).toEqual({}); + }); + + it('should forward plugin info', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + }, + extensions: [testExtension], + }); + + const app = createSpecializedApp({ features: [plugin] }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toMatchObject({ + packageName: '@backstage/frontend-app-api', + }); + }); + + it('should allow overriding plugin info per plugin', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + }, + extensions: [testExtension], + }); + + const overriddenPlugin = plugin.withOverrides({ + extensions: [], + info: { + packageJson: () => Promise.resolve({ name: 'test-override' }), + }, + }); + + const app = createSpecializedApp({ features: [overriddenPlugin] }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toMatchObject({ + packageName: 'test-override', + }); + }); + + it('should merge with plugin info from manifest', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + manifest: async () => ({ + metadata: { + links: [{ title: 'Example', url: 'https://example.com' }], + }, + spec: { + owner: 'cubic-belugas', + }, + }), + }, + extensions: [testExtension], + }); + + const app = createSpecializedApp({ features: [plugin] }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toEqual({ + packageName: '@backstage/frontend-app-api', + version: expect.any(String), + links: [{ title: 'Example', url: 'https://example.com' }], + ownerEntityRefs: ['group:default/cubic-belugas'], + }); + }); + + it('should allow overriding of the plugin info resolver', async () => { + const plugin = createFrontendPlugin({ + pluginId: 'test', + info: { + packageJson: () => import('../../package.json'), + }, + extensions: [testExtension], + }); + + const app = createSpecializedApp({ + features: [plugin], + async pluginInfoResolver(ctx) { + const { info } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: await ctx.manifest(), + }); + return { info: { packageName: `decorated:${info.packageName}` } }; + }, + }); + const info = await app.tree.nodes.get('test')?.spec.source?.info(); + expect(info).toEqual({ + packageName: 'decorated:@backstage/frontend-app-api', + }); + }); + }); }); diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 5f6692d600..51b0f14250 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -31,6 +31,7 @@ import { routeResolutionApiRef, AppNode, ExtensionFactoryMiddleware, + FrontendFeature, } from '@backstage/frontend-plugin-api'; import { AnyApiFactory, @@ -74,8 +75,12 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy'; import { BackstageRouteObject } from '../routing/types'; -import { FrontendFeature, RouteInfo } from './types'; +import { RouteInfo } from './types'; import { matchRoutes } from 'react-router-dom'; +import { + createPluginInfoAttacher, + FrontendPluginInfoResolver, +} from './createPluginInfoAttacher'; function deduplicateFeatures( allFeatures: FrontendFeature[], @@ -209,9 +214,12 @@ export function createSpecializedApp(options?: { | ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[]; flags?: { allowUnknownExtensionConfig?: boolean }; + pluginInfoResolver?: FrontendPluginInfoResolver; }): { apis: ApiHolder; tree: AppTree } { const config = options?.config ?? new ConfigReader({}, 'empty-config'); - const features = deduplicateFeatures(options?.features ?? []); + const features = deduplicateFeatures(options?.features ?? []).map( + createPluginInfoAttacher(config, options?.pluginInfoResolver), + ); const tree = resolveAppTree( 'root', diff --git a/packages/frontend-app-api/src/wiring/index.ts b/packages/frontend-app-api/src/wiring/index.ts index 4ecce3f184..fc52dcb7a1 100644 --- a/packages/frontend-app-api/src/wiring/index.ts +++ b/packages/frontend-app-api/src/wiring/index.ts @@ -15,4 +15,5 @@ */ export { createSpecializedApp } from './createSpecializedApp'; +export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher'; export * from './types'; From 6f48f718b046e8d143afa8cc4b94bbd469ec4a1d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:36:32 +0200 Subject: [PATCH 16/49] frontend-plugin-api: add useAppNode Signed-off-by: Patrik Oldsberg --- .changeset/puny-pillows-wave.md | 5 ++ packages/frontend-plugin-api/report.api.md | 3 + .../src/components/AppNodeProvider.test.tsx | 84 +++++++++++++++++++ .../src/components/AppNodeProvider.tsx | 74 ++++++++++++++++ .../src/components/ExtensionBoundary.tsx | 21 +++-- .../src/components/index.ts | 1 + 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 .changeset/puny-pillows-wave.md create mode 100644 packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx create mode 100644 packages/frontend-plugin-api/src/components/AppNodeProvider.tsx diff --git a/.changeset/puny-pillows-wave.md b/.changeset/puny-pillows-wave.md new file mode 100644 index 0000000000..813e603007 --- /dev/null +++ b/.changeset/puny-pillows-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `useAppNode` hook, which can be used to get a reference to the `AppNode` from by the closest `ExtensionBoundary`. diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 05a12184a3..21c44a2c77 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1830,6 +1830,9 @@ export { useApi }; export { useApiHolder }; +// @public +export function useAppNode(): AppNode | undefined; + // @public export function useComponentRef( ref: ComponentRef, diff --git a/packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx b/packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx new file mode 100644 index 0000000000..fcc3b74c59 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/AppNodeProvider.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createVersionedContext, + createVersionedValueMap, +} from '@backstage/version-bridge'; +import { AppNode } from '../apis'; +import { renderHook } from '@testing-library/react'; +import { AppNodeProvider, useAppNode } from './AppNodeProvider'; +import { withLogCollector } from '@backstage/test-utils'; + +describe('AppNodeProvider', () => { + it('should provide app node context to children', () => { + const node = { id: 'test' } as unknown as AppNode; + const { result } = renderHook(() => useAppNode(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(node); + }); + + it('should return undefined when used outside provider', () => { + const { result } = renderHook(() => useAppNode()); + expect(result.current).toBeUndefined(); + }); + + it('should return the closest app node', () => { + const node1 = { id: 'test1' } as unknown as AppNode; + const node2 = { id: 'test2' } as unknown as AppNode; + + const { result } = renderHook(() => useAppNode(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(node2); + }); + + it('should throw error for invalid context version', () => { + const node = { id: 'test' } as unknown as AppNode; + const Context = createVersionedContext('app-node-context'); + const value = createVersionedValueMap({ 2: { node } }); + + const { error } = withLogCollector(() => { + expect(() => + renderHook(() => useAppNode(), { + wrapper: ({ children }) => ( + {children} + ), + }), + ).toThrow('AppNodeContext v1 not available'); + }); + expect(error).toEqual([ + expect.objectContaining({ + detail: new Error('AppNodeContext v1 not available'), + }), + expect.objectContaining({ + detail: new Error('AppNodeContext v1 not available'), + }), + expect.stringContaining( + 'The above error occurred in the component:', + ), + ]); + }); +}); diff --git a/packages/frontend-plugin-api/src/components/AppNodeProvider.tsx b/packages/frontend-plugin-api/src/components/AppNodeProvider.tsx new file mode 100644 index 0000000000..2bc9715c10 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/AppNodeProvider.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createVersionedContext, + createVersionedValueMap, + useVersionedContext, +} from '@backstage/version-bridge'; +import { AppNode } from '../apis'; +import { ReactNode } from 'react'; + +const CONTEXT_KEY = 'app-node-context'; + +type AppNodeContextV1 = { + node?: AppNode; +}; + +type AppNodeContextMap = { + 1: AppNodeContextV1; +}; + +const AppNodeContext = createVersionedContext(CONTEXT_KEY); + +/** @internal */ +export function AppNodeProvider({ + node, + children, +}: { + node: AppNode; + children: ReactNode; +}) { + const versionedValue = createVersionedValueMap({ 1: { node } }); + + return ; +} + +/** + * React hook providing access to the current {@link AppNode}. + * + * @public + * @remarks + * + * This hook will return the {@link AppNode} for the closest extension. This + * relies on the extension using the {@link (ExtensionBoundary:function)} component in its + * implementation, which is included by default for all common blueprints. + * + * If the current component is not inside an {@link (ExtensionBoundary:function)}, it will + * return `undefined`. + */ +export function useAppNode(): AppNode | undefined { + const versionedContext = useVersionedContext(CONTEXT_KEY); + if (!versionedContext) { + return undefined; + } + + const context = versionedContext.atVersion(1); + if (!context) { + throw new Error('AppNodeContext v1 not available'); + } + return context.node; +} diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 46b3956e0e..7f5581054c 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -28,6 +28,7 @@ import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/ana import { AppNode, useComponentRef } from '../apis'; import { coreComponentRefs } from './coreComponentRefs'; import { coreExtensionData } from '../wiring'; +import { AppNodeProvider } from './AppNodeProvider'; type RouteTrackerProps = PropsWithChildren<{ disableTracking?: boolean; @@ -80,15 +81,17 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) { }; return ( - }> - - - - {children} - - - - + + }> + + + + {children} + + + + + ); } diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 5066144000..31a53be24c 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -20,3 +20,4 @@ export { } from './ExtensionBoundary'; export { coreComponentRefs } from './coreComponentRefs'; export { createComponentRef, type ComponentRef } from './createComponentRef'; +export { useAppNode } from './AppNodeProvider'; From fa5650cc80d69b4ff67b06d205263b260589d23f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 09:53:34 +0200 Subject: [PATCH 17/49] frontend-defaults: add pluginInfoResolver + test Signed-off-by: Patrik Oldsberg --- .changeset/spotty-cases-show.md | 5 ++ packages/frontend-defaults/report.api.md | 3 ++ .../frontend-defaults/src/createApp.test.tsx | 49 +++++++++++++++++++ packages/frontend-defaults/src/createApp.tsx | 3 ++ 4 files changed, 60 insertions(+) create mode 100644 .changeset/spotty-cases-show.md diff --git a/.changeset/spotty-cases-show.md b/.changeset/spotty-cases-show.md new file mode 100644 index 0000000000..3e9a607733 --- /dev/null +++ b/.changeset/spotty-cases-show.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Forwarded the new `pluginInfoResolver` option for `createApp`. diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index e9bcd99920..600f0a27de 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -10,6 +10,7 @@ import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; import { ReactNode } from 'react'; // @public @@ -44,6 +45,8 @@ export interface CreateAppOptions { | CreateAppFeatureLoader )[]; loadingComponent?: ReactNode; + // (undocumented) + pluginInfoResolver?: FrontendPluginInfoResolver; } // @public diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 4fd1ba4c5b..4f627f1839 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -23,12 +23,15 @@ import { createFrontendPlugin, ThemeBlueprint, createFrontendModule, + useAppNode, + FrontendPluginInfo, } from '@backstage/frontend-plugin-api'; import { screen, waitFor } from '@testing-library/react'; import { CreateAppFeatureLoader, createApp } from './createApp'; import { mockApis, renderWithEffects } from '@backstage/test-utils'; import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; import { default as appPluginOriginal } from '@backstage/plugin-app'; +import { useState, useEffect } from 'react'; describe('createApp', () => { const appPlugin = appPluginOriginal.withOverrides({ @@ -119,6 +122,52 @@ describe('createApp', () => { ); }); + it('should allow overriding the plugin info resolver', async () => { + function TestComponent() { + const appNode = useAppNode(); + const [info, setInfo] = useState( + undefined, + ); + + useEffect(() => { + appNode?.spec.source?.info().then(setInfo); + }, [appNode]); + + return
Package name: {info?.packageName}
; + } + + const app = createApp({ + configLoader: async () => ({ config: mockApis.config() }), + features: [ + appPlugin, + createFrontendPlugin({ + pluginId: 'test', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/', + loader: async () => , + }, + }), + ], + }), + ], + pluginInfoResolver: async () => { + return { + info: { + packageName: '@test/test', + }, + }; + }, + }); + + await renderWithEffects(app.createRoot()); + + await expect( + screen.findByText('Package name: @test/test'), + ).resolves.toBeInTheDocument(); + }); + it('should support feature loaders', async () => { const loader: CreateAppFeatureLoader = { getLoaderName() { diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index f25b71ce18..e8baa3a764 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -30,6 +30,7 @@ import { ConfigReader } from '@backstage/config'; import { CreateAppRouteBinder, createSpecializedApp, + FrontendPluginInfoResolver, } from '@backstage/frontend-app-api'; import appPlugin from '@backstage/plugin-app'; import { discoverAvailableFeatures } from './discovery'; @@ -78,6 +79,7 @@ export interface CreateAppOptions { extensionFactoryMiddleware?: | ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[]; + pluginInfoResolver?: FrontendPluginInfoResolver; } /** @@ -112,6 +114,7 @@ export function createApp(options?: CreateAppOptions): { features: [appPlugin, ...loadedFeatures], bindRoutes: options?.bindRoutes, extensionFactoryMiddleware: options?.extensionFactoryMiddleware, + pluginInfoResolver: options?.pluginInfoResolver, }); const rootEl = app.tree.root.instance!.getData( From 32a64e999e7dca67af2937d0088ffb8000ddf103 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 11:56:20 +0200 Subject: [PATCH 18/49] app-next: add custom plugin info example Signed-off-by: Patrik Oldsberg --- packages/app-next/app-config.yaml | 14 +++++ packages/app-next/src/App.tsx | 2 + .../app-next/src/examples/pagesPlugin.tsx | 24 +++++++++ packages/app-next/src/pluginInfoResolver.ts | 52 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 packages/app-next/src/pluginInfoResolver.ts diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 9d2bcf198a..fac6dbbdf2 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -8,6 +8,20 @@ app: catalog.createComponent: catalog-import.importPage org.catalogIndex: catalog.catalogIndex + pluginOverrides: + - match: + pluginId: pages + info: + description: 'This description was overridden in packages/app-next/app-config.yaml' + - match: + pluginId: /^catalog(-.*)?$/ + info: + ownerEntityRefs: [cubic-belugas] + - match: + packageName: '@backstage/plugin-scaffolder' + info: + ownerEntityRefs: [cubic-belugas] + extensions: # - apis.plugin.graphiql.browse.gitlab: true # - graphiql-endpoint:graphiql/gitlab: true diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 0ecb46e06a..43cc63d1a4 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -43,6 +43,7 @@ import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha'; import { convertLegacyPlugin } from '@backstage/core-compat-api'; import { convertLegacyPageExtension } from '@backstage/core-compat-api'; import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha'; +import { pluginInfoResolver } from './pluginInfoResolver'; /* @@ -132,6 +133,7 @@ const app = createApp({ customHomePageModule, ...collectedLegacyPlugins, ], + pluginInfoResolver, /* Handled through config instead */ // bindRoutes({ bind }) { // bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX }); diff --git a/packages/app-next/src/examples/pagesPlugin.tsx b/packages/app-next/src/examples/pagesPlugin.tsx index ddd080c28b..33fc7d8c0b 100644 --- a/packages/app-next/src/examples/pagesPlugin.tsx +++ b/packages/app-next/src/examples/pagesPlugin.tsx @@ -21,7 +21,10 @@ import { createExternalRouteRef, useRouteRef, PageBlueprint, + FrontendPluginInfo, + useAppNode, } from '@backstage/frontend-plugin-api'; +import { useEffect, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; const indexRouteRef = createRouteRef(); @@ -36,6 +39,22 @@ export const pageXRouteRef = createRouteRef(); // path: '/page2', // }); +function PluginInfo() { + const node = useAppNode(); + const [info, setInfo] = useState(undefined); + + useEffect(() => { + node?.spec.source?.info().then(setInfo); + }, [node]); + + return ( +
+

Plugin Info

+
{JSON.stringify(info, null, 2)}
+
+ ); +} + const IndexPage = PageBlueprint.make({ name: 'index', params: { @@ -64,6 +83,7 @@ const IndexPage = PageBlueprint.make({
Settings
+ ); }; @@ -139,6 +159,10 @@ export const pagesPlugin = createFrontendPlugin({ // // OR // // 'page1' // }, + info: { + packageJson: () => import('../../package.json'), + manifest: () => import('../../catalog-info.yaml'), + }, routes: { page1: page1RouteRef, pageX: pageXRouteRef, diff --git a/packages/app-next/src/pluginInfoResolver.ts b/packages/app-next/src/pluginInfoResolver.ts new file mode 100644 index 0000000000..12f730875c --- /dev/null +++ b/packages/app-next/src/pluginInfoResolver.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; + +// This file shows an example of what it looks like to extend the plugin info +// resolution with custom logic and fields. In this case we're reading the +// `spec.type` field from the plugin manifest (catalog-info.yaml). +// +// Using module augmentation we extend the `FrontendPluginInfo` interface to +// include our custom fields. This makes these fields available throughout our project. + +declare module '@backstage/frontend-plugin-api' { + export interface FrontendPluginInfo { + /** + * **DO NOT USE** + * + * This field is added in the example app to showcase module augmentation + * for extending the plugin info in internal apps. It only exists as an + * example in this project. + */ + exampleFieldDoNotUse?: string; + } +} + +export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => { + const manifest = (await ctx.manifest?.()) as Entity | undefined; + const { info: defaultInfo } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: manifest, + }); + return { + info: { + ...defaultInfo, + exampleFieldDoNotUse: manifest?.spec?.type?.toString(), + }, + }; +}; From f0f110baac3307b179f70a812bb7b8f95650704e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 12:51:34 +0200 Subject: [PATCH 19/49] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- .changeset/petite-candies-tan.md | 6 +++--- .changeset/stupid-flies-speak.md | 2 +- .../frontend-app-api/src/wiring/createPluginInfoAttacher.ts | 4 ++-- packages/frontend-defaults/report.api.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/petite-candies-tan.md b/.changeset/petite-candies-tan.md index e5a65f8066..6ec5c9c813 100644 --- a/.changeset/petite-candies-tan.md +++ b/.changeset/petite-candies-tan.md @@ -4,18 +4,18 @@ Added a new optional `info` option to `createFrontendPlugin` that lets you provide a loaders for different sources of metadata information about the plugin. -There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin.This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this: +There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin. This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this: ```ts export default createFrontendPlugin({ pluginId: '...', info: { - info: { packageJson: () => import('../package.json') }, + packageJson: () => import('../package.json'), }, }); ``` -The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful to add additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`. +The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`. Typical usage looks like this: diff --git a/.changeset/stupid-flies-speak.md b/.changeset/stupid-flies-speak.md index cad07ae611..5156a5b8b6 100644 --- a/.changeset/stupid-flies-speak.md +++ b/.changeset/stupid-flies-speak.md @@ -2,4 +2,4 @@ '@backstage/frontend-app-api': patch --- -Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overriden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration. +Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overridden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration. diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts index 27a6ca73ac..2b6f3a57f7 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -90,7 +90,7 @@ function normalizePluginInfo(info: FrontendPluginInfo) { ownerEntityRefs: info.ownerEntityRefs?.map(ref => stringifyEntityRef( parseEntityRef(ref, { - defaultKind: 'group', + defaultKind: 'Group', }), ), ), @@ -161,7 +161,7 @@ function resolveManifestInfo(manifest?: JsonValue) { info.ownerEntityRefs = [ stringifyEntityRef( parseEntityRef(manifest.spec.owner, { - defaultKind: 'group', + defaultKind: 'Group', defaultNamespace: manifest.metadata.namespace?.toString(), }), ), diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 600f0a27de..72f212b3b1 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -9,8 +9,8 @@ import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; -import { JSX as JSX_2 } from 'react'; import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api'; +import { JSX as JSX_2 } from 'react'; import { ReactNode } from 'react'; // @public From 363e515d599ca797445ebcaeea07a59c9baf633c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 17:14:23 +0200 Subject: [PATCH 20/49] docs/frontend-system: document plugin info system Signed-off-by: Patrik Oldsberg --- docs/frontend-system/architecture/10-app.md | 95 +++++++++++++++++++ .../architecture/15-plugins.md | 29 ++++++ 2 files changed, 124 insertions(+) diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index d1c3820f09..2e1a23e3bc 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -77,3 +77,98 @@ app: ``` Note that you do not need to manually exclude packages that you also import explicitly in code, since plugin instances are deduplicated by the app. You will never end up with duplicate plugin installations except if they are in fact two different plugin instances with different IDs. + +## Plugin Info Resolution + +When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info). + +By default the app will pick a few common fields from `package.json` files, and assume that the opaque manifests are `catalog-info.yaml` files that some information can be gathered from too. This information will then be available via the `info()` method on plugin instances, returning a structure of the `FrontendPluginInfo` type. + +### Extending Plugin Info + +The default plugin info is intended as a base to build upon. As part of setting up an app you can both customize the way that the plugin info is resolved, as well as extend the `FrontendPluginInfo` type to include more information. + +In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` inferface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows: + +```ts +declare module '@backstage/frontend-plugin-api' { + interface FrontendPluginInfo { + /** + * The slack channel to use for support requests for this plugin. + */ + slackChannel?: string; + } +} +``` + +### Customizing Plugin Info Resolution + +With the new `slackChannel` field in place, we now need to provide a custom resolver that knows how to extract this information from the plugin information sources. This is done by passing a custom `pluginInfoResolver` to `createApp`, which in our example is declared like this: + +```ts title="pluginInfoResolver.ts" +import { createPluginInfoResolver } from '@backstage/frontend-plugin-api'; + +// It is recommended to keep the above module augmentation in this file too + +export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => { + // In our particular example app we assume that all plugin manifests are catalog-info.yaml files + const manifest = (await ctx.manifest?.()) as Entity | undefined; + + // Call the default resolver to populate common fields + const { info } = await ctx.defaultResolver({ + packageJson: await ctx.packageJson(), + manifest: manifest, + }); + + // In this example the catalog model has been extended with a metadata.slackChannel field + const slackChannel = manifest?.metadata?.slackChannel?.toString(); + + if (slackChannel) { + info.slackChannel = slackChannel; + info.links = [ + ...(info.links ?? []), + { + title: 'Slack Channel', + url: `https://our-workspace.enterprise.slack.com/archives/${slackChannel}`, + }, + ]; + } + + return { info }; +}; +``` + +And included in the app as follows: + +```ts title="App.tsx" +import { pluginInfoResolver } from './pluginInfoResolver'; + +const app = createApp({ + pluginInfoResolver, + // ... other options +}); +``` + +### Overriding Plugin Info + +Another way to customize the plugin info is to use the `app.pluginOverrides` static configuration key. These overrides are applied after the plugin info has been resolved as a final step before making it available to users. These overrides are particularly useful to override information in third-party plugins. For example, if your organization has an individual team that is responsible for the maintenance of the Software Catalog, you might configure the following override: + +```yaml +app: + pluginOverrides: + - match: + pluginId: catalog + info: + ownerEntityRefs: [catalog-owners] +``` + +You can match on both the `pluginId` and/or `packageName` of the plugin, although the `packageName` will only be supported if the plugin provides an loader for the `package.json` file. Using `//` you are also able to use a regex pattern for this matching. For example, if you wanted to override the owner for all plugins from the `@acme` namespace, you could do the following: + +```yaml +app: + pluginOverrides: + - match: + packageName: /@acme/.*/ + info: + ownerEntityRefs: [acme-owners] +``` diff --git a/docs/frontend-system/architecture/15-plugins.md b/docs/frontend-system/architecture/15-plugins.md index 825f2a3982..6d2b14d77f 100644 --- a/docs/frontend-system/architecture/15-plugins.md +++ b/docs/frontend-system/architecture/15-plugins.md @@ -53,6 +53,35 @@ These are the routes that the plugin exposes to the app. The `routes` option dec This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`. +### `info` option + +This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources. + +The `info.packageJson` loader **MUST** be used by all plugins that are implemented within their own package, and it should load the `package.json` file for the plugin package. Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: 'my-plugin', + info: { + packageJson: () => import('../package.json'), + }, + extensions: [...], +}); +``` + +The `info.manifest` loader is used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `metadata.links` and `spec.owner`. + +Typical usage looks like this: + +```ts +export default createFrontendPlugin({ + pluginId: '...', + info: { + manifest: () => import('../catalog-info.yaml'), + }, +}); +``` + ## Installing a Plugin in an App A plugin instance is considered a frontend feature and can be installed directly in any Backstage frontend app. See the [app documentation](./10-app.md) for more information about the different ways in which you can install new features in an app. From 350011ce97759ff98e2c63bfb73ed9e4ea2e3465 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 17:31:53 +0200 Subject: [PATCH 21/49] small review fixes for plugin info Signed-off-by: Patrik Oldsberg --- .../src/wiring/createPluginInfoAttacher.test.ts | 2 +- .../frontend-app-api/src/wiring/createPluginInfoAttacher.ts | 2 +- .../frontend-plugin-api/src/wiring/createFrontendPlugin.ts | 4 +--- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts index 5595c04135..6c053f8aed 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.test.ts @@ -121,7 +121,7 @@ describe('createPluginInfoAttacher', () => { }, { title: 'Repository', - url: 'https://github.com/test/project/tree/master/packages/test', + url: 'https://github.com/test/project/tree/-/packages/test', }, ], }); diff --git a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts index 2b6f3a57f7..dc2b54f534 100644 --- a/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts +++ b/packages/frontend-app-api/src/wiring/createPluginInfoAttacher.ts @@ -210,7 +210,7 @@ function resolvePackageInfo(packageJson?: JsonObject) { url.hostname === 'github.com' && typeof packageJson.repository.directory === 'string' ) { - const path = `${url.pathname}/tree/master/${packageJson.repository.directory}`; + const path = `${url.pathname}/tree/-/${packageJson.repository.directory}`; url.pathname = path.replaceAll('//', '/'); } diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 13c39a37e9..c16ac329cc 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -109,9 +109,7 @@ export interface FrontendPlugin< extensions: Array; /** - * Overrides to merge with the original plugin info. The merging is done for - * each top-level field. Setting a field explicitly to `undefined` will - * remove it. + * Overrides the original info loaders of the plugin one by one. */ info?: FrontendPluginInfoOptions; }): FrontendPlugin; From d44f943e6db73b625cdeb86538cfdc7ed2416fea Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 15 May 2025 23:40:17 +0200 Subject: [PATCH 22/49] Update docs/frontend-system/architecture/10-app.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Signed-off-by: Patrik Oldsberg --- docs/frontend-system/architecture/10-app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/architecture/10-app.md b/docs/frontend-system/architecture/10-app.md index 2e1a23e3bc..fd85ff988b 100644 --- a/docs/frontend-system/architecture/10-app.md +++ b/docs/frontend-system/architecture/10-app.md @@ -88,7 +88,7 @@ By default the app will pick a few common fields from `package.json` files, and The default plugin info is intended as a base to build upon. As part of setting up an app you can both customize the way that the plugin info is resolved, as well as extend the `FrontendPluginInfo` type to include more information. -In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` inferface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows: +In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` interface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows: ```ts declare module '@backstage/frontend-plugin-api' { From d781b331a8314c779e3ffcee746dd4d8f8ab0e01 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Fri, 16 May 2025 12:59:08 -0500 Subject: [PATCH 23/49] render details for composite property schemas Signed-off-by: Matt Benson --- .changeset/eight-planets-see.md | 5 + .../RenderSchema/RenderSchema.test.tsx | 31 ++- .../components/RenderSchema/RenderSchema.tsx | 197 ++++++++++-------- 3 files changed, 141 insertions(+), 92 deletions(-) create mode 100644 .changeset/eight-planets-see.md diff --git a/.changeset/eight-planets-see.md b/.changeset/eight-planets-see.md new file mode 100644 index 0000000000..20adcac7ef --- /dev/null +++ b/.changeset/eight-planets-see.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +render details for composite property schemas diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx index 0f1150591d..ec49f414f9 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.test.tsx @@ -475,7 +475,7 @@ describe('JSON schema UI rendering', () => { const tr = getByTestId(`properties-row_test.${k}`); expect(tr).toBeInTheDocument(); - const sub = getByTestId(`properties-row_test_sub${i}.${k}`); + const sub = getByTestId(`properties-row_test_oneOf${i}.${k}`); expect(sub).toBeInTheDocument(); expect( @@ -485,7 +485,7 @@ describe('JSON schema UI rendering', () => { ).toBe(true); expect( - getByTestId(`properties-row_test_sub${i}.${k}Flag`), + getByTestId(`properties-row_test_oneOf${i}.${k}Flag`), ).toBeInTheDocument(); } }); @@ -528,10 +528,35 @@ describe('JSON schema UI rendering', () => { for (const i of subs.keys()) { for (const k of Object.keys(subs[i].properties!)) { expect( - rendered.getByTestId(`properties-row_test_sub${i}.${k}`), + rendered.getByTestId(`properties-row_test_oneOf${i}.${k}`), ).toBeInTheDocument(); } } }); + it('property alternatives', async () => { + const schema: JSONSchema7 = { + properties: { + bs: { + anyOf: [ + { + type: 'boolean', + }, + { + type: 'string', + }, + ], + }, + }, + }; + const rendered = await renderInTestApp( + , + ); + expect( + rendered.getByTestId(`root-row_test.bs_anyOf0`), + ).toBeInTheDocument(); + expect( + rendered.getByTestId(`root-row_test.bs_anyOf1`), + ).toBeInTheDocument(); + }); }); }); diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx index 8f967f8ee2..fc926e6c44 100644 --- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx +++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx @@ -41,37 +41,44 @@ import { JSONSchema7Definition, JSONSchema7Type, } from 'json-schema'; -import { FC, JSX, cloneElement, Fragment } from 'react'; +import { FC, JSX, cloneElement, Fragment, ReactElement } from 'react'; import { scaffolderTranslationRef } from '../../translation'; import { SchemaRenderContext, SchemaRenderStrategy } from './types'; import { TranslationMessages } from '../TemplatingExtensionsPage/types'; -const getTypes = (properties: JSONSchema7) => { - if (!properties.type) { +const compositeSchemaProperties = ['allOf', 'anyOf', 'not', 'oneOf'] as const; + +type subSchemasType = { + [K in (typeof compositeSchemaProperties)[number]]?: JSONSchema7Definition[]; +}; + +const getTypes = (schema: JSONSchema7) => { + if (!schema.type) { + if ( + Object.getOwnPropertyNames(schema).some(p => + compositeSchemaProperties.includes(p as any), + ) + ) { + return undefined; + } return ['unknown']; } - if (properties.type !== 'array') { - return [properties.type].flat(); + if (schema.type !== 'array') { + return [schema.type].flat(); } return [ - `${properties.type}(${ - (properties.items as JSONSchema7 | undefined)?.type ?? 'unknown' + `${schema.type}(${ + (schema.items as JSONSchema7 | undefined)?.type ?? 'unknown' })`, ]; }; -const getSubschemas = ( - schema: JSONSchema7Definition, -): Record => { +const getSubschemas = (schema: JSONSchema7Definition): subSchemasType => { if (typeof schema === 'boolean') { return {}; } const base: Omit = {}; - const compositeSchemaProperties = ['allOf', 'anyOf', 'not', 'oneOf'] as const; - type subSchemasType = { - [K in (typeof compositeSchemaProperties)[number]]?: JSONSchema7Definition[]; - }; const subschemas: subSchemasType = {}; for (const [key, value] of Object.entries(schema) as [ @@ -224,7 +231,10 @@ const inspectSchema = ( return { canSubschema: false, hasEnum: false }; } return { - canSubschema: getTypes(schema).some(t => t.includes('object')), + canSubschema: + Object.getOwnPropertyNames(schema).some(p => + compositeSchemaProperties.includes(p as any), + ) || getTypes(schema)!.some(t => t.includes('object')), hasEnum: !!enumFrom(schema), }; }; @@ -242,8 +252,8 @@ const typeColumn = { const info = inspectSchema(element.schema); return ( <> - {types.map((type, index) => - type.includes('object') || (info.hasEnum && index === 0) ? ( + {types?.map((type, index) => + info.canSubschema || (info.hasEnum && index === 0) ? ( { if (typeof schema === 'object') { - const subschemas = - strategy === 'root' || !context.parent ? getSubschemas(schema) : {}; + const subschemas = getSubschemas(schema); let columns: Column[] | undefined; let elements: SchemaRenderElement[] | undefined; if (strategy === 'root') { @@ -393,90 +402,100 @@ export const RenderSchema = ({ {elements.map(el => { const id = generateId(el, context); const info = inspectSchema(el.schema); - return ( - - - {columns!.map(col => ( - + {columns!.map(col => ( + + {col.render(el, context)} + + ))} + , + ]; + if ( + typeof el.schema !== 'boolean' && + (info.canSubschema || info.hasEnum) + ) { + let details: ReactElement = ( + + {info.canSubschema && ( + - {col.render(el, context)} - - ))} - - {typeof el.schema !== 'boolean' && - (info.canSubschema || info.hasEnum) && ( - - - - - {info.canSubschema && ( - - )} - {info.hasEnum && ( - <> - {cloneElement( - context.headings[0], - {}, - 'Valid values:', - )} - - - )} - - - - + /> )} - - ); + {info.hasEnum && ( + <> + {cloneElement( + context.headings[0], + {}, + 'Valid values:', + )} + + + )} + + ); + if (getTypes(el.schema)) { + details = ( + + {details} + + ); + } + rows.push( + + + {details} + + , + ); + } + return {rows}; })} )} - {Object.keys(subschemas).map(sk => ( + {(Object.keys(subschemas) as Array).map(sk => ( {cloneElement(context.headings[0], {}, sk)} - {subschemas[sk].map((sub, index) => ( + {subschemas[sk]!.map((sub, index) => ( Date: Wed, 14 May 2025 15:42:30 -0500 Subject: [PATCH 24/49] upgrade GitLab MR action createTemplateAction API Signed-off-by: Matt Benson --- .../report.api.md | 29 ++- .../gitlabMergeRequest.examples.test.ts | 2 +- .../src/actions/gitlabMergeRequest.test.ts | 2 +- .../src/actions/gitlabMergeRequest.ts | 206 +++++++----------- 4 files changed, 99 insertions(+), 140 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index cdb6065a3b..335c013ada 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -196,19 +196,24 @@ export const createPublishGitlabMergeRequestAction: (options: { title: string; description: string; branchName: string; - targetBranchName?: string; - sourcePath?: string; - targetPath?: string; - token?: string; - commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto'; - projectid?: string; - removeSourceBranch?: boolean; - assignee?: string; - reviewers?: string[]; - assignReviewersFromApprovalRules?: boolean; + targetBranchName?: string | undefined; + sourcePath?: string | undefined; + targetPath?: string | undefined; + token?: string | undefined; + commitAction?: 'auto' | 'update' | 'skip' | 'create' | 'delete' | undefined; + projectid?: string | undefined; + removeSourceBranch?: boolean | undefined; + assignee?: string | undefined; + reviewers?: string[] | undefined; + assignReviewersFromApprovalRules?: boolean | undefined; }, - JsonObject, - 'v1' + { + targetBranchName: string; + projectid: string; + projectPath: string; + mergeRequestUrl: string; + }, + 'v2' >; // @public diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.examples.test.ts index a97159a6b9..27f39fe621 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.examples.test.ts @@ -125,7 +125,7 @@ jest.mock('@gitbeaker/rest', () => ({ })); describe('createGitLabMergeRequest', () => { - let instance: TemplateAction; + let instance: TemplateAction; const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts index c5161b199d..5e7ac3d908 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts @@ -238,7 +238,7 @@ jest.mock('@gitbeaker/rest', () => ({ })); describe('createGitLabMergeRequest', () => { - let instance: TemplateAction; + let instance: TemplateAction; const mockDir = createMockDirectory(); const workspacePath = mockDir.resolve('workspace'); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts index 61a6c04889..65023eda1e 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts @@ -134,8 +134,10 @@ async function getReviewersFromApprovalRules( } } +const commitActions = ['create', 'delete', 'update', 'skip', 'auto'] as const; + /** - * Create a new action that creates a gitlab merge request. + * Create a new action that creates a GitLab merge request. * * @public */ @@ -144,138 +146,90 @@ export const createPublishGitlabMergeRequestAction = (options: { }) => { const { integrations } = options; - return createTemplateAction<{ - repoUrl: string; - title: string; - description: string; - branchName: string; - targetBranchName?: string; - sourcePath?: string; - targetPath?: string; - token?: string; - commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto'; - /** @deprecated projectID passed as query parameters in the repoUrl */ - projectid?: string; - removeSourceBranch?: boolean; - assignee?: string; - reviewers?: string[]; - assignReviewersFromApprovalRules?: boolean; - }>({ + return createTemplateAction({ id: 'publish:gitlab:merge-request', examples, schema: { input: { - required: ['repoUrl', 'branchName'], - type: 'object', - properties: { - repoUrl: { - type: 'string', - title: 'Repository Location', - description: `\ -Accepts the format 'gitlab.com?repo=project_name&owner=group_name' where \ -'project_name' is the repository name and 'group_name' is a group or username`, - }, - /** @deprecated projectID is passed as query parameters in the repoUrl */ - projectid: { - type: 'string', - title: 'projectid', - description: 'Project ID/Name(slug) of the Gitlab Project', - }, - title: { - type: 'string', - title: 'Merge Request Name', - description: 'The name for the merge request', - }, - description: { - type: 'string', - title: 'Merge Request Description', - description: 'The description of the merge request', - }, - branchName: { - type: 'string', - title: 'Source Branch Name', - description: 'The source branch name of the merge request', - }, - targetBranchName: { - type: 'string', - title: 'Target Branch Name', - description: 'The target branch name of the merge request', - }, - sourcePath: { - type: 'string', - title: 'Working Subdirectory', - description: `\ + repoUrl: z => + z.string().describe(`\ +Accepts the format \`gitlab.com?repo=project_name&owner=group_name\` where \ +\`project_name\` is the repository name and \`group_name\` is a group or username`), + title: z => z.string().describe('The name for the merge request'), + description: z => + z.string().describe('The description of the merge request'), + branchName: z => + z.string().describe('The source branch name of the merge request'), + targetBranchName: z => + z + .string() + .optional() + .describe( + 'The target branch name of the merge request (defaults to _default branch of repository_)', + ), + sourcePath: z => + z.string().optional().describe(`\ Subdirectory of working directory to copy changes from. \ -For reasons of backward compatibility, any specified 'targetPath' input will \ +For reasons of backward compatibility, any specified \`targetPath\` input will \ be applied in place of an absent/falsy value for this input. \ -Circumvent this behavior using '.'`, - }, - targetPath: { - type: 'string', - title: 'Repository Subdirectory', - description: 'Subdirectory of repository to apply changes to', - }, - token: { - title: 'Authentication Token', - type: 'string', - description: 'The token to use for authorization to GitLab', - }, - commitAction: { - title: 'Commit action', - type: 'string', - enum: ['create', 'update', 'delete', 'auto'], - description: `\ -The action to be used for git commit. Defaults to the custom 'auto' action provided by backstage, -which uses additional API calls in order to detect whether to 'create', 'update' or 'skip' each source file.`, - }, - removeSourceBranch: { - title: 'Delete source branch', - type: 'boolean', - description: - 'Option to delete source branch once the MR has been merged. Default: false', - }, - assignee: { - title: 'Merge Request Assignee', - type: 'string', - description: 'User this merge request will be assigned to', - }, - reviewers: { - title: 'Merge Request Reviewers', - type: 'array', - items: { - type: 'string', - }, - description: 'Users that will be assigned as reviewers', - }, - assignReviewersFromApprovalRules: { - title: 'Assign reviewers from approval rules', - type: 'boolean', - description: - 'Automatically assign reviewers from the approval rules of the MR. Includes Codeowners', - }, - }, +Circumvent this behavior using \`.\``), + targetPath: z => + z + .string() + .optional() + .describe('Subdirectory of repository to apply changes to'), + token: z => + z + .string() + .optional() + .describe('The token to use for authorization to GitLab'), + commitAction: z => + z.enum(commitActions).optional().describe(`\ +The action to be used for \`git\` commit. Defaults to the custom \`auto\` action provided by Backstage, +which uses additional API calls in order to detect whether to \`create\`, \`update\` or \`skip\` each source file.`), + /** @deprecated projectID passed as query parameters in the repoUrl */ + projectid: z => + z + .string() + .optional() + .describe( + `\ +Project ID/Name(slug) of the GitLab Project +_deprecated_: \`projectid\` passed as query parameters in the \`repoUrl\``, + ), + removeSourceBranch: z => + z + .boolean() + .optional() + .describe( + 'Option to delete source branch once the MR has been merged. Default: `false`', + ), + assignee: z => + z + .string() + .optional() + .describe('User this merge request will be assigned to'), + reviewers: z => + z + .string() + .array() + .optional() + .describe('Users that will be assigned as reviewers'), + assignReviewersFromApprovalRules: z => + z + .boolean() + .optional() + .describe( + 'Automatically assign reviewers from the approval rules of the MR. Includes `CODEOWNERS`', + ), }, output: { - type: 'object', - properties: { - targetBranchName: { - title: 'Target branch name of the merge request', - type: 'string', - }, - projectid: { - title: 'Gitlab Project id/Name(slug)', - type: 'string', - }, - projectPath: { - title: 'Gitlab Project path', - type: 'string', - }, - mergeRequestUrl: { - title: 'MergeRequest(MR) URL', - type: 'string', - description: 'Link to the merge request in GitLab', - }, - }, + targetBranchName: z => + z.string().describe('Target branch name of the merge request'), + projectid: z => z.string().describe('GitLab Project id/Name(slug)'), + projectPath: z => z.string().describe('GitLab Project path'), + mergeRequestUrl: z => + z.string().describe('Link to the merge request in GitLab'), }, }, async handler(ctx) { @@ -552,7 +506,7 @@ which uses additional API calls in order to detect whether to 'create', 'update' ctx.output('projectid', repoID); ctx.output('targetBranchName', targetBranch); ctx.output('projectPath', repoID); - ctx.output('mergeRequestUrl', mrWebUrl); + ctx.output('mergeRequestUrl', mrWebUrl as string); }, }); }; From 3d6493a23a8df035c4272fea852068042da6f166 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Wed, 14 May 2025 17:03:47 -0500 Subject: [PATCH 25/49] Support merge request labels in publish:gitlab:merge-request Signed-off-by: Matt Benson --- .changeset/small-dots-march.md | 5 ++ .../report.api.md | 1 + .../src/actions/gitlabMergeRequest.test.ts | 62 +++++++++++++++++++ .../src/actions/gitlabMergeRequest.ts | 8 +++ 4 files changed, 76 insertions(+) create mode 100644 .changeset/small-dots-march.md diff --git a/.changeset/small-dots-march.md b/.changeset/small-dots-march.md new file mode 100644 index 0000000000..0796ef68bb --- /dev/null +++ b/.changeset/small-dots-march.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Support merge request labels in publish:gitlab:merge-request diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 335c013ada..ed0d4b66a9 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -206,6 +206,7 @@ export const createPublishGitlabMergeRequestAction: (options: { assignee?: string | undefined; reviewers?: string[] | undefined; assignReviewersFromApprovalRules?: boolean | undefined; + labels?: string | string[] | undefined; }, { targetBranchName: string; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts index 5e7ac3d908..0cc1ca60c3 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.test.ts @@ -1446,4 +1446,66 @@ describe('createGitLabMergeRequest', () => { ); }); }); + describe('with labels', () => { + it('handles single label', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + title: 'Create my new MR', + branchName: 'new-mr', + description: 'MR description', + commitAction: 'skip', + labels: 'single-label', + }; + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith( + 'owner/repo', + 'new-mr', + 'main', + ); + expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled(); + expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( + 'owner/repo', + 'new-mr', + 'main', + 'Create my new MR', + { + description: 'MR description', + removeSourceBranch: false, + labels: 'single-label', + }, + ); + }); + it('handles array of labels', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + title: 'Create my new MR', + branchName: 'new-mr', + description: 'MR description', + commitAction: 'skip', + labels: ['foo', 'bar', 'baz'], + }; + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith( + 'owner/repo', + 'new-mr', + 'main', + ); + expect(mockGitlabClient.Commits.create).not.toHaveBeenCalled(); + expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith( + 'owner/repo', + 'new-mr', + 'main', + 'Create my new MR', + { + description: 'MR description', + removeSourceBranch: false, + labels: ['foo', 'bar', 'baz'], + }, + ); + }); + }); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts index 65023eda1e..a9ee80a30f 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts @@ -222,6 +222,12 @@ _deprecated_: \`projectid\` passed as query parameters in the \`repoUrl\``, .describe( 'Automatically assign reviewers from the approval rules of the MR. Includes `CODEOWNERS`', ), + labels: z => + z + .string() + .or(z.string().array()) + .optional() + .describe('Labels with which to tag the created merge request'), }, output: { targetBranchName: z => @@ -245,6 +251,7 @@ _deprecated_: \`projectid\` passed as query parameters in the \`repoUrl\``, sourcePath, title, token, + labels, } = ctx.input; const { owner, repo, project } = parseRepoUrl(repoUrl, integrations); @@ -453,6 +460,7 @@ _deprecated_: \`projectid\` passed as query parameters in the \`repoUrl\``, : false, assigneeId, reviewerIds, + labels, }, ); return { From 19b14cc338be94dadea04a9e6252c17d330ad924 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Thu, 15 May 2025 15:20:14 -0500 Subject: [PATCH 26/49] check in updated (reordered) api reports Signed-off-by: Matt Benson --- .../report.api.md | 2 +- plugins/scaffolder-backend/report.api.md | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index ed0d4b66a9..193bbf926b 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -200,7 +200,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'skip' | 'create' | 'delete' | undefined; + commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 41c228e3f1..b8222c4781 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -365,19 +365,25 @@ export const createPublishGitlabMergeRequestAction: (options: { title: string; description: string; branchName: string; - targetBranchName?: string; - sourcePath?: string; - targetPath?: string; - token?: string; - commitAction?: 'create' | 'delete' | 'update' | 'skip' | 'auto'; - projectid?: string; - removeSourceBranch?: boolean; - assignee?: string; - reviewers?: string[]; - assignReviewersFromApprovalRules?: boolean; + targetBranchName?: string | undefined; + sourcePath?: string | undefined; + targetPath?: string | undefined; + token?: string | undefined; + commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined; + projectid?: string | undefined; + removeSourceBranch?: boolean | undefined; + assignee?: string | undefined; + reviewers?: string[] | undefined; + assignReviewersFromApprovalRules?: boolean | undefined; + labels?: string | string[] | undefined; }, - JsonObject, - 'v1' + { + targetBranchName: string; + projectid: string; + projectPath: string; + mergeRequestUrl: string; + }, + 'v2' >; // @public @deprecated From 2a5540e3c3e40bcda16f6a0724c8907e3db22dbc Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Tue, 20 May 2025 08:16:49 -0500 Subject: [PATCH 27/49] Cleaning up the `app-config.yaml` Signed-off-by: Andre Wanlin --- app-config.yaml | 216 ++---------------------------------------------- 1 file changed, 5 insertions(+), 211 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 89e417fad3..9d9e645b5b 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -64,83 +64,11 @@ backend: # See README.md in the proxy-backend plugin for information on the configuration format proxy: endpoints: - '/circleci/api': - target: https://circleci.com/api/v1.1 - headers: - Circle-Token: ${CIRCLECI_AUTH_TOKEN} - - '/jenkins/api': - target: http://localhost:8080 - headers: - Authorization: ${JENKINS_BASIC_AUTH_HEADER} - - '/travisci/api': - target: https://api.travis-ci.com - changeOrigin: true - headers: - Authorization: ${TRAVISCI_AUTH_TOKEN} - travis-api-version: '3' - - '/newrelic/apm/api': - target: https://api.newrelic.com/v2 - headers: - X-Api-Key: ${NEW_RELIC_REST_API_KEY} - allowedHeaders: - - link - - '/newrelic/api': - target: https://api.newrelic.com - headers: - X-Api-Key: ${NEW_RELIC_USER_KEY} - '/pagerduty': target: https://api.pagerduty.com headers: Authorization: Token token=${PAGERDUTY_TOKEN} - '/buildkite/api': - target: https://api.buildkite.com/v2/ - headers: - Authorization: ${BUILDKITE_TOKEN} - - '/sentry/api': - target: https://sentry.io/api/ - allowedMethods: ['GET'] - headers: - Authorization: ${SENTRY_TOKEN} - - '/ilert': - target: https://api.ilert.com - allowedMethods: ['GET', 'POST', 'PUT'] - allowedHeaders: ['Authorization'] - headers: - Authorization: ${ILERT_AUTH_HEADER} - - '/airflow': - target: https://your.airflow.instance.com/api/v1 - headers: - Authorization: ${AIRFLOW_BASIC_AUTH_HEADER} - - '/gocd': - target: https://your.gocd.instance.com/go/api - allowedMethods: ['GET'] - allowedHeaders: ['Authorization'] - headers: - Authorization: Basic ${GOCD_AUTH_CREDENTIALS} - - '/dynatrace': - target: https://your.dynatrace.instance.com/api/v2 - headers: - Authorization: 'Api-Token ${DYNATRACE_ACCESS_TOKEN}' - - '/stackstorm': - target: https://your.stackstorm.instance.com/api - headers: - St2-Api-Key: ${ST2_API_KEY} - - '/puppetdb': - target: https://your.puppetdb.instance.com - organization: name: My Company @@ -157,46 +85,6 @@ techdocs: publisher: type: 'local' # Alternatives - 'googleGcs' or 'awsS3' or 'azureBlobStorage' or 'openStackSwift'. Read documentation for using alternatives. -dynatrace: - baseUrl: https://your.dynatrace.instance.com - -nomad: - addr: 0.0.0.0 - -# Score-cards sample configuration. -scorecards: - jsonDataUrl: https://raw.githubusercontent.com/Oriflame/backstage-plugins/main/plugins/score-card/sample-data/ - wikiLinkTemplate: https://link-to-wiki/{id} - -sentry: - organization: my-company - -rollbar: - organization: my-company - # NOTE: The rollbar-backend & accountToken key may be deprecated in the future (replaced by a proxy config) - accountToken: my-rollbar-account-token - -lighthouse: - baseUrl: http://localhost:3003 - -kubernetes: - serviceLocatorMethod: - type: 'multiTenant' - clusterLocatorMethods: - - type: 'config' - clusters: [] - -kafka: - clientId: backstage - clusters: - - name: cluster - dashboardUrl: https://akhq.io/ - brokers: - - localhost:9092 - -allure: - baseUrl: http://localhost:5050/allure-docker-service - integrations: github: - host: github.com @@ -269,26 +157,6 @@ catalog: plugins: - catalog - search - - processors: - ldapOrg: - ### Example for how to add your enterprise LDAP server - # providers: - # - target: ldaps://ds.example.net - # bind: - # dn: uid=ldap-reader-user,ou=people,ou=example,dc=example,dc=net - # secret: ${LDAP_SECRET} - # users: - # dn: ou=people,ou=example,dc=example,dc=net - # options: - # filter: (uid=*) - # map: - # description: l - # groups: - # dn: ou=access,ou=groups,ou=example,dc=example,dc=net - # options: - # filter: (&(objectClass=some-group-class)(!(groupType=email))) - locations: # Add a location here to ingest it, for example from a URL: # @@ -325,13 +193,14 @@ catalog: target: ../../plugins/scaffolder-backend/sample-templates/all-templates.yaml rules: - allow: [Template] + scaffolder: # Use to customize default commit author info used when new components are created - # defaultAuthor: - # name: Scaffolder - # email: scaffolder@backstage.io + defaultAuthor: + name: Scaffolder + email: scaffolder@backstage.io # Use to customize the default commit message when new components are created - # defaultCommitMessage: 'Initial commit' + defaultCommitMessage: 'Initial commit' auth: ### Add auth.keyStore.provider to more granularly control how to store JWK data when running @@ -422,80 +291,5 @@ auth: myproxy: {} guest: {} -costInsights: - engineerCost: 200000 - engineerThreshold: 0.5 - products: - computeEngine: - name: Compute Engine - icon: compute - cloudDataflow: - name: Cloud Dataflow - icon: data - cloudStorage: - name: Cloud Storage - icon: storage - bigQuery: - name: BigQuery - icon: search - events: - name: Events - icon: data - metrics: - DAU: - name: Daily Active Users - default: true - MSC: - name: Monthly Subscribers - currencies: - engineers: - label: 'Engineers 🛠' - unit: 'engineer' - usd: - label: 'US Dollars 💵' - kind: 'USD' - unit: 'dollar' - prefix: '$' - rate: 1 - carbonOffsetTons: - label: 'Carbon Offset Tons ♻️⚖️s' - kind: 'CARBON_OFFSET_TONS' - unit: 'carbon offset ton' - rate: 3.5 - beers: - label: 'Beers 🍺' - kind: 'BEERS' - unit: 'beer' - rate: 4.5 - pintsIceCream: - label: 'Pints of Ice Cream 🍦' - kind: 'PINTS_OF_ICE_CREAM' - unit: 'ice cream pint' - rate: 5.5 - -pagerDuty: - eventsBaseUrl: 'https://events.pagerduty.com/v2' - -jenkins: - instances: - - name: default - baseUrl: https://jenkins.example.com - username: backstage-bot - apiKey: 123456789abcdef0123456789abcedf012 - -azureDevOps: - host: dev.azure.com - token: my-token - organization: my-company - -apacheAirflow: - baseUrl: https://your.airflow.instance.com - -gocd: - baseUrl: https://your.gocd.instance.com - -stackstorm: - webUrl: https://your.stackstorm.webui.instance.com - permission: enabled: true From 2c3d82eafc85cc6a8732ccbadb55feff2b444882 Mon Sep 17 00:00:00 2001 From: Andre Wanlin Date: Tue, 20 May 2025 08:31:40 -0500 Subject: [PATCH 28/49] Ran prettier Signed-off-by: Andre Wanlin --- app-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 9d9e645b5b..eaee401d09 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -197,8 +197,8 @@ catalog: scaffolder: # Use to customize default commit author info used when new components are created defaultAuthor: - name: Scaffolder - email: scaffolder@backstage.io + name: Scaffolder + email: scaffolder@backstage.io # Use to customize the default commit message when new components are created defaultCommitMessage: 'Initial commit' From d33b55204973aeb690a4d0b64d86feaa283e9070 Mon Sep 17 00:00:00 2001 From: mbruhin <47482924+mbruhin@users.noreply.github.com> Date: Tue, 20 May 2025 11:18:35 -0600 Subject: [PATCH 29/49] update name Signed-off-by: mbruhin <47482924+mbruhin@users.noreply.github.com> --- .../src/actions/bitbucketCloudPullRequest.ts | 4 ++-- .../src/actions/bitbucketServerPullRequest.ts | 4 ++-- plugins/scaffolder-node/report.api.md | 6 ++--- plugins/scaffolder-node/src/actions/index.ts | 6 ++++- .../scaffolder-node/src/actions/util.test.ts | 22 +++++++++++-------- plugins/scaffolder-node/src/actions/util.ts | 2 +- 6 files changed, 26 insertions(+), 18 deletions(-) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts index 5a30df24f9..b59aaa4e41 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -23,7 +23,7 @@ import { addFiles, cloneRepo, parseRepoUrl, - filterGitFiles, + isNotGitDirectoryOrContents, } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; import fs from 'fs-extra'; @@ -414,7 +414,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: { // copy files fs.cpSync(sourceDir, tempDir, { recursive: true, - filter: filterGitFiles, + filter: isNotGitDirectoryOrContents, }); await addFiles({ diff --git a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts index 9176471913..f91d30cd75 100644 --- a/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-server/src/actions/bitbucketServerPullRequest.ts @@ -26,7 +26,7 @@ import { addFiles, cloneRepo, parseRepoUrl, - filterGitFiles, + isNotGitDirectoryOrContents, } from '@backstage/plugin-scaffolder-node'; import { Config } from '@backstage/config'; import fs from 'fs-extra'; @@ -454,7 +454,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { // copy files fs.cpSync(sourceDir, tempDir, { recursive: true, - filter: filterGitFiles, + filter: isNotGitDirectoryOrContents, }); await addFiles({ diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index c7cf681d33..9a1c856573 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -286,9 +286,6 @@ export function fetchFile(options: { token?: string; }): Promise; -// @public -export function filterGitFiles(path: string): boolean; - // @public (undocumented) export const getRepoSourceDirectory: ( workspacePath: string, @@ -319,6 +316,9 @@ export function initRepoAndPush(input: { commitHash: string; }>; +// @public +export function isNotGitDirectoryOrContents(path: string): boolean; + // @public (undocumented) export const parseRepoUrl: ( repoUrl: string, diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index 041d57328a..686d89d844 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -33,4 +33,8 @@ export { createBranch, cloneRepo, } from './gitHelpers'; -export { parseRepoUrl, getRepoSourceDirectory, filterGitFiles } from './util'; +export { + parseRepoUrl, + getRepoSourceDirectory, + isNotGitDirectoryOrContents, +} from './util'; diff --git a/plugins/scaffolder-node/src/actions/util.test.ts b/plugins/scaffolder-node/src/actions/util.test.ts index 2e0a464304..792e306d00 100644 --- a/plugins/scaffolder-node/src/actions/util.test.ts +++ b/plugins/scaffolder-node/src/actions/util.test.ts @@ -245,25 +245,29 @@ describe('scaffolder action utils', () => { }); }); - describe('filterGitFiles', () => { + describe('isNotGitDirectoryOrContents', () => { it('should filter .git directory and its contents but keep other files', () => { // Import the function to test - const { filterGitFiles } = require('./util'); + const { isNotGitDirectoryOrContents } = require('./util'); // Should filter out .git directory - expect(filterGitFiles('.git')).toBe(false); + expect(isNotGitDirectoryOrContents('.git')).toBe(false); // Should filter out .git directory in subdirectories - expect(filterGitFiles('subdir/.git')).toBe(false); + expect(isNotGitDirectoryOrContents('subdir/.git')).toBe(false); // Should filter out files inside .git directory - expect(filterGitFiles('.git/config')).toBe(false); - expect(filterGitFiles('subdir/.git/config')).toBe(false); + expect(isNotGitDirectoryOrContents('.git/config')).toBe(false); + expect(isNotGitDirectoryOrContents('subdir/.git/config')).toBe(false); // Should keep .gitignore and other non-.git-directory files - expect(filterGitFiles('.gitignore')).toBe(true); - expect(filterGitFiles('src/components/GitHubIcon.js')).toBe(true); - expect(filterGitFiles('.github/workflows/ci.yml')).toBe(true); + expect(isNotGitDirectoryOrContents('.gitignore')).toBe(true); + expect(isNotGitDirectoryOrContents('src/components/GitHubIcon.js')).toBe( + true, + ); + expect(isNotGitDirectoryOrContents('.github/workflows/ci.yml')).toBe( + true, + ); }); }); }); diff --git a/plugins/scaffolder-node/src/actions/util.ts b/plugins/scaffolder-node/src/actions/util.ts index da86befdc7..57141e9c24 100644 --- a/plugins/scaffolder-node/src/actions/util.ts +++ b/plugins/scaffolder-node/src/actions/util.ts @@ -192,6 +192,6 @@ export const parseSchemas = ( * while keeping other files like .gitignore * @public */ -export function filterGitFiles(path: string): boolean { +export function isNotGitDirectoryOrContents(path: string): boolean { return !(path.endsWith('.git') || path.includes('.git/')); } From 584cc7fd29c1d63861ac41986cd61e40855e9e88 Mon Sep 17 00:00:00 2001 From: Philipp Eckel Date: Wed, 21 May 2025 18:44:36 +0200 Subject: [PATCH 30/49] chore: add entry and logo for Wheel of Names plugin Signed-off-by: Philipp Eckel --- microsite/data/plugins/wheel-of-names.yaml | 10 ++++++++++ microsite/static/img/wheel-of-names.svg | 4 ++++ 2 files changed, 14 insertions(+) create mode 100644 microsite/data/plugins/wheel-of-names.yaml create mode 100644 microsite/static/img/wheel-of-names.svg diff --git a/microsite/data/plugins/wheel-of-names.yaml b/microsite/data/plugins/wheel-of-names.yaml new file mode 100644 index 0000000000..3ae31d612c --- /dev/null +++ b/microsite/data/plugins/wheel-of-names.yaml @@ -0,0 +1,10 @@ +--- +title: Wheel of Names +author: intive +authorUrl: https://www.intive.com/ +category: Utility +description: This plugin provides a customizable spinning wheel that can be used for random selection, decision making, or just for fun in your Backstage instance. +documentation: https://github.com/backstage/community-plugins/blob/main/workspaces/wheel-of-names/plugins/wheel-of-names/README.md +iconUrl: /img/wheel-of-names.svg +npmPackageName: '@backstage-community/plugin-wheel-of-names' +addedDate: '2025-05-21' \ No newline at end of file diff --git a/microsite/static/img/wheel-of-names.svg b/microsite/static/img/wheel-of-names.svg new file mode 100644 index 0000000000..d6f19a06fb --- /dev/null +++ b/microsite/static/img/wheel-of-names.svg @@ -0,0 +1,4 @@ + + + + From 5476046fc575c9e97ec68f4f09c7eefcf461a520 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Wed, 21 May 2025 14:36:24 -0400 Subject: [PATCH 31/49] inline routeFunc Signed-off-by: Chris Suich --- plugins/techdocs-react/report.api.md | 13 +++++-------- plugins/techdocs-react/src/helpers.ts | 20 +++++++------------- plugins/techdocs-react/src/index.ts | 1 - 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/plugins/techdocs-react/report.api.md b/plugins/techdocs-react/report.api.md index fd8de5803f..a3bae1f571 100644 --- a/plugins/techdocs-react/report.api.md +++ b/plugins/techdocs-react/report.api.md @@ -21,7 +21,11 @@ import { SetStateAction } from 'react'; // @public export const buildTechDocsURL: ( entity: Entity, - routeFunc: TechDocsRouteFunc | undefined, + routeFunc: RouteFunc<{ + namespace: string; + kind: string; + name: string; + }> | undefined, ) => string | undefined; // @public @@ -132,13 +136,6 @@ export type TechDocsReaderPageValue = { onReady?: () => void; }; -// @public -export type TechDocsRouteFunc = RouteFunc<{ - namespace: string; - kind: string; - name: string; -}>; - // @public export const TechDocsShadowDom: ( props: TechDocsShadowDomProps, diff --git a/plugins/techdocs-react/src/helpers.ts b/plugins/techdocs-react/src/helpers.ts index 8c4c9ee8f1..e6daf99b26 100644 --- a/plugins/techdocs-react/src/helpers.ts +++ b/plugins/techdocs-react/src/helpers.ts @@ -27,18 +27,6 @@ import { } from '@backstage/plugin-techdocs-common'; import { RouteFunc } from '@backstage/core-plugin-api'; -/** - * The RouteFunc used by buildTechDocsURL() to build the TechDocs link for - * an entity. - * - * @public - */ -export type TechDocsRouteFunc = RouteFunc<{ - namespace: string; - kind: string; - name: string; -}>; - /** * Lower-case entity triplets by default, but allow override. * @@ -82,7 +70,13 @@ export function getEntityRootTechDocsPath(entity: Entity): string { */ export const buildTechDocsURL = ( entity: Entity, - routeFunc: TechDocsRouteFunc | undefined, + routeFunc: + | RouteFunc<{ + namespace: string; + kind: string; + name: string; + }> + | undefined, ) => { if (!routeFunc) { return undefined; diff --git a/plugins/techdocs-react/src/index.ts b/plugins/techdocs-react/src/index.ts index 6b0e228a27..7c4f316c1e 100644 --- a/plugins/techdocs-react/src/index.ts +++ b/plugins/techdocs-react/src/index.ts @@ -57,4 +57,3 @@ export { getEntityRootTechDocsPath, buildTechDocsURL, } from './helpers'; -export type { TechDocsRouteFunc } from './helpers'; From fcd29a338e37abfeb80f22353e7b83feb57d6fad Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Wed, 21 May 2025 14:45:59 -0400 Subject: [PATCH 32/49] useLayoutEffect, useRef, and simply deps Signed-off-by: Chris Suich --- .../TechDocsReaderPageContent/dom.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx index f441d1b472..0bd7d0e335 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/dom.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ -import { useCallback, useEffect, useState } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + // useRef, + useState, +} from 'react'; import useMediaQuery from '@material-ui/core/useMediaQuery'; import { useTheme } from '@material-ui/core/styles'; @@ -55,23 +61,17 @@ const MOBILE_MEDIA_QUERY = 'screen and (max-width: 76.1875em)'; // current location in the history. This should only happen on the initial load so // navigating to the root of the docs doesn't also redirect. const useInitialRedirect = (defaultPath?: string) => { - const [hasRun, setHasRun] = useState(false); + // const hasRun = useRef(false); const location = useLocation(); const navigate = useNavigate(); const { '*': currPath = '' } = useParams(); - useEffect(() => { - // Only run once - if (hasRun) { - return; - } - setHasRun(true); - + useLayoutEffect(() => { if (currPath === '' && defaultPath !== '') { navigate(`${location.pathname}${defaultPath}`, { replace: true }); } - }, [hasRun, currPath, defaultPath, location, navigate]); + }, []); // eslint-disable-line react-hooks/exhaustive-deps }; /** From ff542441f72412ba4d2b2ba8639a4d0d24568b21 Mon Sep 17 00:00:00 2001 From: Chris Suich Date: Wed, 21 May 2025 16:10:32 -0400 Subject: [PATCH 33/49] update api report Signed-off-by: Chris Suich --- plugins/techdocs-react/report.api.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/techdocs-react/report.api.md b/plugins/techdocs-react/report.api.md index a3bae1f571..62ac6375fa 100644 --- a/plugins/techdocs-react/report.api.md +++ b/plugins/techdocs-react/report.api.md @@ -21,11 +21,13 @@ import { SetStateAction } from 'react'; // @public export const buildTechDocsURL: ( entity: Entity, - routeFunc: RouteFunc<{ - namespace: string; - kind: string; - name: string; - }> | undefined, + routeFunc: + | RouteFunc<{ + namespace: string; + kind: string; + name: string; + }> + | undefined, ) => string | undefined; // @public From c9796686156914d8e872f05e8706ba0e2b862571 Mon Sep 17 00:00:00 2001 From: Philipp Eckel Date: Fri, 23 May 2025 08:56:40 +0200 Subject: [PATCH 34/49] fix: add format Signed-off-by: Philipp Eckel --- microsite/data/plugins/wheel-of-names.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsite/data/plugins/wheel-of-names.yaml b/microsite/data/plugins/wheel-of-names.yaml index 3ae31d612c..0377742ab0 100644 --- a/microsite/data/plugins/wheel-of-names.yaml +++ b/microsite/data/plugins/wheel-of-names.yaml @@ -7,4 +7,4 @@ description: This plugin provides a customizable spinning wheel that can be used documentation: https://github.com/backstage/community-plugins/blob/main/workspaces/wheel-of-names/plugins/wheel-of-names/README.md iconUrl: /img/wheel-of-names.svg npmPackageName: '@backstage-community/plugin-wheel-of-names' -addedDate: '2025-05-21' \ No newline at end of file +addedDate: '2025-05-21' From 9de91b0b6090befeffc3ebf427e27a0a2eb746d1 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 23 May 2025 17:13:05 -0400 Subject: [PATCH 35/49] fix: add missing highlight language Signed-off-by: aramissennyeydd --- packages/repo-tools/src/commands/package-docs/command.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/repo-tools/src/commands/package-docs/command.ts b/packages/repo-tools/src/commands/package-docs/command.ts index d02bcad396..aa5edc7651 100644 --- a/packages/repo-tools/src/commands/package-docs/command.ts +++ b/packages/repo-tools/src/commands/package-docs/command.ts @@ -56,6 +56,7 @@ const HIGHLIGHT_LANGUAGES = [ 'diff', 'js', 'json', + 'docker', ]; function getExports(packageJson: any) { From e643ee45ffbb8be19a8fcd4177bafa14cd8c8bd0 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 23 May 2025 17:14:44 -0400 Subject: [PATCH 36/49] add changeset Signed-off-by: aramissennyeydd --- .changeset/grumpy-dryers-act.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/grumpy-dryers-act.md diff --git a/.changeset/grumpy-dryers-act.md b/.changeset/grumpy-dryers-act.md new file mode 100644 index 0000000000..caaeac5fa8 --- /dev/null +++ b/.changeset/grumpy-dryers-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Add missing highlight language for the `package-docs` command. From 04592af95367e06933cfca0f22675a0327af7091 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 26 May 2025 18:27:24 +0200 Subject: [PATCH 37/49] feat: implementing ActionRegistry service to register distributed actions Signed-off-by: benjdlambert --- packages/backend-defaults/package.json | 7 +- .../backend-defaults/src/CreateBackend.ts | 2 + .../actionsRegistry/PluginActionsRegistry.ts | 128 ++++++ .../actionsRegistryServiceFactory.test.ts | 367 ++++++++++++++++++ .../actionsRegistryServiceFactory.ts | 57 +++ .../src/entrypoints/actionsRegistry/index.ts | 16 + packages/backend-plugin-api/package.json | 3 +- .../definitions/ActionsRegistryService.ts | 66 ++++ .../src/services/definitions/coreServices.ts | 15 + .../src/services/definitions/index.ts | 6 + yarn.lock | 2 + 11 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts create mode 100644 packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts create mode 100644 packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 600f68d3f4..67507dd606 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -20,6 +20,7 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", + "./actionsRegistry": "./src/entrypoints/actionsRegistry/index.ts", "./auditor": "./src/entrypoints/auditor/index.ts", "./auth": "./src/entrypoints/auth/index.ts", "./cache": "./src/entrypoints/cache/index.ts", @@ -45,6 +46,9 @@ "types": "src/index.ts", "typesVersions": { "*": { + "actionsRegistry": [ + "src/entrypoints/actionsRegistry/index.ts" + ], "auditor": [ "src/entrypoints/auditor/index.ts" ], @@ -188,7 +192,8 @@ "winston-transport": "^4.5.0", "yauzl": "^3.0.0", "yn": "^4.0.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zod-to-json-schema": "^3.20.4" }, "devDependencies": { "@aws-sdk/util-stream-node": "^3.350.0", diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index d618c94138..30a913af68 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -35,8 +35,10 @@ import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler'; import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { userInfoServiceFactory } from '@backstage/backend-defaults/userInfo'; import { eventsServiceFactory } from '@backstage/plugin-events-node'; +import { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry'; export const defaultServiceFactories = [ + actionsRegistryServiceFactory, auditorServiceFactory, authServiceFactory, cacheServiceFactory, diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts new file mode 100644 index 0000000000..8dedf40658 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ActionsRegistryAction, + HttpAuthService, + LoggerService, +} from '@backstage/backend-plugin-api'; +import PromiseRouter from 'express-promise-router'; +import { Router, json } from 'express'; +import { z, ZodType } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { InputError, NotFoundError } from '@backstage/errors'; + +export class PluginActionsRegistry { + private constructor( + private readonly actions: Map< + string, + ActionsRegistryAction + >, + private readonly router: Router, + private readonly logger: LoggerService, + private readonly httpAuth: HttpAuthService, + ) { + this.bindRoutes(); + } + + static create({ + httpAuth, + logger, + }: { + httpAuth: HttpAuthService; + logger: LoggerService; + }): PluginActionsRegistry { + return new PluginActionsRegistry( + new Map(), + PromiseRouter(), + logger, + httpAuth, + ); + } + + getRouter(): Router { + return this.router; + } + + register( + options: ActionsRegistryAction, + ): void { + this.actions.set(options.id, options as ActionsRegistryAction); + } + + private bindRoutes() { + this.router.use(json()); + + this.router.get('/.backstage/v1/actions', (_, res) => { + return res.json({ + actions: Array.from(this.actions.entries()).map(([_id, action]) => ({ + ...action, + schema: { + input: action.schema?.input + ? zodToJsonSchema(action.schema.input) + : zodToJsonSchema(z.any()), + output: action.schema?.output + ? zodToJsonSchema(action.schema.output) + : zodToJsonSchema(z.any()), + }, + })), + }); + }); + + this.router.post( + '/.backstage/v1/actions/:actionId/invoke', + async (req, res) => { + const action = this.actions.get(req.params.actionId); + + if (!action) { + throw new NotFoundError(`Action "${req.params.actionId}" not found`); + } + + const input = action.schema?.input + ? action.schema.input.safeParse(req.body) + : ({ success: true, data: undefined } as const); + + if (!input.success) { + throw new InputError( + `Invalid input to action "${req.params.actionId}"`, + input.error, + ); + } + + const credentials = await this.httpAuth.credentials(req); + + const result = await action.action({ + input: input.data, + credentials, + logger: this.logger, + }); + + const output = action.schema?.output + ? action.schema.output.safeParse(result) + : ({ success: true, data: result } as const); + + if (!output.success) { + throw new InputError( + `Invalid output from action "${req.params.actionId}"`, + output.error, + ); + } + + return res.json({ response: output.data }); + }, + ); + } +} diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts new file mode 100644 index 0000000000..b3b518a8e9 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -0,0 +1,367 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { httpRouterServiceFactory } from '../httpRouter'; +import request from 'supertest'; +import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; + +describe('actionsRegistryServiceFactory', () => { + const defaultServices = [ + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user(), + }), + ]; + + describe('typescript tests', () => { + it('should properly infer the input types', () => { + createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + test: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + action: async ({ input: { test } }) => { + // @ts-expect-error - test is not a boolean + const _t: boolean = test; + return { ok: true }; + }, + }); + }, + }); + }, + }); + + expect(true).toBe(true); + }); + + it('should properly infer the output types', () => { + createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + test: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + // @ts-expect-error - ok is not a boolean + action: async () => { + return { ok: 'bo' }; + }, + }); + }, + }); + }, + }); + + expect(true).toBe(true); + }); + }); + + describe('/.backstage/v1/actions', () => { + it('should allow registering of actions', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + action: async () => ({ ok: true }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + }, + output: { + type: 'object', + properties: { + ok: { + type: 'boolean', + }, + }, + }, + }, + }, + ], + }); + }); + + it('should allow registering of actions with no schema', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + action: async () => ({ ok: true }), + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).get( + '/api/my-plugin/.backstage/v1/actions', + ); + + expect(status).toBe(200); + + expect(body).toMatchObject({ + actions: [ + { + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: {}, + output: {}, + }, + }, + ], + }); + }); + }); + + describe('/.backstage/v1/actions/:actionId/invoke', () => { + const mockAction = jest.fn(); + + beforeEach(() => { + mockAction.mockResolvedValue({ ok: true }); + }); + + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + actionsRegistry: coreServices.actionsRegistry, + }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + }), + }, + action: mockAction, + }); + }, + }); + }, + }); + + it('should throw an error if the action is not found', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server).post( + '/api/my-plugin/.backstage/v1/actions/test/invoke', + ); + + expect(status).toBe(404); + expect(body).toMatchObject({ + error: { + message: 'Action "test" not found', + }, + }); + }); + + it('should throw an error if the action input does not match the schema', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 123, + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: expect.stringMatching( + 'Invalid input to action "my-plugin:test"', + ), + }, + }); + }); + + it('should call the action with the input', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 'test', + }); + + expect(mockAction).toHaveBeenCalledWith({ + input: { + name: 'test', + }, + credentials: { + $$type: '@backstage/BackstageCredentials', + principal: { + type: 'user', + userEntityRef: 'user:default/mock', + }, + }, + logger: expect.anything(), + }); + }); + + it('should validate the output of the action if provided', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockResolvedValue({ ok: 'blob' }); + + const { body, status } = await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 'test', + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: expect.stringMatching( + 'Invalid output from action "my-plugin:test"', + ), + }, + }); + }); + + it('should return the output of the action', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + const { body, status } = await request(server) + .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .send({ + name: 'test', + }); + + expect(status).toBe(200); + expect(body).toMatchObject({ response: { ok: true } }); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts new file mode 100644 index 0000000000..424b60acc4 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ActionsRegistryActionOptions, + coreServices, + createServiceFactory, +} from '@backstage/backend-plugin-api'; +import { PluginActionsRegistry } from './PluginActionsRegistry'; +import { z, ZodType } from 'zod'; + +export const actionsRegistryServiceFactory = createServiceFactory({ + service: coreServices.actionsRegistry, + deps: { + metadata: coreServices.pluginMetadata, + httpRouter: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, + logger: coreServices.logger, + }, + factory: ({ metadata, httpRouter, httpAuth, logger }) => { + const pluginActionsRegistry = PluginActionsRegistry.create({ + httpAuth, + logger, + }); + + httpRouter.use(pluginActionsRegistry.getRouter()); + + return { + async register< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, + >(options: ActionsRegistryActionOptions) { + pluginActionsRegistry.register({ + ...options, + id: `${metadata.getId()}:${options.name}`, + schema: { + input: options.schema?.input?.(z), + output: options.schema?.output?.(z), + }, + }); + }, + }; + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts new file mode 100644 index 0000000000..ac65dd7a90 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 17ff606187..2f2c8fd552 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -63,7 +63,8 @@ "@types/express": "^4.17.6", "@types/luxon": "^3.0.0", "knex": "^3.0.0", - "luxon": "^3.0.0" + "luxon": "^3.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts new file mode 100644 index 0000000000..895eeb9338 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { z, ZodType } from 'zod'; +import { LoggerService } from './LoggerService'; +import { BackstageCredentials } from './AuthService'; + +export type ActionsRegistryActionContext = { + input: z.infer; + logger: LoggerService; + credentials: BackstageCredentials; +}; + +// todo: opaque type? +export type ActionsRegistryAction< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, +> = { + id: string; + name: string; + title: string; + description: string; + // todo: what about additional metadata? + schema?: { + input?: TInputSchema; + output?: TOutputSchema; + }; + action: ( + context: ActionsRegistryActionContext, + ) => Promise : void>; +}; + +export type ActionsRegistryActionOptions< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, +> = { + name: string; + title: string; + description: string; + // todo: what about additional metadata? + schema?: { + input?: (zod: typeof z) => TInputSchema; + output?: (zod: typeof z) => TOutputSchema; + }; + action: ( + context: ActionsRegistryActionContext, + ) => Promise : void>; +}; + +export interface ActionsRegistryService { + register( + options: ActionsRegistryActionOptions, + ): void; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 8c8c6c0f82..48ee4c084f 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -35,6 +35,21 @@ export namespace coreServices { id: 'core.auth', }); + /** + * Service for registering and managing actions. + * + * See {@link ActionsRegistryService} + * and {@link https://backstage.io/docs/backend-system/core-services/actions-registry | the service docs} + * for more information. + * + * @public + */ + export const actionsRegistry = createServiceRef< + import('./ActionsRegistryService').ActionsRegistryService + >({ + id: 'core.actionsRegistry', + }); + /** * Authenticated user information retrieval. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index d6346cbe4e..2967c7baab 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +export type { + ActionsRegistryService, + ActionsRegistryActionOptions, + ActionsRegistryActionContext, + ActionsRegistryAction, +} from './ActionsRegistryService'; export type { AuditorService, AuditorServiceCreateEventOptions, diff --git a/yarn.lock b/yarn.lock index 5276e3d24a..26bdf48fb7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3636,6 +3636,7 @@ __metadata: yauzl: "npm:^3.0.0" yn: "npm:^4.0.0" zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.20.4" peerDependencies: "@google-cloud/cloud-sql-connector": ^1.4.0 peerDependenciesMeta: @@ -3738,6 +3739,7 @@ __metadata: "@types/luxon": "npm:^3.0.0" knex: "npm:^3.0.0" luxon: "npm:^3.0.0" + zod: "npm:^3.22.4" languageName: unknown linkType: soft From 4d69ab51c60c75b885116da8f40e870f31c5fd35 Mon Sep 17 00:00:00 2001 From: Phred Date: Mon, 26 May 2025 11:57:56 -0500 Subject: [PATCH 38/49] reduced repetition of default params from test cases Signed-off-by: Phred --- .../tasks/NunjucksWorkflowRunner.test.ts | 132 +++--------------- 1 file changed, 20 insertions(+), 112 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 2d6ed2d57c..6d9692cf18 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -38,17 +38,16 @@ import { mockCredentials, mockServices, } from '@backstage/backend-test-utils'; -import { LoggerService } from '@backstage/backend-plugin-api'; import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger'; describe('NunjucksWorkflowRunner', () => { - let logger: LoggerService; - let actionRegistry = new TemplateActionRegistry(); + let actionRegistry: TemplateActionRegistry; let runner: NunjucksWorkflowRunner; let fakeActionHandler: jest.Mock; let fakeTaskLog: jest.Mock; let stripAnsi: typeof import('strip-ansi').default; + const logger = mockServices.logger.mock(); const mockDir = createMockDirectory(); const mockedPermissionApi: jest.Mocked = { @@ -71,11 +70,21 @@ describe('NunjucksWorkflowRunner', () => { }); const createMockTaskWithSpec = ( - spec: TaskSpec, + { + apiVersion = 'scaffolder.backstage.io/v1beta3', + output = {}, + parameters = {}, + ...spec + }: Partial, secrets?: TaskSecrets, isDryRun?: boolean, ): TaskContext => ({ - spec, + spec: { + apiVersion, + output, + parameters, + ...spec, + } as TaskSpec, secrets, isDryRun, complete: async () => {}, @@ -98,8 +107,6 @@ describe('NunjucksWorkflowRunner', () => { // This one is ESM-only stripAnsi = await import('strip-ansi').then(m => m.default); - jest.resetAllMocks(); - logger = mockServices.logger.mock(); actionRegistry = new TemplateActionRegistry(); fakeActionHandler = jest.fn(); fakeTaskLog = jest.fn(); @@ -234,11 +241,14 @@ describe('NunjucksWorkflowRunner', () => { }); }); + afterEach(() => { + mockDir.clear(); + + jest.resetAllMocks(); + }); + it('should throw an error if the action does not exist', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [{ id: 'test', name: 'name', action: 'does-not-exist' }], }); @@ -250,9 +260,6 @@ describe('NunjucksWorkflowRunner', () => { describe('validation', () => { it('should throw an error if the action has a schema and the input does not match', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [{ id: 'test', name: 'name', action: 'jest-validated-action' }], }); @@ -263,9 +270,6 @@ describe('NunjucksWorkflowRunner', () => { it('should throw an error if the action has a zod schema and the input does not match', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', name: 'name', action: 'jest-zod-validated-action' }, ], @@ -278,9 +282,6 @@ describe('NunjucksWorkflowRunner', () => { it('should throw an error if the action has legacy zod schema and the input does not match', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -297,9 +298,6 @@ describe('NunjucksWorkflowRunner', () => { it('should run the action when the zod validation passes', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -317,9 +315,6 @@ describe('NunjucksWorkflowRunner', () => { it('should run the action when the zod validation passes with legacy zod', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -337,9 +332,6 @@ describe('NunjucksWorkflowRunner', () => { it('should run the action when the validation passes', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -373,9 +365,6 @@ describe('NunjucksWorkflowRunner', () => { }; const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -404,9 +393,6 @@ describe('NunjucksWorkflowRunner', () => { it('should pass token through', async () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -433,7 +419,6 @@ describe('NunjucksWorkflowRunner', () => { describe('conditionals', () => { it('should execute steps conditionally', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', name: 'test', action: 'output-action' }, { @@ -446,7 +431,6 @@ describe('NunjucksWorkflowRunner', () => { output: { result: '${{ steps.conditional.output.mock }}', }, - parameters: {}, }); const { output } = await runner.execute(task); @@ -456,7 +440,6 @@ describe('NunjucksWorkflowRunner', () => { it('should skips steps conditionally', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', name: 'test', action: 'output-action' }, { @@ -469,7 +452,6 @@ describe('NunjucksWorkflowRunner', () => { output: { result: '${{ steps.conditional.output.mock }}', }, - parameters: {}, }); const { output } = await runner.execute(task); @@ -479,7 +461,6 @@ describe('NunjucksWorkflowRunner', () => { it('should skips steps using the negating equals operator', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', name: 'test', action: 'output-action' }, { @@ -492,7 +473,6 @@ describe('NunjucksWorkflowRunner', () => { output: { result: '${{ steps.conditional.output.mock }}', }, - parameters: {}, }); const { output } = await runner.execute(task); @@ -502,7 +482,6 @@ describe('NunjucksWorkflowRunner', () => { describe('should apply boolean step conditions', () => { it('executes when true', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'conditional', @@ -514,7 +493,6 @@ describe('NunjucksWorkflowRunner', () => { output: { result: '${{ steps.conditional.output.mock }}', }, - parameters: {}, }); const { output } = await runner.execute(task); @@ -522,7 +500,6 @@ describe('NunjucksWorkflowRunner', () => { }); it('skips when false', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'conditional', @@ -534,7 +511,6 @@ describe('NunjucksWorkflowRunner', () => { output: { result: '${{ steps.conditional.output.mock }}', }, - parameters: {}, }); const { output } = await runner.execute(task); @@ -546,7 +522,6 @@ describe('NunjucksWorkflowRunner', () => { describe('templating', () => { it('should template the input to an action', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -557,7 +532,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { input: 'BACKSTAGE', }, @@ -573,7 +547,6 @@ describe('NunjucksWorkflowRunner', () => { it('should not try and parse something that is not parsable', async () => { jest.spyOn(logger, 'error'); const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -584,7 +557,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { input: 'BACKSTAGE', }, @@ -597,7 +569,6 @@ describe('NunjucksWorkflowRunner', () => { it('should keep the original types for the input and not parse things that are not meant to be parsed', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -609,7 +580,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { number: 0, string: '1', @@ -625,7 +595,6 @@ describe('NunjucksWorkflowRunner', () => { it('should template complex values into the action', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -636,7 +605,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { complex: { bar: 'BACKSTAGE' }, }, @@ -651,7 +619,6 @@ describe('NunjucksWorkflowRunner', () => { it('supports really complex structures', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -662,7 +629,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { complex: { bar: 'BACKSTAGE', @@ -680,7 +646,6 @@ describe('NunjucksWorkflowRunner', () => { it('supports numbers as first class too', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -691,7 +656,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { complex: { bar: 'BACKSTAGE', @@ -710,8 +674,6 @@ describe('NunjucksWorkflowRunner', () => { it('should deal with checkpoints', async () => { const task = { ...createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, steps: [ { id: 'test', @@ -762,7 +724,6 @@ describe('NunjucksWorkflowRunner', () => { it('should template the output from simple actions', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -774,7 +735,6 @@ describe('NunjucksWorkflowRunner', () => { output: { foo: '${{steps.test.output.mock | upper}}', }, - parameters: {}, }); const { output } = await runner.execute(task); @@ -784,7 +744,6 @@ describe('NunjucksWorkflowRunner', () => { it('should include task ID in the templated context', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -797,8 +756,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, - parameters: {}, }); await runner.execute(task); @@ -836,9 +793,6 @@ describe('NunjucksWorkflowRunner', () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -882,9 +836,6 @@ describe('NunjucksWorkflowRunner', () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -909,7 +860,6 @@ describe('NunjucksWorkflowRunner', () => { it('should run a step repeatedly - flat values', async () => { const colors = ['blue', 'green', 'red']; const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -919,7 +869,6 @@ describe('NunjucksWorkflowRunner', () => { input: { color: '${{each.value}}' }, }, ], - output: {}, parameters: { colors, }, @@ -938,7 +887,6 @@ describe('NunjucksWorkflowRunner', () => { it('should run a step repeatedly - object list', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -951,7 +899,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, parameters: { settings: [{ color: 'blue' }], }, @@ -974,7 +921,6 @@ describe('NunjucksWorkflowRunner', () => { transparent: 'yes', }; const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -984,7 +930,6 @@ describe('NunjucksWorkflowRunner', () => { input: { key: '${{each.key}}', value: '${{each.value}}' }, }, ], - output: {}, parameters: { settings, }, @@ -1006,7 +951,6 @@ describe('NunjucksWorkflowRunner', () => { it('should run a step repeatedly with validation of single-expression value', async () => { const numbers = [5, 7, 9]; const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1016,7 +960,6 @@ describe('NunjucksWorkflowRunner', () => { input: { foo: '${{each.value}}' }, }, ], - output: {}, parameters: { numbers, }, @@ -1037,7 +980,6 @@ describe('NunjucksWorkflowRunner', () => { it('should validate each action iteration', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1047,7 +989,6 @@ describe('NunjucksWorkflowRunner', () => { input: { foo: '${{each.value.foo}}' }, }, ], - output: {}, parameters: { data: [ { @@ -1065,7 +1006,6 @@ describe('NunjucksWorkflowRunner', () => { it('should validate each parameter renders to a valid value', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1075,8 +1015,6 @@ describe('NunjucksWorkflowRunner', () => { input: { foo: '${{each.value}}' }, }, ], - output: {}, - parameters: {}, }); await expect(runner.execute(task)).rejects.toThrow( 'Invalid value on action jest-validated-action.each parameter, "${{parameters.data}}" cannot be resolved to a value', @@ -1089,7 +1027,6 @@ describe('NunjucksWorkflowRunner', () => { it('should pass through the secrets to the context', async () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1098,8 +1035,6 @@ describe('NunjucksWorkflowRunner', () => { input: {}, }, ], - output: {}, - parameters: {}, }, { foo: 'bar' }, ); @@ -1114,7 +1049,6 @@ describe('NunjucksWorkflowRunner', () => { it('should be able to template secrets into the input of an action', async () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1125,8 +1059,6 @@ describe('NunjucksWorkflowRunner', () => { }, }, ], - output: {}, - parameters: {}, }, { foo: 'bar' }, ); @@ -1141,7 +1073,6 @@ describe('NunjucksWorkflowRunner', () => { it('does not allow templating of secrets as an output', async () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1155,7 +1086,6 @@ describe('NunjucksWorkflowRunner', () => { output: { b: '${{ secrets.foo }}', }, - parameters: {}, }, { foo: 'bar' }, ); @@ -1169,7 +1099,6 @@ describe('NunjucksWorkflowRunner', () => { describe('user', () => { it('allows access to the user entity at the templating level', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1199,7 +1128,6 @@ describe('NunjucksWorkflowRunner', () => { describe('filters', () => { it('provides the parseRepoUrl filter', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1228,7 +1156,6 @@ describe('NunjucksWorkflowRunner', () => { describe('parseEntityRef', () => { it('parses entity ref', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1256,7 +1183,6 @@ describe('NunjucksWorkflowRunner', () => { it('provides default kind for parsing entity ref', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1284,7 +1210,6 @@ describe('NunjucksWorkflowRunner', () => { it('provides default namespace for parsing entity ref', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1312,7 +1237,6 @@ describe('NunjucksWorkflowRunner', () => { it('provides default kind and namespace for parsing entity ref', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1342,7 +1266,6 @@ describe('NunjucksWorkflowRunner', () => { 'ignores invalid context "%s" for parsing entity refF', async kind => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1371,7 +1294,6 @@ describe('NunjucksWorkflowRunner', () => { it('fails when unable to parse entity ref', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1398,7 +1320,6 @@ describe('NunjucksWorkflowRunner', () => { it('provides the pick filter', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1422,7 +1343,6 @@ describe('NunjucksWorkflowRunner', () => { it('should allow deep nesting of picked objects', async () => { const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', steps: [ { id: 'test', @@ -1455,9 +1375,6 @@ describe('NunjucksWorkflowRunner', () => { it('sets isDryRun flag correctly', async () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -1481,13 +1398,10 @@ describe('NunjucksWorkflowRunner', () => { it('should have metadata in action context during dry run', async () => { const task = createMockTaskWithSpec( { - apiVersion: 'scaffolder.backstage.io/v1beta3', templateInfo: { entityRef: 'dryRun-Entity', entity: { metadata: { name: 'test-template' } }, }, - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -1519,9 +1433,6 @@ describe('NunjucksWorkflowRunner', () => { ]); const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test', @@ -1560,9 +1471,6 @@ describe('NunjucksWorkflowRunner', () => { ]); const task = createMockTaskWithSpec({ - apiVersion: 'scaffolder.backstage.io/v1beta3', - parameters: {}, - output: {}, steps: [ { id: 'test1', From 4e8f01357e71d745e0ea87fd55bf3b67eaafc088 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 11:52:39 +0200 Subject: [PATCH 39/49] feat: some tweaks to the actions registry and implementing some of the actions client Signed-off-by: benjdlambert --- app-config.yaml | 1 - packages/backend-defaults/config.d.ts | 10 ++ .../actions/DefaultActionsService.ts | 99 +++++++++++++++++++ .../actions/actionsServiceFactory.ts | 33 +++++++ .../src/entrypoints/actions/index.ts | 15 +++ ...ry.ts => DefaultActionsRegistryService.ts} | 64 ++++++++---- .../actionsRegistryServiceFactory.test.ts | 89 ++++++++++++++--- .../actionsRegistryServiceFactory.ts | 29 ++---- packages/backend-plugin-api/package.json | 2 + .../definitions/ActionsRegistryService.ts | 24 +---- .../services/definitions/ActionsService.ts | 36 +++++++ .../src/services/definitions/coreServices.ts | 17 +++- .../src/services/definitions/index.ts | 3 +- yarn.lock | 2 + 14 files changed, 348 insertions(+), 76 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts create mode 100644 packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts create mode 100644 packages/backend-defaults/src/entrypoints/actions/index.ts rename packages/backend-defaults/src/entrypoints/actionsRegistry/{PluginActionsRegistry.ts => DefaultActionsRegistryService.ts} (58%) create mode 100644 packages/backend-plugin-api/src/services/definitions/ActionsService.ts diff --git a/app-config.yaml b/app-config.yaml index 89e417fad3..5651b03f91 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -35,7 +35,6 @@ backend: # auth: # keys: # - secret: ${BACKEND_SECRET} - auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 14732bbccd..1ef054a537 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -121,6 +121,16 @@ export interface Config { }; }; + /** + * Options used by the default actions service. + */ + actions?: { + /** + * List of plugin sources to load actions from. + */ + pluginSources?: string[]; + }; + /** * Options used by the default auth, httpAuth and userInfo services. */ diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts new file mode 100644 index 0000000000..73920015a0 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + ActionsRegistryAction, + ActionsService, + ActionsServiceAction, + DiscoveryService, + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { NotFoundError, ResponseError } from '@backstage/errors'; +import { JsonObject } from '@backstage/types'; + +export class DefaultActionsService implements ActionsService { + private constructor( + private readonly discovery: DiscoveryService, + private readonly config: RootConfigService, + private readonly logger: LoggerService, + ) {} + + static create({ + discovery, + config, + logger, + }: { + discovery: DiscoveryService; + config: RootConfigService; + logger: LoggerService; + }) { + return new DefaultActionsService(discovery, config, logger); + } + + async listActions() { + const pluginSources = + this.config.getOptionalStringArray('actions.pluginSources') ?? []; + + const remoteActionsList = await Promise.all( + pluginSources.map(async source => { + const pluginBaseUrl = await this.discovery.getBaseUrl(source); + const response = await fetch(`${pluginBaseUrl}/.backstage/v1/actions`); + if (!response.ok) { + this.logger.warn(`Failed to fetch actions from ${source}`); + return []; + } + + const { actions } = (await response.json()) as { + actions: Omit, 'action'>[]; + }; + + return actions; + }), + ); + + return { actions: remoteActionsList.flat() }; + } + + async invokeAction(opts: { id: string; input?: JsonObject }) { + const pluginId = this.pluginIdFromActionId(opts.id); + + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const response = await fetch( + `${baseUrl}/.backstage/v1/actions/${opts.id}/invoke`, + { + method: 'POST', + body: JSON.stringify(opts.input), + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + const { output } = await response.json(); + return { output }; + } + + private pluginIdFromActionId(id: string): string { + const colonIndex = id.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid action id: ${id}`); + } + return id.substring(0, colonIndex); + } +} diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts new file mode 100644 index 0000000000..721769b847 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { coreServices } from '@backstage/backend-plugin-api'; +import { DefaultActionsService } from './DefaultActionsService'; + +export const actionsServiceFactory = createServiceFactory({ + service: coreServices.actions, + deps: { + discovery: coreServices.discovery, + config: coreServices.rootConfig, + logger: coreServices.logger, + }, + factory: ({ discovery, config, logger }) => + DefaultActionsService.create({ + discovery, + config, + logger, + }), +}); diff --git a/packages/backend-defaults/src/entrypoints/actions/index.ts b/packages/backend-defaults/src/entrypoints/actions/index.ts new file mode 100644 index 0000000000..379ce77f52 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts similarity index 58% rename from packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts rename to packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index 8dedf40658..fb3a5cb396 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/PluginActionsRegistry.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -15,25 +15,30 @@ */ import { - ActionsRegistryAction, + ActionsRegistryActionOptions, + ActionsRegistryService, + AuthService, HttpAuthService, LoggerService, + PluginMetadataService, } from '@backstage/backend-plugin-api'; import PromiseRouter from 'express-promise-router'; import { Router, json } from 'express'; import { z, ZodType } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; -import { InputError, NotFoundError } from '@backstage/errors'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; -export class PluginActionsRegistry { +export class DefaultActionsRegistryService implements ActionsRegistryService { private constructor( private readonly actions: Map< string, - ActionsRegistryAction + ActionsRegistryActionOptions >, private readonly router: Router, private readonly logger: LoggerService, private readonly httpAuth: HttpAuthService, + private readonly auth: AuthService, + private readonly metadata: PluginMetadataService, ) { this.bindRoutes(); } @@ -41,15 +46,21 @@ export class PluginActionsRegistry { static create({ httpAuth, logger, + auth, + metadata, }: { httpAuth: HttpAuthService; logger: LoggerService; - }): PluginActionsRegistry { - return new PluginActionsRegistry( + auth: AuthService; + metadata: PluginMetadataService; + }): DefaultActionsRegistryService { + return new DefaultActionsRegistryService( new Map(), PromiseRouter(), logger, httpAuth, + auth, + metadata, ); } @@ -58,24 +69,31 @@ export class PluginActionsRegistry { } register( - options: ActionsRegistryAction, + options: ActionsRegistryActionOptions, ): void { - this.actions.set(options.id, options as ActionsRegistryAction); + const id = `${this.metadata.getId()}:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); } private bindRoutes() { this.router.use(json()); - this.router.get('/.backstage/v1/actions', (_, res) => { + this.router.get('/.backstage/actions/v1/actions', (_, res) => { return res.json({ - actions: Array.from(this.actions.entries()).map(([_id, action]) => ({ + actions: Array.from(this.actions.entries()).map(([id, action]) => ({ + id, ...action, schema: { input: action.schema?.input - ? zodToJsonSchema(action.schema.input) + ? zodToJsonSchema(action.schema.input(z)) : zodToJsonSchema(z.any()), output: action.schema?.output - ? zodToJsonSchema(action.schema.output) + ? zodToJsonSchema(action.schema.output(z)) : zodToJsonSchema(z.any()), }, })), @@ -83,7 +101,7 @@ export class PluginActionsRegistry { }); this.router.post( - '/.backstage/v1/actions/:actionId/invoke', + '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const action = this.actions.get(req.params.actionId); @@ -92,7 +110,7 @@ export class PluginActionsRegistry { } const input = action.schema?.input - ? action.schema.input.safeParse(req.body) + ? action.schema.input(z).safeParse(req.body) : ({ success: true, data: undefined } as const); if (!input.success) { @@ -103,7 +121,19 @@ export class PluginActionsRegistry { } const credentials = await this.httpAuth.credentials(req); + if (this.auth.isPrincipal(credentials, 'user')) { + if (!credentials.principal.actor) { + throw new NotAllowedError( + `Actions must be invoked by a service, not a user`, + ); + } + } else if (this.auth.isPrincipal(credentials, 'none')) { + throw new NotAllowedError( + `Actions must be invoked by a service, not an anonymous request`, + ); + } + // todo: wrap up in forwardederror? const result = await action.action({ input: input.data, credentials, @@ -111,8 +141,8 @@ export class PluginActionsRegistry { }); const output = action.schema?.output - ? action.schema.output.safeParse(result) - : ({ success: true, data: result } as const); + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); if (!output.success) { throw new InputError( @@ -121,7 +151,7 @@ export class PluginActionsRegistry { ); } - return res.json({ response: output.data }); + return res.json({ output: output.data }); }, ); } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index b3b518a8e9..88f72239d0 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -25,13 +25,14 @@ import { import { httpRouterServiceFactory } from '../httpRouter'; import request from 'supertest'; import { actionsRegistryServiceFactory } from './actionsRegistryServiceFactory'; +import { InputError } from '@backstage/errors'; describe('actionsRegistryServiceFactory', () => { const defaultServices = [ actionsRegistryServiceFactory, httpRouterServiceFactory, mockServices.httpAuth.factory({ - defaultCredentials: mockCredentials.user(), + defaultCredentials: mockCredentials.service('user:default/mock'), }), ]; @@ -110,7 +111,7 @@ describe('actionsRegistryServiceFactory', () => { }); }); - describe('/.backstage/v1/actions', () => { + describe('/.backstage/actions/v1/actions', () => { it('should allow registering of actions', async () => { const pluginSubject = createBackendPlugin({ pluginId: 'my-plugin', @@ -146,7 +147,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).get( - '/api/my-plugin/.backstage/v1/actions', + '/api/my-plugin/.backstage/actions/v1/actions', ); expect(status).toBe(200); @@ -205,7 +206,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).get( - '/api/my-plugin/.backstage/v1/actions', + '/api/my-plugin/.backstage/actions/v1/actions', ); expect(status).toBe(200); @@ -226,11 +227,11 @@ describe('actionsRegistryServiceFactory', () => { }); }); - describe('/.backstage/v1/actions/:actionId/invoke', () => { + describe('/.backstage/actions/v1/actions/:actionId/invoke', () => { const mockAction = jest.fn(); beforeEach(() => { - mockAction.mockResolvedValue({ ok: true }); + mockAction.mockResolvedValue({ output: { ok: true } }); }); const pluginSubject = createBackendPlugin({ @@ -268,7 +269,7 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server).post( - '/api/my-plugin/.backstage/v1/actions/test/invoke', + '/api/my-plugin/.backstage/actions/v1/actions/test/invoke', ); expect(status).toBe(404); @@ -285,7 +286,9 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 123, }); @@ -306,7 +309,9 @@ describe('actionsRegistryServiceFactory', () => { }); await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); @@ -318,14 +323,43 @@ describe('actionsRegistryServiceFactory', () => { credentials: { $$type: '@backstage/BackstageCredentials', principal: { - type: 'user', - userEntityRef: 'user:default/mock', + type: 'service', + subject: 'user:default/mock', }, }, logger: expect.anything(), }); }); + it('should throw an error if the action is invoked by a user', async () => { + const testServices = [ + actionsRegistryServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user(), + }), + ]; + + const { server } = await startTestBackend({ + features: [pluginSubject, ...testServices], + }); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(403); + expect(body).toMatchObject({ + error: { + message: 'Actions must be invoked by a service, not a user', + }, + }); + }); + it('should validate the output of the action if provided', async () => { const { server } = await startTestBackend({ features: [pluginSubject, ...defaultServices], @@ -334,7 +368,9 @@ describe('actionsRegistryServiceFactory', () => { mockAction.mockResolvedValue({ ok: 'blob' }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); @@ -355,13 +391,38 @@ describe('actionsRegistryServiceFactory', () => { }); const { body, status } = await request(server) - .post('/api/my-plugin/.backstage/v1/actions/my-plugin:test/invoke') + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) .send({ name: 'test', }); expect(status).toBe(200); - expect(body).toMatchObject({ response: { ok: true } }); + expect(body).toMatchObject({ output: { ok: true } }); + }); + + it('should return the error from the action if it throws', async () => { + const { server } = await startTestBackend({ + features: [pluginSubject, ...defaultServices], + }); + + mockAction.mockRejectedValue(new InputError('test')); + + const { body, status } = await request(server) + .post( + '/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + ) + .send({ + name: 'test', + }); + + expect(status).toBe(400); + expect(body).toMatchObject({ + error: { + message: 'test', + }, + }); }); }); }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 424b60acc4..d894694091 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -15,12 +15,10 @@ */ import { - ActionsRegistryActionOptions, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; -import { PluginActionsRegistry } from './PluginActionsRegistry'; -import { z, ZodType } from 'zod'; +import { DefaultActionsRegistryService } from './DefaultActionsRegistryService'; export const actionsRegistryServiceFactory = createServiceFactory({ service: coreServices.actionsRegistry, @@ -29,29 +27,18 @@ export const actionsRegistryServiceFactory = createServiceFactory({ httpRouter: coreServices.httpRouter, httpAuth: coreServices.httpAuth, logger: coreServices.logger, + auth: coreServices.auth, }, - factory: ({ metadata, httpRouter, httpAuth, logger }) => { - const pluginActionsRegistry = PluginActionsRegistry.create({ + factory: ({ metadata, httpRouter, httpAuth, logger, auth }) => { + const actionsRegistryService = DefaultActionsRegistryService.create({ httpAuth, logger, + auth, + metadata, }); - httpRouter.use(pluginActionsRegistry.getRouter()); + httpRouter.use(actionsRegistryService.getRouter()); - return { - async register< - TInputSchema extends ZodType, - TOutputSchema extends ZodType, - >(options: ActionsRegistryActionOptions) { - pluginActionsRegistry.register({ - ...options, - id: `${metadata.getId()}:${options.name}`, - schema: { - input: options.schema?.input?.(z), - output: options.schema?.output?.(z), - }, - }); - }, - }; + return actionsRegistryService; }, }); diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 2f2c8fd552..666c703ed7 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -61,7 +61,9 @@ "@backstage/plugin-permission-node": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", + "@types/json-schema": "^7.0.6", "@types/luxon": "^3.0.0", + "json-schema": "^0.4.0", "knex": "^3.0.0", "luxon": "^3.0.0", "zod": "^3.22.4" diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 895eeb9338..9240891227 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -23,25 +23,6 @@ export type ActionsRegistryActionContext = { credentials: BackstageCredentials; }; -// todo: opaque type? -export type ActionsRegistryAction< - TInputSchema extends ZodType, - TOutputSchema extends ZodType, -> = { - id: string; - name: string; - title: string; - description: string; - // todo: what about additional metadata? - schema?: { - input?: TInputSchema; - output?: TOutputSchema; - }; - action: ( - context: ActionsRegistryActionContext, - ) => Promise : void>; -}; - export type ActionsRegistryActionOptions< TInputSchema extends ZodType, TOutputSchema extends ZodType, @@ -49,14 +30,15 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - // todo: what about additional metadata? schema?: { input?: (zod: typeof z) => TInputSchema; output?: (zod: typeof z) => TOutputSchema; }; action: ( context: ActionsRegistryActionContext, - ) => Promise : void>; + ) => Promise< + TOutputSchema extends ZodType ? { output: z.infer } : void + >; }; export interface ActionsRegistryService { diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts new file mode 100644 index 0000000000..7887546878 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { JsonObject, JsonValue } from '@backstage/types'; +import { JSONSchema7 } from 'json-schema'; + +export type ActionsServiceAction = { + id: string; + name: string; + title: string; + description: string; + schema?: { + input?: JSONSchema7; + output?: JSONSchema7; + }; +}; + +export interface ActionsService { + listActions: () => Promise<{ actions: ActionsServiceAction[] }>; + invokeAction(opts: { + id: string; + input?: JsonObject; + }): Promise<{ output: JsonValue }>; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 48ee4c084f..c5282368ac 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -36,7 +36,22 @@ export namespace coreServices { }); /** - * Service for registering and managing actions. + * Service for calling distributed actions + * + * See {@link ActionsService} + * and {@link https://backstage.io/docs/backend-system/core-services/actions | the service docs} + * for more information. + * + * @public + */ + export const actions = createServiceRef< + import('./ActionsService').ActionsService + >({ + id: 'core.actions', + }); + + /** + * Service for registering and managing distributed actions. * * See {@link ActionsRegistryService} * and {@link https://backstage.io/docs/backend-system/core-services/actions-registry | the service docs} diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 2967c7baab..a01544b60f 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -18,8 +18,9 @@ export type { ActionsRegistryService, ActionsRegistryActionOptions, ActionsRegistryActionContext, - ActionsRegistryAction, } from './ActionsRegistryService'; + +export type { ActionsService, ActionsServiceAction } from './ActionsService'; export type { AuditorService, AuditorServiceCreateEventOptions, diff --git a/yarn.lock b/yarn.lock index 26bdf48fb7..1809a03667 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3736,7 +3736,9 @@ __metadata: "@backstage/plugin-permission-node": "workspace:^" "@backstage/types": "workspace:^" "@types/express": "npm:^4.17.6" + "@types/json-schema": "npm:^7.0.6" "@types/luxon": "npm:^3.0.0" + json-schema: "npm:^0.4.0" knex: "npm:^3.0.0" luxon: "npm:^3.0.0" zod: "npm:^3.22.4" From 6513bba7101329e524faf8ca640f3b932b5a29c5 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:25:16 +0200 Subject: [PATCH 40/49] feat: finishing implementation of the actions client Signed-off-by: benjdlambert --- packages/backend-defaults/package.json | 4 + .../backend-defaults/src/CreateBackend.ts | 2 + .../actions/DefaultActionsService.ts | 62 +++- .../actions/actionsServiceFactory.test.ts | 330 ++++++++++++++++++ .../actions/actionsServiceFactory.ts | 4 +- .../src/entrypoints/actions/index.ts | 1 + .../actionsRegistryServiceFactory.test.ts | 8 +- 7 files changed, 394 insertions(+), 17 deletions(-) create mode 100644 packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 67507dd606..7c918b4dc4 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -20,6 +20,7 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", + "./actions": "./src/entrypoints/actions/index.ts", "./actionsRegistry": "./src/entrypoints/actionsRegistry/index.ts", "./auditor": "./src/entrypoints/auditor/index.ts", "./auth": "./src/entrypoints/auth/index.ts", @@ -46,6 +47,9 @@ "types": "src/index.ts", "typesVersions": { "*": { + "actions": [ + "src/entrypoints/actions/index.ts" + ], "actionsRegistry": [ "src/entrypoints/actionsRegistry/index.ts" ], diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 30a913af68..032978c806 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -36,9 +36,11 @@ import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader'; import { userInfoServiceFactory } from '@backstage/backend-defaults/userInfo'; import { eventsServiceFactory } from '@backstage/plugin-events-node'; import { actionsRegistryServiceFactory } from './entrypoints/actionsRegistry'; +import { actionsServiceFactory } from './entrypoints/actions'; export const defaultServiceFactories = [ actionsRegistryServiceFactory, + actionsServiceFactory, auditorServiceFactory, authServiceFactory, cacheServiceFactory, diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index 73920015a0..de3959ec6c 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -14,14 +14,15 @@ * limitations under the License. */ import { - ActionsRegistryAction, ActionsService, ActionsServiceAction, + AuthService, + BackstageCredentials, DiscoveryService, LoggerService, RootConfigService, } from '@backstage/backend-plugin-api'; -import { NotFoundError, ResponseError } from '@backstage/errors'; +import { ResponseError } from '@backstage/errors'; import { JsonObject } from '@backstage/types'; export class DefaultActionsService implements ActionsService { @@ -29,35 +30,43 @@ export class DefaultActionsService implements ActionsService { private readonly discovery: DiscoveryService, private readonly config: RootConfigService, private readonly logger: LoggerService, + private readonly auth: AuthService, ) {} static create({ discovery, config, logger, + auth, }: { discovery: DiscoveryService; config: RootConfigService; logger: LoggerService; + auth: AuthService; }) { - return new DefaultActionsService(discovery, config, logger); + return new DefaultActionsService(discovery, config, logger, auth); } - async listActions() { + async listActions({ credentials }: { credentials: BackstageCredentials }) { const pluginSources = - this.config.getOptionalStringArray('actions.pluginSources') ?? []; + this.config.getOptionalStringArray('backend.actions.pluginSources') ?? []; const remoteActionsList = await Promise.all( pluginSources.map(async source => { const pluginBaseUrl = await this.discovery.getBaseUrl(source); - const response = await fetch(`${pluginBaseUrl}/.backstage/v1/actions`); + const response = await this.makeRequest({ + url: `${pluginBaseUrl}/.backstage/actions/v1/actions`, + pluginId: source, + credentials, + }); + if (!response.ok) { this.logger.warn(`Failed to fetch actions from ${source}`); return []; } const { actions } = (await response.json()) as { - actions: Omit, 'action'>[]; + actions: ActionsServiceAction; }; return actions; @@ -67,28 +76,57 @@ export class DefaultActionsService implements ActionsService { return { actions: remoteActionsList.flat() }; } - async invokeAction(opts: { id: string; input?: JsonObject }) { + async invokeAction(opts: { + id: string; + input?: JsonObject; + credentials: BackstageCredentials; + }) { const pluginId = this.pluginIdFromActionId(opts.id); const baseUrl = await this.discovery.getBaseUrl(pluginId); - const response = await fetch( - `${baseUrl}/.backstage/v1/actions/${opts.id}/invoke`, - { + + const response = await this.makeRequest({ + url: `${baseUrl}/.backstage/actions/v1/actions/${opts.id}/invoke`, + pluginId, + credentials: opts.credentials, + options: { method: 'POST', body: JSON.stringify(opts.input), headers: { 'Content-Type': 'application/json', }, }, - ); + }); if (!response.ok) { throw await ResponseError.fromResponse(response); } + const { output } = await response.json(); return { output }; } + private async makeRequest(opts: { + url: string; + pluginId: string; + options?: RequestInit; + credentials: BackstageCredentials; + }) { + const { url, credentials, options } = opts; + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: credentials, + targetPluginId: opts.pluginId, + }); + + return fetch(url, { + ...options, + headers: { + ...options?.headers, + Authorization: `Bearer ${token}`, + }, + }); + } + private pluginIdFromActionId(id: string): string { const colonIndex = id.indexOf(':'); if (colonIndex === -1) { diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts new file mode 100644 index 0000000000..f1bb54a184 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -0,0 +1,330 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + mockCredentials, + mockServices, + registerMswTestHooks, + ServiceFactoryTester, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { actionsRegistryServiceFactory } from '../actionsRegistry'; +import { httpRouterServiceFactory } from '../httpRouter'; +import { actionsServiceFactory } from './actionsServiceFactory'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { + ActionsServiceAction, + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { json } from 'express'; +import Router from 'express-promise-router'; +import request from 'supertest'; + +const server = setupServer(); + +describe('actionsServiceFactory', () => { + describe('client', () => { + registerMswTestHooks(server); + const mockActionsListEndpoint = jest.fn(); + const mockNotFoundActionsListEndpoint = jest.fn(); + + const defaultServices = [ + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { + pluginSources: ['my-plugin', 'not-found-plugin'], + }, + }, + }, + }), + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + mockServices.discovery.factory(), + actionsRegistryServiceFactory, + ]; + + const mockActionsDefinition: ActionsServiceAction = { + description: 'my mock description', + id: 'my-plugin:test', + name: 'testy', + title: 'Test', + }; + + beforeEach(() => { + server.use( + rest.get( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions', + mockActionsListEndpoint.mockImplementation((_req, res, ctx) => + res( + ctx.json({ + actions: [mockActionsDefinition], + }), + ), + ), + ), + rest.get( + 'http://localhost:0/api/not-found-plugin/.backstage/actions/v1/actions', + mockNotFoundActionsListEndpoint.mockImplementation((_req, res, ctx) => + res(ctx.status(404)), + ), + ), + ); + }); + + describe('listActions', () => { + it('should list all plugins in config to find actions and handle failures gracefully', async () => { + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + const { actions } = await subject.listActions({ + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(actions).toEqual([mockActionsDefinition]); + + expect(mockActionsListEndpoint).toHaveBeenCalledTimes(1); + expect(mockNotFoundActionsListEndpoint).toHaveBeenCalledTimes(1); + }); + }); + + describe('invokeAction', () => { + it('should invoke the action and return the output', async () => { + server.use( + rest.post( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + (_req, res, ctx) => res(ctx.json({ output: { ok: true } })), + ), + ); + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + const { output } = await subject.invokeAction({ + id: 'my-plugin:test', + credentials: mockCredentials.service('user:default/mock'), + }); + + expect(output).toEqual({ ok: true }); + }); + + it('should throw a 404 if the action does not exist', async () => { + server.use( + rest.post( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + (_req, res, ctx) => res(ctx.status(404)), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + await expect( + subject.invokeAction({ + id: 'my-plugin:test', + credentials: mockCredentials.service('user:default/mock'), + }), + ).rejects.toThrow('404'); + }); + + it('should throw a 400 if the action returns an invalid input', async () => { + server.use( + rest.post( + 'http://localhost:0/api/my-plugin/.backstage/actions/v1/actions/my-plugin:test/invoke', + (_req, res, ctx) => res(ctx.status(400)), + ), + ); + + const subject = await ServiceFactoryTester.from(actionsServiceFactory, { + dependencies: defaultServices, + }).getSubject(); + + await expect( + subject.invokeAction({ + id: 'my-plugin:test', + credentials: mockCredentials.service('user:default/mock'), + }), + ).rejects.toThrow('400'); + }); + }); + + describe('integration', () => { + beforeAll(() => { + // disable the msw server for this test. + server.close(); + }); + + const features = [ + actionsServiceFactory, + httpRouterServiceFactory, + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.service('user:default/mock'), + }), + actionsRegistryServiceFactory, + mockServices.rootConfig.factory({ + data: { + backend: { + actions: { pluginSources: ['plugin-with-action'] }, + }, + }, + }), + + createBackendPlugin({ + pluginId: 'plugin-with-action', + register({ registerInit }) { + registerInit({ + deps: { actionsRegistry: coreServices.actionsRegistry }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'with-validation', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + ok: z.boolean(), + string: z.string(), + }), + }, + action: async ({ input }) => { + return { + output: { + ok: true, + string: `hello ${input.name}`, + }, + }; + }, + }); + }, + }); + }, + }), + createBackendPlugin({ + pluginId: 'test-harness', + register({ registerInit }) { + registerInit({ + deps: { + actionsService: coreServices.actions, + router: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, + }, + async init({ actionsService, router, httpAuth }) { + const innerRouter = Router(); + innerRouter.use(json()); + router.use(innerRouter); + + innerRouter.post('/invoke', async (req, res) => { + const { id, input } = req.body; + const response = await actionsService.invokeAction({ + id, + input, + credentials: await httpAuth.credentials(req), + }); + res.json(response); + }); + + innerRouter.get('/actions', async (req, res) => { + const response = await actionsService.listActions({ + credentials: await httpAuth.credentials(req), + }); + + res.json(response); + }); + }, + }); + }, + }), + ]; + + it('should list the actions and return the output', async () => { + const { server: testHarnessServer } = await startTestBackend({ + features, + }); + + const { body, status } = await request(testHarnessServer).get( + '/api/test-harness/actions', + ); + + expect(status).toBe(200); + expect(body).toEqual({ + actions: [ + { + description: 'Test', + id: 'plugin-with-action:with-validation', + name: 'with-validation', + schema: { + input: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], + type: 'object', + }, + output: { + $schema: 'http://json-schema.org/draft-07/schema#', + additionalProperties: false, + properties: { + ok: { + type: 'boolean', + }, + string: { + type: 'string', + }, + }, + required: ['ok', 'string'], + type: 'object', + }, + }, + title: 'Test', + }, + ], + }); + }); + + it('should invoke the action and return the output', async () => { + const { server: testHarnessServer } = await startTestBackend({ + features, + }); + + const { body, status } = await request(testHarnessServer) + .post('/api/test-harness/invoke') + .send({ + id: 'plugin-with-action:with-validation', + input: { + name: 'world', + }, + }); + + expect(body).toEqual({ output: { ok: true, string: 'hello world' } }); + expect(status).toBe(200); + }); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts index 721769b847..de1f1ef15d 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -23,11 +23,13 @@ export const actionsServiceFactory = createServiceFactory({ discovery: coreServices.discovery, config: coreServices.rootConfig, logger: coreServices.logger, + auth: coreServices.auth, }, - factory: ({ discovery, config, logger }) => + factory: ({ discovery, config, logger, auth }) => DefaultActionsService.create({ discovery, config, logger, + auth, }), }); diff --git a/packages/backend-defaults/src/entrypoints/actions/index.ts b/packages/backend-defaults/src/entrypoints/actions/index.ts index 379ce77f52..bc34150a5f 100644 --- a/packages/backend-defaults/src/entrypoints/actions/index.ts +++ b/packages/backend-defaults/src/entrypoints/actions/index.ts @@ -13,3 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export { actionsServiceFactory } from './actionsServiceFactory'; diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 88f72239d0..9efd29572c 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -63,7 +63,7 @@ describe('actionsRegistryServiceFactory', () => { action: async ({ input: { test } }) => { // @ts-expect-error - test is not a boolean const _t: boolean = test; - return { ok: true }; + return { output: { ok: true } }; }, }); }, @@ -99,7 +99,7 @@ describe('actionsRegistryServiceFactory', () => { }, // @ts-expect-error - ok is not a boolean action: async () => { - return { ok: 'bo' }; + return { output: { ok: 'bo' } }; }, }); }, @@ -135,7 +135,7 @@ describe('actionsRegistryServiceFactory', () => { ok: z.boolean(), }), }, - action: async () => ({ ok: true }), + action: async () => ({ output: { ok: true } }), }); }, }); @@ -194,7 +194,7 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', title: 'Test', description: 'Test', - action: async () => ({ ok: true }), + action: async () => ({ output: { ok: true } }), }); }, }); From 1db36159f59af9b1dc5e97456b0b4a8a65bb105f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:25:43 +0200 Subject: [PATCH 41/49] chore: include credentials Signed-off-by: benjdlambert --- .../src/services/definitions/ActionsService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 7887546878..06e61bddb9 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -15,6 +15,7 @@ */ import { JsonObject, JsonValue } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; +import { BackstageCredentials } from './AuthService'; export type ActionsServiceAction = { id: string; @@ -28,9 +29,12 @@ export type ActionsServiceAction = { }; export interface ActionsService { - listActions: () => Promise<{ actions: ActionsServiceAction[] }>; + listActions: (opts: { + credentials: BackstageCredentials; + }) => Promise<{ actions: ActionsServiceAction[] }>; invokeAction(opts: { id: string; input?: JsonObject; + credentials: BackstageCredentials; }): Promise<{ output: JsonValue }>; } From d8d25283f4cbeb29254f4e2c7286f82a0dec4f46 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:32:19 +0200 Subject: [PATCH 42/49] chore: added api-reports Signed-off-by: benjdlambert --- .../backend-defaults/report-actions.api.md | 17 +++++ .../report-actionsRegistry.api.md | 17 +++++ .../actions/actionsServiceFactory.ts | 3 + .../actionsRegistryServiceFactory.ts | 3 + packages/backend-plugin-api/report.api.md | 73 +++++++++++++++++++ .../definitions/ActionsRegistryService.ts | 9 +++ .../services/definitions/ActionsService.ts | 6 ++ 7 files changed, 128 insertions(+) create mode 100644 packages/backend-defaults/report-actions.api.md create mode 100644 packages/backend-defaults/report-actionsRegistry.api.md diff --git a/packages/backend-defaults/report-actions.api.md b/packages/backend-defaults/report-actions.api.md new file mode 100644 index 0000000000..4c072af02a --- /dev/null +++ b/packages/backend-defaults/report-actions.api.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ActionsService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const actionsServiceFactory: ServiceFactory< + ActionsService, + 'plugin', + 'singleton' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/report-actionsRegistry.api.md b/packages/backend-defaults/report-actionsRegistry.api.md new file mode 100644 index 0000000000..d35bf4cef4 --- /dev/null +++ b/packages/backend-defaults/report-actionsRegistry.api.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/backend-defaults" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ActionsRegistryService } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export const actionsRegistryServiceFactory: ServiceFactory< + ActionsRegistryService, + 'plugin', + 'singleton' +>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts index de1f1ef15d..466cc02121 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.ts @@ -17,6 +17,9 @@ import { createServiceFactory } from '@backstage/backend-plugin-api'; import { coreServices } from '@backstage/backend-plugin-api'; import { DefaultActionsService } from './DefaultActionsService'; +/** + * @public + */ export const actionsServiceFactory = createServiceFactory({ service: coreServices.actions, deps: { diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index d894694091..70bfbf9986 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -20,6 +20,9 @@ import { } from '@backstage/backend-plugin-api'; import { DefaultActionsRegistryService } from './DefaultActionsRegistryService'; +/** + * @public + */ export const actionsRegistryServiceFactory = createServiceFactory({ service: coreServices.actionsRegistry, deps: { diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index 52843dec96..b8c8534029 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -12,6 +12,7 @@ import type { Handler } from 'express'; import { HumanDuration } from '@backstage/types'; import { isChildPath } from '@backstage/cli-common'; import { JsonObject } from '@backstage/types'; +import { JSONSchema7 } from 'json-schema'; import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; import { Permission } from '@backstage/plugin-permission-common'; @@ -25,6 +26,72 @@ import { QueryPermissionResponse } from '@backstage/plugin-permission-common'; import { Readable } from 'stream'; import type { Request as Request_2 } from 'express'; import type { Response as Response_2 } from 'express'; +import { z } from 'zod'; +import { ZodType } from 'zod'; + +// @public (undocumented) +export type ActionsRegistryActionContext = { + input: z.infer; + logger: LoggerService; + credentials: BackstageCredentials; +}; + +// @public (undocumented) +export type ActionsRegistryActionOptions< + TInputSchema extends ZodType, + TOutputSchema extends ZodType, +> = { + name: string; + title: string; + description: string; + schema?: { + input?: (zod: typeof z) => TInputSchema; + output?: (zod: typeof z) => TOutputSchema; + }; + action: (context: ActionsRegistryActionContext) => Promise< + TOutputSchema extends ZodType + ? { + output: z.infer; + } + : void + >; +}; + +// @public (undocumented) +export interface ActionsRegistryService { + // (undocumented) + register( + options: ActionsRegistryActionOptions, + ): void; +} + +// @public (undocumented) +export interface ActionsService { + // (undocumented) + invokeAction(opts: { + id: string; + input?: JsonObject; + credentials: BackstageCredentials; + }): Promise<{ + output: JsonValue; + }>; + // (undocumented) + listActions: (opts: { credentials: BackstageCredentials }) => Promise<{ + actions: ActionsServiceAction[]; + }>; +} + +// @public (undocumented) +export type ActionsServiceAction = { + id: string; + name: string; + title: string; + description: string; + schema?: { + input?: JSONSchema7; + output?: JSONSchema7; + }; +}; // @public export interface AuditorService { @@ -205,6 +272,12 @@ export type CacheServiceSetOptions = { // @public export namespace coreServices { const auth: ServiceRef; + const actions: ServiceRef; + const actionsRegistry: ServiceRef< + ActionsRegistryService, + 'plugin', + 'singleton' + >; const userInfo: ServiceRef; const cache: ServiceRef; const rootConfig: ServiceRef; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index 9240891227..ab6539a33e 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -17,12 +17,18 @@ import { z, ZodType } from 'zod'; import { LoggerService } from './LoggerService'; import { BackstageCredentials } from './AuthService'; +/** + * @public + */ export type ActionsRegistryActionContext = { input: z.infer; logger: LoggerService; credentials: BackstageCredentials; }; +/** + * @public + */ export type ActionsRegistryActionOptions< TInputSchema extends ZodType, TOutputSchema extends ZodType, @@ -41,6 +47,9 @@ export type ActionsRegistryActionOptions< >; }; +/** + * @public + */ export interface ActionsRegistryService { register( options: ActionsRegistryActionOptions, diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 06e61bddb9..156513ebc8 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -17,6 +17,9 @@ import { JsonObject, JsonValue } from '@backstage/types'; import { JSONSchema7 } from 'json-schema'; import { BackstageCredentials } from './AuthService'; +/** + * @public + */ export type ActionsServiceAction = { id: string; name: string; @@ -28,6 +31,9 @@ export type ActionsServiceAction = { }; }; +/** + * @public + */ export interface ActionsService { listActions: (opts: { credentials: BackstageCredentials; From 664c07a98feb7d9879a34b680f91dab396896ac8 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:33:36 +0200 Subject: [PATCH 43/49] chore: added changeset Signed-off-by: benjdlambert --- .changeset/wet-bars-report.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/wet-bars-report.md diff --git a/.changeset/wet-bars-report.md b/.changeset/wet-bars-report.md new file mode 100644 index 0000000000..2890fa846d --- /dev/null +++ b/.changeset/wet-bars-report.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-plugin-api': minor +'@backstage/backend-defaults': patch +--- + +Added `coreServices.actionsRegistry` and `coreServices.actions` to allow registration of distributed actions from plugins, and the ability to invoke these actions From 2d2d97e12b5e1cab49025422de086705c9ad6431 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 13:38:45 +0200 Subject: [PATCH 44/49] chore: wrap up in forwarded error Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .../DefaultActionsRegistryService.ts | 43 ++++++++++++------- .../actionsRegistryServiceFactory.test.ts | 4 +- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index fb3a5cb396..bcc5c23805 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -26,7 +26,12 @@ import PromiseRouter from 'express-promise-router'; import { Router, json } from 'express'; import { z, ZodType } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; -import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + ForwardedError, + InputError, + NotAllowedError, + NotFoundError, +} from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { private constructor( @@ -133,25 +138,31 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { ); } - // todo: wrap up in forwardederror? - const result = await action.action({ - input: input.data, - credentials, - logger: this.logger, - }); + try { + const result = await action.action({ + input: input.data, + credentials, + logger: this.logger, + }); - const output = action.schema?.output - ? action.schema.output(z).safeParse(result?.output) - : ({ success: true, data: result?.output } as const); + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); - if (!output.success) { - throw new InputError( - `Invalid output from action "${req.params.actionId}"`, - output.error, + if (!output.success) { + throw new InputError( + `Invalid output from action "${req.params.actionId}"`, + output.error, + ); + } + + res.json({ output: output.data }); + } catch (error) { + throw new ForwardedError( + `Failed execution of action "${req.params.actionId}"`, + error, ); } - - return res.json({ output: output.data }); }, ); } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 9efd29572c..832155f794 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -420,7 +420,9 @@ describe('actionsRegistryServiceFactory', () => { expect(status).toBe(400); expect(body).toMatchObject({ error: { - message: 'test', + message: expect.stringContaining( + 'Failed execution of action "my-plugin:test"', + ), }, }); }); From a9219496d5c073aaa0b8caf32ece10455cf65e61 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 14:19:41 +0200 Subject: [PATCH 45/49] chore: code review comments Signed-off-by: benjdlambert --- app-config.yaml | 1 + .../actions/DefaultActionsService.ts | 22 +++---- .../actions/actionsServiceFactory.test.ts | 20 ++++--- .../DefaultActionsRegistryService.ts | 59 ++++++++----------- .../actionsRegistryServiceFactory.test.ts | 6 +- .../actionsRegistryServiceFactory.ts | 2 +- packages/backend-plugin-api/report.api.md | 22 +++---- .../definitions/ActionsRegistryService.ts | 10 ++-- .../services/definitions/ActionsService.ts | 10 ++-- packages/backend-test-utils/report.api.md | 24 ++++++++ .../src/next/services/mockServices.ts | 16 +++++ 11 files changed, 115 insertions(+), 77 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 5651b03f91..89e417fad3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -35,6 +35,7 @@ backend: # auth: # keys: # - secret: ${BACKEND_SECRET} + auth: # TODO: once plugins have been migrated we can remove this, but right now it # is require for the backend-next to work in this repo diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index de3959ec6c..4f11050646 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -47,15 +47,14 @@ export class DefaultActionsService implements ActionsService { return new DefaultActionsService(discovery, config, logger, auth); } - async listActions({ credentials }: { credentials: BackstageCredentials }) { + async list({ credentials }: { credentials: BackstageCredentials }) { const pluginSources = this.config.getOptionalStringArray('backend.actions.pluginSources') ?? []; const remoteActionsList = await Promise.all( pluginSources.map(async source => { - const pluginBaseUrl = await this.discovery.getBaseUrl(source); const response = await this.makeRequest({ - url: `${pluginBaseUrl}/.backstage/actions/v1/actions`, + path: `/.backstage/actions/v1/actions`, pluginId: source, credentials, }); @@ -76,17 +75,16 @@ export class DefaultActionsService implements ActionsService { return { actions: remoteActionsList.flat() }; } - async invokeAction(opts: { + async invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; }) { const pluginId = this.pluginIdFromActionId(opts.id); - - const baseUrl = await this.discovery.getBaseUrl(pluginId); - const response = await this.makeRequest({ - url: `${baseUrl}/.backstage/actions/v1/actions/${opts.id}/invoke`, + path: `/.backstage/actions/v1/actions/${encodeURIComponent( + opts.id, + )}/invoke`, pluginId, credentials: opts.credentials, options: { @@ -107,18 +105,20 @@ export class DefaultActionsService implements ActionsService { } private async makeRequest(opts: { - url: string; + path: string; pluginId: string; options?: RequestInit; credentials: BackstageCredentials; }) { - const { url, credentials, options } = opts; + const { path, pluginId, credentials, options } = opts; + const baseUrl = await this.discovery.getBaseUrl(pluginId); + const { token } = await this.auth.getPluginRequestToken({ onBehalfOf: credentials, targetPluginId: opts.pluginId, }); - return fetch(url, { + return fetch(`${baseUrl}${path}`, { ...options, headers: { ...options?.headers, diff --git a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts index f1bb54a184..7b7285c89a 100644 --- a/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actions/actionsServiceFactory.test.ts @@ -66,6 +66,10 @@ describe('actionsServiceFactory', () => { id: 'my-plugin:test', name: 'testy', title: 'Test', + schema: { + input: {}, + output: {}, + }, }; beforeEach(() => { @@ -89,13 +93,13 @@ describe('actionsServiceFactory', () => { ); }); - describe('listActions', () => { + describe('list', () => { it('should list all plugins in config to find actions and handle failures gracefully', async () => { const subject = await ServiceFactoryTester.from(actionsServiceFactory, { dependencies: defaultServices, }).getSubject(); - const { actions } = await subject.listActions({ + const { actions } = await subject.list({ credentials: mockCredentials.service('user:default/mock'), }); @@ -106,7 +110,7 @@ describe('actionsServiceFactory', () => { }); }); - describe('invokeAction', () => { + describe('invoke', () => { it('should invoke the action and return the output', async () => { server.use( rest.post( @@ -118,7 +122,7 @@ describe('actionsServiceFactory', () => { dependencies: defaultServices, }).getSubject(); - const { output } = await subject.invokeAction({ + const { output } = await subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }); @@ -139,7 +143,7 @@ describe('actionsServiceFactory', () => { }).getSubject(); await expect( - subject.invokeAction({ + subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }), @@ -159,7 +163,7 @@ describe('actionsServiceFactory', () => { }).getSubject(); await expect( - subject.invokeAction({ + subject.invoke({ id: 'my-plugin:test', credentials: mockCredentials.service('user:default/mock'), }), @@ -238,7 +242,7 @@ describe('actionsServiceFactory', () => { innerRouter.post('/invoke', async (req, res) => { const { id, input } = req.body; - const response = await actionsService.invokeAction({ + const response = await actionsService.invoke({ id, input, credentials: await httpAuth.credentials(req), @@ -247,7 +251,7 @@ describe('actionsServiceFactory', () => { }); innerRouter.get('/actions', async (req, res) => { - const response = await actionsService.listActions({ + const response = await actionsService.list({ credentials: await httpAuth.credentials(req), }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index bcc5c23805..a1777cb9b6 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -34,19 +34,15 @@ import { } from '@backstage/errors'; export class DefaultActionsRegistryService implements ActionsRegistryService { + private actions: Map> = + new Map(); + private constructor( - private readonly actions: Map< - string, - ActionsRegistryActionOptions - >, - private readonly router: Router, private readonly logger: LoggerService, private readonly httpAuth: HttpAuthService, private readonly auth: AuthService, private readonly metadata: PluginMetadataService, - ) { - this.bindRoutes(); - } + ) {} static create({ httpAuth, @@ -59,36 +55,14 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { auth: AuthService; metadata: PluginMetadataService; }): DefaultActionsRegistryService { - return new DefaultActionsRegistryService( - new Map(), - PromiseRouter(), - logger, - httpAuth, - auth, - metadata, - ); + return new DefaultActionsRegistryService(logger, httpAuth, auth, metadata); } - getRouter(): Router { - return this.router; - } + createRouter(): Router { + const router = PromiseRouter(); + router.use(json()); - register( - options: ActionsRegistryActionOptions, - ): void { - const id = `${this.metadata.getId()}:${options.name}`; - - if (this.actions.has(id)) { - throw new Error(`Action with id "${id}" is already registered`); - } - - this.actions.set(id, options); - } - - private bindRoutes() { - this.router.use(json()); - - this.router.get('/.backstage/actions/v1/actions', (_, res) => { + router.get('/.backstage/actions/v1/actions', (_, res) => { return res.json({ actions: Array.from(this.actions.entries()).map(([id, action]) => ({ id, @@ -105,7 +79,7 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { }); }); - this.router.post( + router.post( '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const action = this.actions.get(req.params.actionId); @@ -165,5 +139,18 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { } }, ); + return router; + } + + register( + options: ActionsRegistryActionOptions, + ): void { + const id = `${this.metadata.getId()}:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); } } diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 832155f794..5d47ff055d 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -194,7 +194,11 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', title: 'Test', description: 'Test', - action: async () => ({ output: { ok: true } }), + schema: { + input: z => z.undefined(), + output: z => z.string(), + }, + action: async () => ({ output: 'ok' }), }); }, }); diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts index 70bfbf9986..85515a6046 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/actionsRegistryServiceFactory.ts @@ -40,7 +40,7 @@ export const actionsRegistryServiceFactory = createServiceFactory({ metadata, }); - httpRouter.use(actionsRegistryService.getRouter()); + httpRouter.use(actionsRegistryService.createRouter()); return actionsRegistryService; }, diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index b8c8534029..68597dcbe1 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -44,16 +44,16 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - schema?: { - input?: (zod: typeof z) => TInputSchema; - output?: (zod: typeof z) => TOutputSchema; + schema: { + input: (zod: typeof z) => TInputSchema; + output: (zod: typeof z) => TOutputSchema; }; action: (context: ActionsRegistryActionContext) => Promise< - TOutputSchema extends ZodType - ? { + z.infer extends void + ? void + : { output: z.infer; } - : void >; }; @@ -68,7 +68,7 @@ export interface ActionsRegistryService { // @public (undocumented) export interface ActionsService { // (undocumented) - invokeAction(opts: { + invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; @@ -76,7 +76,7 @@ export interface ActionsService { output: JsonValue; }>; // (undocumented) - listActions: (opts: { credentials: BackstageCredentials }) => Promise<{ + list: (opts: { credentials: BackstageCredentials }) => Promise<{ actions: ActionsServiceAction[]; }>; } @@ -87,9 +87,9 @@ export type ActionsServiceAction = { name: string; title: string; description: string; - schema?: { - input?: JSONSchema7; - output?: JSONSchema7; + schema: { + input: JSONSchema7; + output: JSONSchema7; }; }; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts index ab6539a33e..3044c84ed8 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsRegistryService.ts @@ -36,14 +36,16 @@ export type ActionsRegistryActionOptions< name: string; title: string; description: string; - schema?: { - input?: (zod: typeof z) => TInputSchema; - output?: (zod: typeof z) => TOutputSchema; + schema: { + input: (zod: typeof z) => TInputSchema; + output: (zod: typeof z) => TOutputSchema; }; action: ( context: ActionsRegistryActionContext, ) => Promise< - TOutputSchema extends ZodType ? { output: z.infer } : void + z.infer extends void + ? void + : { output: z.infer } >; }; diff --git a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts index 156513ebc8..69b13afaaa 100644 --- a/packages/backend-plugin-api/src/services/definitions/ActionsService.ts +++ b/packages/backend-plugin-api/src/services/definitions/ActionsService.ts @@ -25,9 +25,9 @@ export type ActionsServiceAction = { name: string; title: string; description: string; - schema?: { - input?: JSONSchema7; - output?: JSONSchema7; + schema: { + input: JSONSchema7; + output: JSONSchema7; }; }; @@ -35,10 +35,10 @@ export type ActionsServiceAction = { * @public */ export interface ActionsService { - listActions: (opts: { + list: (opts: { credentials: BackstageCredentials; }) => Promise<{ actions: ActionsServiceAction[] }>; - invokeAction(opts: { + invoke(opts: { id: string; input?: JsonObject; credentials: BackstageCredentials; diff --git a/packages/backend-test-utils/report.api.md b/packages/backend-test-utils/report.api.md index 7edcf6e1b7..379a2a70ac 100644 --- a/packages/backend-test-utils/report.api.md +++ b/packages/backend-test-utils/report.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { ActionsRegistryService } from '@backstage/backend-plugin-api'; +import { ActionsService } from '@backstage/backend-plugin-api'; import { AuditorService } from '@backstage/backend-plugin-api'; import { AuthService } from '@backstage/backend-plugin-api'; import { Backend } from '@backstage/backend-app-api'; @@ -161,6 +163,28 @@ export function mockErrorHandler(): ErrorRequestHandler< // @public export namespace mockServices { + // (undocumented) + export namespace actions { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) + export namespace actionsRegistry { + const // (undocumented) + factory: () => ServiceFactory< + ActionsRegistryService, + 'plugin', + 'singleton' + >; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } // (undocumented) export namespace auditor { const // (undocumented) diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index f68f1337b9..0c5b18bd1e 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -53,6 +53,8 @@ import { MockRootLoggerService } from './MockRootLoggerService'; import { MockUserInfoService } from './MockUserInfoService'; import { mockCredentials } from './mockCredentials'; import { MockEventsService } from './MockEventsService'; +import { actionsServiceFactory } from '@backstage/backend-defaults/actions'; +import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/actionsRegistry'; /** @internal */ function createLoggerMock() { @@ -518,6 +520,20 @@ export namespace mockServices { search: jest.fn(), })); } + export namespace actions { + export const factory = () => actionsServiceFactory; + export const mock = simpleMock(coreServices.actions, () => ({ + list: jest.fn(), + invoke: jest.fn(), + })); + } + + export namespace actionsRegistry { + export const factory = () => actionsRegistryServiceFactory; + export const mock = simpleMock(coreServices.actionsRegistry, () => ({ + register: jest.fn(), + })); + } /** * Creates a functional mock implementation of the From d4b62813c51ed8fb503626bb458130cebf83ac13 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 27 May 2025 13:33:05 +0100 Subject: [PATCH 46/49] Update page.mdx Signed-off-by: Charles de Dreuille --- canon-docs/src/app/(docs)/releases/page.mdx | 93 +++++++++++++-------- 1 file changed, 57 insertions(+), 36 deletions(-) diff --git a/canon-docs/src/app/(docs)/releases/page.mdx b/canon-docs/src/app/(docs)/releases/page.mdx index e71779e854..dcb96e5bf7 100644 --- a/canon-docs/src/app/(docs)/releases/page.mdx +++ b/canon-docs/src/app/(docs)/releases/page.mdx @@ -1,56 +1,77 @@ # Releases +## Version 0.4.0 + +### Main updates + +- ✨ Add new `Tab` component - [#29996](https://github.com/backstage/backstage/pull/29996) +- ✨ Add `truncate` prop to `Text` and `Heading` - [#29988](https://github.com/backstage/backstage/pull/29988) +- ✨ Add combobox option to `Menu` - [#29986](https://github.com/backstage/backstage/pull/29986) +- ✨ Add icon prop on `TextField` - [#29820](https://github.com/backstage/backstage/pull/29820) +- Improve icon props on `Button` and `IconButton` - [#29667](https://github.com/backstage/backstage/pull/29667) +- Improve the way we treat custom render on `Text` and `Heading` - [#29989](https://github.com/backstage/backstage/pull/29989) +- Improve `Menu` styles - [#29986](https://github.com/backstage/backstage/pull/29986) +- Improve `TextField` styles - [#29974](https://github.com/backstage/backstage/pull/29974) +- Improve clear button on `TextField` - [#29878](https://github.com/backstage/backstage/pull/29878) + +### Notable fixes + +- Fix spacing props on all layout components - [#30013](https://github.com/backstage/backstage/pull/30013) +- Fix - Pin Base UI version - [#29782](https://github.com/backstage/backstage/pull/29782) +- Fix - Clicking `Select` label moves focus to trigger - [#29755](https://github.com/backstage/backstage/pull/29755) +- Fix `DataTable.Pagination` count issue - [#29688](https://github.com/backstage/backstage/pull/29688) + ## Version 0.3.0 ### Main updates -- Add `DataTable` component - ([#29484](https://github.com/backstage/backstage/pull/29484), [#29603](https://github.com/backstage/backstage/pull/29603)) -- Add `Select` component - ([#29440](https://github.com/backstage/backstage/pull/29440)) -- Add `Avatar` component - ([#29594](https://github.com/backstage/backstage/pull/29594)) -- Add `Collapsible` component - ([#29617](https://github.com/backstage/backstage/pull/29617)) -- Add `TextField` component instead of `Field` + `Input` - ([#29364](https://github.com/backstage/backstage/pull/29364)) -- Add `TableCellProfile` - ([#29600](https://github.com/backstage/backstage/pull/29600)) -- Add breakpoint hooks - `up()` and `down()` - ([#29564](https://github.com/backstage/backstage/pull/29564)) -- Add gray scale css tokens - ([#29543](https://github.com/backstage/backstage/pull/29543)) -- Update CSS styling API using `[data-___]` instead of class names for props - ([#29560](https://github.com/backstage/backstage/pull/29560)) -- Update `Checkbox` dark mode - ([#29544](https://github.com/backstage/backstage/pull/29544)) -- Update `Container` styles - ([#29475](https://github.com/backstage/backstage/pull/29475)) -- Update `Menu` styles - ([#29351](https://github.com/backstage/backstage/pull/29351)) -- Fix `Select` styles on small sizes + with long option names - ([#29545](https://github.com/backstage/backstage/pull/29545)) -- Fix render prop on `Link` - ([#29247](https://github.com/backstage/backstage/pull/29247)) -- Remove `Field` from `TextField` + `Select` - ([#29482](https://github.com/backstage/backstage/pull/29482)) -- Update `textDecoration` to `none` on `Text` / `Heading` - ([#29357](https://github.com/backstage/backstage/pull/29357)) +- Add `DataTable` component - [#29484](https://github.com/backstage/backstage/pull/29484), [#29603](https://github.com/backstage/backstage/pull/29603) +- Add `Select` component - [#29440](https://github.com/backstage/backstage/pull/29440) +- Add `Avatar` component - [#29594](https://github.com/backstage/backstage/pull/29594) +- Add `Collapsible` component - [#29617](https://github.com/backstage/backstage/pull/29617) +- Add `TextField` component instead of `Field` + `Input` - [#29364](https://github.com/backstage/backstage/pull/29364) +- Add `TableCellProfile` - [#29600](https://github.com/backstage/backstage/pull/29600) +- Add breakpoint hooks - `up()` and `down()` - [#29564](https://github.com/backstage/backstage/pull/29564) +- Add gray scale css tokens - [#29543](https://github.com/backstage/backstage/pull/29543) +- Update CSS styling API using `[data-___]` instead of class names for props - [#29560](https://github.com/backstage/backstage/pull/29560) +- Update `Checkbox` dark mode - [#29544](https://github.com/backstage/backstage/pull/29544) +- Update `Container` styles - [#29475](https://github.com/backstage/backstage/pull/29475) +- Update `Menu` styles - [#29351](https://github.com/backstage/backstage/pull/29351) +- Fix `Select` styles on small sizes + with long option names - [#29545](https://github.com/backstage/backstage/pull/29545) +- Fix render prop on `Link` - [#29247](https://github.com/backstage/backstage/pull/29247) +- Remove `Field` from `TextField` + `Select` - [#29482](https://github.com/backstage/backstage/pull/29482) +- Update `textDecoration` to `none` on `Text` / `Heading` - [#29357](https://github.com/backstage/backstage/pull/29357) ### Notable fixes -- Docs - Use stories from Storybook for all examples in Nextjs - ([#29306](https://github.com/backstage/backstage/pull/29306)) -- Docs - Add release page (this one 🤗) - ([#29461](https://github.com/backstage/backstage/pull/29461)) -- Docs - Add docs for Menu, Link - ([#29576](https://github.com/backstage/backstage/pull/29576)) -- Fix CSS watch mode - ([#29352](https://github.com/backstage/backstage/pull/29352)) +- Docs - Use stories from Storybook for all examples in Nextjs - [#29306](https://github.com/backstage/backstage/pull/29306) +- Docs - Add release page (this one 🤗) - [#29461](https://github.com/backstage/backstage/pull/29461) +- Docs - Add docs for Menu, Link - [#29576](https://github.com/backstage/backstage/pull/29576) +- Fix CSS watch mode - [#29352](https://github.com/backstage/backstage/pull/29352) ## Version 0.2.0 ### Main updates -- New `Tooltip` component ([#29241](https://github.com/backstage/backstage/pull/29241)) -- New `Menu` component. ([#29151](https://github.com/backstage/backstage/pull/29151)) -- New `IconButton` component. ([#29239](https://github.com/backstage/backstage/pull/29239)) -- New `ScrollArea` component. ([#29240](https://github.com/backstage/backstage/pull/29240)) -- Improve `Button` & `Checkbox` styles. ([#29127](https://github.com/backstage/backstage/pull/29127)) ([#28789](https://github.com/backstage/backstage/pull/28789)) -- Improve `Text` styles. ([#29200](https://github.com/backstage/backstage/pull/29200)) -- Renamed `CanonProvider` to `IconProvider`. ([#29002](https://github.com/backstage/backstage/pull/29002)) -- Added about 40+ new icons. ([#29264](https://github.com/backstage/backstage/pull/29264)) -- Simplified styling into a unique styles.css file ([#29199](https://github.com/backstage/backstage/pull/29199)) -- Added Canon styles to Backstage ([#29137](https://github.com/backstage/backstage/pull/29137)) -- Update global CSS tokens ([#28804](https://github.com/backstage/backstage/pull/28804)) -- Merge Stack + Inline into Flex ([#28634](https://github.com/backstage/backstage/pull/28634)) +- New `Tooltip` component - [#29241](https://github.com/backstage/backstage/pull/29241) +- New `Menu` component - [#29151](https://github.com/backstage/backstage/pull/29151) +- New `IconButton` component - [#29239](https://github.com/backstage/backstage/pull/29239) +- New `ScrollArea` component - [#29240](https://github.com/backstage/backstage/pull/29240) +- Improve `Button` & `Checkbox` styles - [#29127](https://github.com/backstage/backstage/pull/29127), [#28789](https://github.com/backstage/backstage/pull/28789) +- Improve `Text` styles - [#29200](https://github.com/backstage/backstage/pull/29200) +- Renamed `CanonProvider` to `IconProvider` - [#29002](https://github.com/backstage/backstage/pull/29002) +- Added about 40+ new icons - [#29264](https://github.com/backstage/backstage/pull/29264) +- Simplified styling into a unique styles.css file - [#29199](https://github.com/backstage/backstage/pull/29199) +- Added Canon styles to Backstage - [#29137](https://github.com/backstage/backstage/pull/29137) +- Update global CSS tokens - [#28804](https://github.com/backstage/backstage/pull/28804) +- Merge Stack + Inline into Flex - [#28634](https://github.com/backstage/backstage/pull/28634) ### Notable fixes: -- Improve `Button` types. ([#29205](https://github.com/backstage/backstage/pull/29205)) -- Move font weight and family back to each components ([#28972](https://github.com/backstage/backstage/pull/28972)) -- Fix custom values in spacing props ([#28770](https://github.com/backstage/backstage/pull/28770)) -- Multiple updates on the Canon Docs site ([#28760](https://github.com/backstage/backstage/pull/28760)) ([#28591](https://github.com/backstage/backstage/pull/28591)) +- Improve `Button` types - [#29205](https://github.com/backstage/backstage/pull/29205) +- Move font weight and family back to each components - [#28972](https://github.com/backstage/backstage/pull/28972) +- Fix custom values in spacing props - [#28770](https://github.com/backstage/backstage/pull/28770) +- Multiple updates on the Canon Docs site - [#28760](https://github.com/backstage/backstage/pull/28760), [#28591](https://github.com/backstage/backstage/pull/28591) ## Version 0.1.0 From c999c2568bfc923ea1579bf9070ac5a98ef9d476 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 14:26:27 +0200 Subject: [PATCH 47/49] chore: break apart changeset Signed-off-by: benjdlambert Signed-off-by: benjdlambert --- .changeset/sweet-ducks-wait.md | 5 ++++ .changeset/wet-bars-report.md | 1 - .changeset/wet-bars-reporting.md | 5 ++++ .../DefaultActionsRegistryService.ts | 26 +++++++++---------- 4 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 .changeset/sweet-ducks-wait.md create mode 100644 .changeset/wet-bars-reporting.md diff --git a/.changeset/sweet-ducks-wait.md b/.changeset/sweet-ducks-wait.md new file mode 100644 index 0000000000..4792fafe84 --- /dev/null +++ b/.changeset/sweet-ducks-wait.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': minor +--- + +Added mock implementations for `ActionsService` and `ActionsRegistryService` diff --git a/.changeset/wet-bars-report.md b/.changeset/wet-bars-report.md index 2890fa846d..d556f6cc61 100644 --- a/.changeset/wet-bars-report.md +++ b/.changeset/wet-bars-report.md @@ -1,6 +1,5 @@ --- '@backstage/backend-plugin-api': minor -'@backstage/backend-defaults': patch --- Added `coreServices.actionsRegistry` and `coreServices.actions` to allow registration of distributed actions from plugins, and the ability to invoke these actions diff --git a/.changeset/wet-bars-reporting.md b/.changeset/wet-bars-reporting.md new file mode 100644 index 0000000000..7dc66cb280 --- /dev/null +++ b/.changeset/wet-bars-reporting.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added some default implementations for the `ActionsService` and `ActionsRegistryService` that allow registration of actions for a particular plugin. diff --git a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index a1777cb9b6..17523a2ee5 100644 --- a/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -82,6 +82,19 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { router.post( '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { + const credentials = await this.httpAuth.credentials(req); + if (this.auth.isPrincipal(credentials, 'user')) { + if (!credentials.principal.actor) { + throw new NotAllowedError( + `Actions must be invoked by a service, not a user`, + ); + } + } else if (this.auth.isPrincipal(credentials, 'none')) { + throw new NotAllowedError( + `Actions must be invoked by a service, not an anonymous request`, + ); + } + const action = this.actions.get(req.params.actionId); if (!action) { @@ -99,19 +112,6 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { ); } - const credentials = await this.httpAuth.credentials(req); - if (this.auth.isPrincipal(credentials, 'user')) { - if (!credentials.principal.actor) { - throw new NotAllowedError( - `Actions must be invoked by a service, not a user`, - ); - } - } else if (this.auth.isPrincipal(credentials, 'none')) { - throw new NotAllowedError( - `Actions must be invoked by a service, not an anonymous request`, - ); - } - try { const result = await action.action({ input: input.data, From 9d38d811bd5843b4fc3b5cd44c0b173f026745cf Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 27 May 2025 13:37:49 +0100 Subject: [PATCH 48/49] Fix playground Signed-off-by: Charles de Dreuille --- canon-docs/src/app/(playground)/playground/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/canon-docs/src/app/(playground)/playground/page.tsx b/canon-docs/src/app/(playground)/playground/page.tsx index 3f1675551e..671d644993 100644 --- a/canon-docs/src/app/(playground)/playground/page.tsx +++ b/canon-docs/src/app/(playground)/playground/page.tsx @@ -80,11 +80,11 @@ const Content = () => { const Line = ({ content, title }: { content: ReactNode; title: string }) => { return ( - + {title} {content} - + ); }; From 0a9deff5cb0178d60991fd139c90865b37525d2f Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 27 May 2025 15:12:16 +0200 Subject: [PATCH 49/49] chore: refactor the test Signed-off-by: benjdlambert --- .../actions/DefaultActionsService.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts index 4f11050646..2421482f4c 100644 --- a/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts +++ b/packages/backend-defaults/src/entrypoints/actions/DefaultActionsService.ts @@ -53,22 +53,24 @@ export class DefaultActionsService implements ActionsService { const remoteActionsList = await Promise.all( pluginSources.map(async source => { - const response = await this.makeRequest({ - path: `/.backstage/actions/v1/actions`, - pluginId: source, - credentials, - }); + try { + const response = await this.makeRequest({ + path: `/.backstage/actions/v1/actions`, + pluginId: source, + credentials, + }); + if (!response.ok) { + throw await ResponseError.fromResponse(response); + } + const { actions } = (await response.json()) as { + actions: ActionsServiceAction; + }; - if (!response.ok) { - this.logger.warn(`Failed to fetch actions from ${source}`); + return actions; + } catch (error) { + this.logger.warn(`Failed to fetch actions from ${source}`, error); return []; } - - const { actions } = (await response.json()) as { - actions: ActionsServiceAction; - }; - - return actions; }), );