diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c835abe..bff7c21 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -26,7 +26,6 @@ import { apis } from './apis'; import { entityPage } from './components/catalog/EntityPage'; import { searchPage } from './components/search/SearchPage'; import { Root } from './components/Root'; - import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components'; import { githubAuthApiRef } from '@backstage/core-plugin-api'; import { SignInPage } from '@backstage/core-components'; @@ -36,6 +35,7 @@ import { CatalogGraphPage } from '@backstage/plugin-catalog-graph'; import { RequirePermission } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha'; import { EntityFluxHelmReleasesCard } from '@weaveworksoss/backstage-plugin-flux'; +import { FluxRuntimePage } from '@weaveworksoss/backstage-plugin-flux'; const app = createApp({ components: { @@ -111,6 +111,7 @@ const routes = ( } /> } /> } /> + } /> ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 6768b48..4ed1472 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -26,6 +26,7 @@ import { } from '@backstage/core-components'; import MenuIcon from '@material-ui/icons/Menu'; import SearchIcon from '@material-ui/icons/Search'; +import { FluxIcon } from '@weaveworksoss/backstage-plugin-flux'; const useSidebarLogoStyles = makeStyles({ root: { @@ -73,6 +74,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( + diff --git a/plugins/backstage-plugin-flux/README.md b/plugins/backstage-plugin-flux/README.md index 2058072..2887ffd 100644 --- a/plugins/backstage-plugin-flux/README.md +++ b/plugins/backstage-plugin-flux/README.md @@ -26,6 +26,11 @@ You can also add cards for resources with the following components, each of thes - EntityFluxHelmRepositoriesCard - EntityFluxImagePoliciesCard +The plugin also provides a page for viewing the Flux runtime state across your clusters, and a Card if you would prefer to include it some other page you have instead. + +- FluxRuntimePage +- FluxRuntimeCard + As with other Backstage plugins, you can compose the UI you need. ## Prerequisite @@ -277,6 +282,48 @@ kubernetes: caData: LS0tLS1CRUdJTiBDRVJUSUZJQ0... ``` +6. [Optional] Add a Flux Runtime page to your app + +- An example Page is included as the `` component. +- Add the page to your app by first adding a route in `App.tsx` + +```tsx +// In packages/app/src/App.tsx +import { FluxRuntimePage } from '@weaveworksoss/backstage-plugin-flux'; + +// ... + +const routes = ( + + ... + } /> + +); +``` + +- Add the page to the navigation bar: + +```tsx +// In packages/app/src/components/Root/Root.tsx + +import { FluxIcon } from '@weaveworksoss/backstage-plugin-flux'; + +// ... + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + + }> + ... + + + + + + {children} + +``` + ## Verification For the resources where we display a Verification status, if the Flux resource @@ -302,4 +349,3 @@ Request failed with 401 Unauthorized, {"error":{"name":"AuthenticationError","me This is likely caused by this issue in [Backstage](https://github.com/backstage/backstage/issues/12394). The simplest thing to do is put some sort of authentication in front of your Backstage setup, for example using the [GitHub Authentication Provider](https://backstage.io/docs/auth/github/provider/) this will ensure there's an authentication token available. - diff --git a/plugins/backstage-plugin-flux/dev/helpers.ts b/plugins/backstage-plugin-flux/dev/helpers.ts index 5407b59..56282f8 100644 --- a/plugins/backstage-plugin-flux/dev/helpers.ts +++ b/plugins/backstage-plugin-flux/dev/helpers.ts @@ -266,3 +266,26 @@ export const newTestImagePolicy = ( }, }; }; + +export const newTestFluxController = ( + name: string, + namespace: string, + labels: { [name: string]: string }, +) => { + return { + apiVersion: 'meta.k8s.io/v1', + kind: 'PartialObjectMetadata', + metadata: { + name, + namespace, + uid: 'b062d329-538d-4bb3-b4df-b2ac4b06dba8', + resourceVersion: '1001263', + generation: 1, + creationTimestamp: '2023-10-19T16:34:14Z', + labels, + annotations: { + 'deployment.kubernetes.io/revision': '1', + }, + }, + }; +}; diff --git a/plugins/backstage-plugin-flux/dev/index.tsx b/plugins/backstage-plugin-flux/dev/index.tsx index 81d106a..f97d60b 100644 --- a/plugins/backstage-plugin-flux/dev/index.tsx +++ b/plugins/backstage-plugin-flux/dev/index.tsx @@ -35,9 +35,16 @@ import { newTestKustomization, newTestHelmRepository, newTestImagePolicy, + newTestFluxController, } from './helpers'; import { ReconcileRequestAnnotation } from '../src/hooks'; import { EntityFluxSourcesCard } from '../src/components/EntityFluxSourcesCard'; +import { FluxRuntimeCard } from '../src/components/FluxRuntimeCard'; +import { + NAMESPACES_PATH, + getDeploymentsPath, +} from '../src/hooks/useGetDeployments'; +import { Namespace } from '../src/objects'; const fakeEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -72,7 +79,10 @@ class StubKubernetesClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { await new Promise(resolve => setTimeout(resolve, 100)); - return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }]; + return [ + { name: 'mock-cluster-1', authProvider: 'serviceAccount1' }, + { name: 'mock-cluster-2', authProvider: 'serviceAccount2' }, + ]; } getWorkloadsByEntity( @@ -117,12 +127,12 @@ class StubKubernetesClient implements KubernetesApi { }; } - // this is only used by sync and suspend/resume async proxy({ + clusterName, init, path, }: { - clusterName: string; + clusterName?: string; path: string; init?: RequestInit | undefined; }): Promise { @@ -174,6 +184,93 @@ class StubKubernetesClient implements KubernetesApi { } as Response; } + if (!init?.method && path === NAMESPACES_PATH) { + if (clusterName === 'mock-cluster-1') { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'NamespacesList', + apiVersion: 'meta.k8s.io/v1', + items: [ + { + metadata: { + name: 'flux-system', + labels: { + 'app.kubernetes.io/instance': 'flux-system', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.0.0', + 'kubernetes.io/metadata.name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/namespace': 'flux-system', + 'pod-security.kubernetes.io/warn': 'restricted', + 'pod-security.kubernetes.io/warn-version': 'latest', + }, + uid: '1dcca7cb-c651-4a86-93b4-ecf440df2353', + resourceVersion: '1583', + creationTimestamp: '2023-10-19T16:34:12Z', + }, + } as Namespace, + ], + }), + } as Response; + } + if (clusterName === 'mock-cluster-2') { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'NamespacesList', + apiVersion: 'meta.k8s.io/v1', + items: [ + { + metadata: { + name: 'default', + uid: '1dcca7cb-c651-4a86-93b4-ecf440df2353', + resourceVersion: '1583', + creationTimestamp: '2023-10-19T16:34:12Z', + labels: { + 'app.kubernetes.io/instance': 'default', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.0.0', + 'kubernetes.io/metadata.name': 'default', + 'kustomize.toolkit.fluxcd.io/name': 'default', + 'kustomize.toolkit.fluxcd.io/namespace': 'default', + 'pod-security.kubernetes.io/warn': 'restricted', + 'pod-security.kubernetes.io/warn-version': 'latest', + }, + }, + } as Namespace, + ], + }), + } as Response; + } + } + + if (!init?.method && path === getDeploymentsPath('flux-system')) { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'DeploymentList', + apiVersion: 'apps/v1', + items: [this.resources[0], this.resources[1]], + }), + } as Response; + } + + if (!init?.method && path === getDeploymentsPath('default')) { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'DeploymentList', + apiVersion: 'apps/v1', + items: [this.resources[2]], + }), + } as Response; + } + // very simple right now if (this.mockResponses[path]?.length) { // shift pops the [0]th element off the array @@ -605,5 +702,62 @@ createDevApp() ), }) + .addPage({ + title: 'Flux Runtime', + path: '/flux_runtime', + element: ( + + + + + + ), + }) .registerPlugin(weaveworksFluxPlugin) .render(); diff --git a/plugins/backstage-plugin-flux/src/components/FluxEntityTable.tsx b/plugins/backstage-plugin-flux/src/components/FluxEntityTable.tsx index 474d676..4bf193f 100644 --- a/plugins/backstage-plugin-flux/src/components/FluxEntityTable.tsx +++ b/plugins/backstage-plugin-flux/src/components/FluxEntityTable.tsx @@ -38,7 +38,8 @@ export function FluxEntityTable({ emptyContent={
- No {title} found for this entity. + No {title} found + {title === 'flux controllers' ? '' : 'for this entity'}.
} diff --git a/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeCard.test.tsx b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeCard.test.tsx new file mode 100644 index 0000000..a72a7fa --- /dev/null +++ b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeCard.test.tsx @@ -0,0 +1,272 @@ +import React from 'react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { configApiRef } from '@backstage/core-plugin-api'; +import { ConfigReader } from '@backstage/core-app-api'; +import { + KubernetesApi, + kubernetesApiRef, + KubernetesAuthProvidersApi, + kubernetesAuthProvidersApiRef, +} from '@backstage/plugin-kubernetes'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { FluxRuntimeCard } from './FluxRuntimeCard'; +import { + getDeploymentsPath, + NAMESPACES_PATH, +} from '../../hooks/useGetDeployments'; +import { Namespace } from '../../objects'; + +const makeTestFluxController = ( + name: string, + namespace: string, + labels: { [name: string]: string }, +) => { + return { + apiVersion: 'meta.k8s.io/v1', + kind: 'PartialObjectMetadata', + metadata: { + name, + namespace, + uid: 'b062d329-538d-4bb3-b4df-b2ac4b06dba8', + resourceVersion: '1001263', + generation: 1, + creationTimestamp: '2023-10-19T16:34:14Z', + labels, + annotations: { + 'deployment.kubernetes.io/revision': '1', + }, + }, + }; +}; + +class StubKubernetesClient implements KubernetesApi { + getObjectsByEntity = jest.fn(); + + async getClusters(): Promise<{ name: string; authProvider: string }[]> { + return [ + { name: 'mock-cluster-1', authProvider: 'serviceAccount1' }, + { name: 'mock-cluster-2', authProvider: 'serviceAccount2' }, + ]; + } + + getWorkloadsByEntity = jest.fn(); + + getCustomObjectsByEntity = jest.fn(); + + async proxy({ + clusterName, + init, + path, + }: { + clusterName?: string; + path: string; + init?: RequestInit | undefined; + }): Promise { + if (!init?.method && path === NAMESPACES_PATH) { + if (clusterName === 'mock-cluster-1') { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'NamespacesList', + apiVersion: 'meta.k8s.io/v1', + items: [ + { + metadata: { + name: 'flux-system', + labels: { + 'app.kubernetes.io/instance': 'flux-system', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.0.0', + 'kubernetes.io/metadata.name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/namespace': 'flux-system', + 'pod-security.kubernetes.io/warn': 'restricted', + 'pod-security.kubernetes.io/warn-version': 'latest', + }, + uid: '1dcca7cb-c651-4a86-93b4-ecf440df2353', + resourceVersion: '1583', + creationTimestamp: '2023-10-19T16:34:12Z', + }, + } as Namespace, + ], + }), + } as Response; + } + if (clusterName === 'mock-cluster-2') { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'NamespacesList', + apiVersion: 'meta.k8s.io/v1', + items: [ + { + metadata: { + name: 'default', + uid: '1dcca7cb-c651-4a86-93b4-ecf440df2353', + resourceVersion: '1583', + creationTimestamp: '2023-10-19T16:34:12Z', + labels: { + 'app.kubernetes.io/instance': 'default', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.0.0', + 'kubernetes.io/metadata.name': 'default', + 'kustomize.toolkit.fluxcd.io/name': 'default', + 'kustomize.toolkit.fluxcd.io/namespace': 'default', + 'pod-security.kubernetes.io/warn': 'restricted', + 'pod-security.kubernetes.io/warn-version': 'latest', + }, + }, + } as Namespace, + ], + }), + } as Response; + } + } + + if (!init?.method && path === getDeploymentsPath('flux-system')) { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'DeploymentList', + apiVersion: 'apps/v1', + items: [ + makeTestFluxController('helm-controller', 'flux-system', { + 'app.kubernetes.io/component': 'helm-controller', + 'app.kubernetes.io/instance': 'flux-system', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.1.2', + 'control-plane': 'controller', + 'kustomize.toolkit.fluxcd.io/name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/namespace': 'flux-system', + }), + makeTestFluxController( + 'image-automation-controller', + 'flux-system', + { + 'app.kubernetes.io/component': 'image-automation-controller', + 'app.kubernetes.io/instance': 'flux-system', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.1.2', + 'control-plane': 'controller', + 'kustomize.toolkit.fluxcd.io/name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/namespace': 'flux-system', + }, + ), + ], + }), + } as Response; + } + + if (!init?.method && path === getDeploymentsPath('default')) { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'DeploymentList', + apiVersion: 'apps/v1', + items: [ + makeTestFluxController('image-automation-controller', 'default', { + 'app.kubernetes.io/component': 'image-automation-controller', + 'app.kubernetes.io/instance': 'default', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.1.2', + 'control-plane': 'controller', + 'kustomize.toolkit.fluxcd.io/name': 'default', + 'kustomize.toolkit.fluxcd.io/namespace': 'default', + }), + ], + }), + } as Response; + } + + return null; + } +} + +class StubKubernetesAuthProvidersApi implements KubernetesAuthProvidersApi { + decorateRequestBodyForAuth( + _: string, + requestBody: KubernetesRequestBody, + ): Promise { + return Promise.resolve(requestBody); + } + getCredentials(_: string): Promise<{ + token?: string; + }> { + return Promise.resolve({ token: 'mock-token' }); + } +} + +describe('', () => { + let Wrapper: React.ComponentType>; + + beforeEach(() => { + Wrapper = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('listing Flux Controllers per Cluster', () => { + it('shows for each cluster what flux controllers are running', async () => { + const result = await renderInTestApp( + + + + + , + ); + + const { getByText } = result; + + const testCases = [ + { + cluster: 'mock-cluster-1', + namespace: 'flux-system', + version: 'v2.1.2', + availableComponents: [ + 'helm-controller', + 'image-automation-controller', + ], + }, + { + cluster: 'mock-cluster-2', + namespace: 'default', + version: 'v2.1.2', + availableComponents: ['image-automation-controller'], + }, + ]; + + for (const testCase of testCases) { + const cell = getByText(testCase.cluster); + expect(cell).toBeInTheDocument(); + + const tr = cell.closest('tr'); + expect(tr).toBeInTheDocument(); + expect(tr).toHaveTextContent(testCase.namespace); + expect(tr).toHaveTextContent(testCase.version); + expect(tr).toHaveTextContent(testCase.availableComponents.join(', ')); + } + }); + }); +}); diff --git a/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeCard.tsx b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeCard.tsx new file mode 100644 index 0000000..fb319fe --- /dev/null +++ b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeCard.tsx @@ -0,0 +1,35 @@ +import React, { FC } from 'react'; +import { InfoCard } from '@backstage/core-components'; +import { WeaveGitOpsContext } from '../WeaveGitOpsContext'; +import { FluxRuntimeTable, defaultColumns } from './FluxRuntimeTable'; +import { useGetDeployments } from '../../hooks/useGetDeployments'; + +const FluxRuntimePanel: FC<{ many?: boolean }> = ({ many }) => { + const { data, isLoading, error } = useGetDeployments(); + + if (error) { + return
Error: {error.message}
; + } + + return ( + + + + ); +}; + +/** + * Render the Deployments in Flux Runtime. + * + * @public + */ +export const FluxRuntimeCard = ({ many = true }: { many?: boolean }) => ( + + + +); diff --git a/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeTable.tsx b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeTable.tsx new file mode 100644 index 0000000..6b16e57 --- /dev/null +++ b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/FluxRuntimeTable.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { TableColumn } from '@backstage/core-components'; +import { + idColumn, + filters, + clusterColumn, + namespaceColumn, + versionColumn, + Cluster, + availableComponentsColumn, + clusterNameFilteringColumn, +} from '../helpers'; +import { FluxEntityTable } from '../FluxEntityTable'; +import { FluxControllerEnriched } from '../../objects'; + +export const defaultColumns: TableColumn[] = [ + clusterNameFilteringColumn(), + idColumn(), + clusterColumn(), + namespaceColumn(), + versionColumn(), + availableComponentsColumn(), +]; + +type Props = { + deployments: FluxControllerEnriched[]; + isLoading: boolean; + columns: TableColumn[]; + many?: boolean; +}; + +export const FluxRuntimeTable = ({ + deployments, + isLoading, + columns, + many, +}: Props) => { + let clusters: Cluster[] = []; + deployments.forEach(deployment => { + const cls = clusters.find( + cluster => cluster.clusterName === deployment.clusterName, + ); + if (cls) { + cls.availableComponents = [ + ...cls.availableComponents, + deployment.metadata.labels['app.kubernetes.io/component'], + ]; + } else { + clusters = [ + ...clusters, + { + clusterName: deployment.clusterName, + namespace: deployment.metadata.namespace, + version: deployment.metadata.labels['app.kubernetes.io/version'], + availableComponents: [ + deployment.metadata.labels['app.kubernetes.io/component'], + ], + }, + ]; + } + }); + + const data = clusters.map(c => { + const { clusterName, namespace, version, availableComponents } = c; + return { + id: `${namespace}/${clusterName}`, + clusterName, + namespace, + version, + availableComponents, + } as Cluster & { id: string }; + }); + + return ( + + ); +}; diff --git a/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/index.ts b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/index.ts new file mode 100644 index 0000000..62dbd54 --- /dev/null +++ b/plugins/backstage-plugin-flux/src/components/FluxRuntimeCard/index.ts @@ -0,0 +1 @@ +export { FluxRuntimeCard } from './FluxRuntimeCard'; diff --git a/plugins/backstage-plugin-flux/src/components/FluxRuntimePage/index.tsx b/plugins/backstage-plugin-flux/src/components/FluxRuntimePage/index.tsx new file mode 100644 index 0000000..3ebdf40 --- /dev/null +++ b/plugins/backstage-plugin-flux/src/components/FluxRuntimePage/index.tsx @@ -0,0 +1,44 @@ +import { Content, Header, Page } from '@backstage/core-components'; +import { makeStyles } from '@material-ui/core'; +import React from 'react'; +import { FluxRuntimeCard } from '../FluxRuntimeCard'; + +const useStyles = makeStyles(() => ({ + overflowXScroll: { + overflowX: 'scroll', + }, +})); + +export interface FluxRuntimePageProps { + /** + * Title + */ + title?: string; + /** + * Subtitle + */ + subtitle?: string; + /** + * Page Title + */ + pageTitle?: string; +} + +/** + * Main Page of Flux Runtime + * + * @public + */ +export function FluxRuntimePage(props: FluxRuntimePageProps) { + const { title = 'Flux Runtime' } = props; + const classes = useStyles(); + + return ( + +
+ + + + + ); +} diff --git a/plugins/backstage-plugin-flux/src/components/helpers.tsx b/plugins/backstage-plugin-flux/src/components/helpers.tsx index 09d3987..cc7e93b 100644 --- a/plugins/backstage-plugin-flux/src/components/helpers.tsx +++ b/plugins/backstage-plugin-flux/src/components/helpers.tsx @@ -30,11 +30,18 @@ import { } from '../objects'; import Flex from './Flex'; import KubeStatusIndicator, { getIndicatorInfo } from './KubeStatusIndicator'; -import { helm, kubernetes, oci, git, imagepolicy } from '../images/icons'; +import { helm, kubernetes, oci, git, flux } from '../images/icons'; import { useToggleSuspendResource } from '../hooks/useToggleSuspendResource'; export type Source = GitRepository | OCIRepository | HelmRepository; export type Deployment = HelmRelease | Kustomization; +export type Cluster = { + clusterName: string; + namespace: string; + version: string; + availableComponents: string[]; +}; + /** * Calculate a Name label for a resource with the namespace/name and link to * this in Weave GitOps if possible. @@ -250,7 +257,7 @@ export const nameAndClusterName = ({ ); -export const idColumn = () => { +export const idColumn = () => { return { title: 'Id', field: 'id', @@ -259,7 +266,9 @@ export const idColumn = () => { }; // Added hidden column to allow checkbox filtering by clusterName -export const clusterNameFilteringColumn = () => { +export const clusterNameFilteringColumn = < + T extends FluxObject | Cluster, +>() => { return { title: 'Cluster name', hidden: true, @@ -267,6 +276,40 @@ export const clusterNameFilteringColumn = () => { } as TableColumn; }; +export const clusterColumn = () => { + return { + title: 'Cluster', + render: resource => {resource?.clusterName}, + ...sortAndFilterOptions(resource => resource?.clusterName), + } as TableColumn; +}; + +export const namespaceColumn = () => { + return { + title: 'Namespace', + render: resource => {resource?.namespace}, + ...sortAndFilterOptions(resource => resource?.namespace), + } as TableColumn; +}; + +export const versionColumn = () => { + return { + title: 'Version', + render: resource => {resource?.version}, + ...sortAndFilterOptions(resource => resource?.version), + } as TableColumn; +}; + +export const availableComponentsColumn = () => { + return { + title: 'Available Components', + render: resource => {resource?.availableComponents.join(', ')}, + ...sortAndFilterOptions(resource => + resource?.availableComponents.join(', '), + ), + } as TableColumn; +}; + export const nameAndClusterNameColumn = () => { return { title: 'Name', @@ -378,7 +421,7 @@ export const getIconType = (type: string) => { case 'OCIRepository': return oci; case 'ImagePolicy': - return imagepolicy; + return flux; default: return null; } diff --git a/plugins/backstage-plugin-flux/src/components/index.ts b/plugins/backstage-plugin-flux/src/components/index.ts index 1878814..eafe31e 100644 --- a/plugins/backstage-plugin-flux/src/components/index.ts +++ b/plugins/backstage-plugin-flux/src/components/index.ts @@ -3,3 +3,4 @@ export * from './EntityFluxHelmReleasesCard'; export * from './EntityFluxOCIRepositoriesCard'; export * from './EntityFluxKustomizationsCard'; export * from './EntityFluxHelmRepositoriesCard'; +export * from './FluxRuntimeCard'; diff --git a/plugins/backstage-plugin-flux/src/hooks/useGetDeployments.test.ts b/plugins/backstage-plugin-flux/src/hooks/useGetDeployments.test.ts new file mode 100644 index 0000000..d25c605 --- /dev/null +++ b/plugins/backstage-plugin-flux/src/hooks/useGetDeployments.test.ts @@ -0,0 +1,118 @@ +import { kubernetesApiRef } from '@backstage/plugin-kubernetes'; +import { + NAMESPACES_PATH, + getDeploymentsList, + getDeploymentsPath, +} from './useGetDeployments'; +import { FluxController, Namespace } from '../objects'; + +function makeMockKubernetesApi() { + return { + getObjectsByEntity: jest.fn(), + getClusters: jest.fn(), + getWorkloadsByEntity: jest.fn(), + getCustomObjectsByEntity: jest.fn(), + proxy: jest.fn(), + } as jest.Mocked; +} + +describe('getDeploymentsList', () => { + const namespace = { + metadata: { + name: 'flux-system', + uid: '1dcca7cb-c651-4a86-93b4-ecf440df2353', + resourceVersion: '1583', + creationTimestamp: '2023-10-19T16:34:12Z', + labels: { + 'app.kubernetes.io/instance': 'flux-system', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.0.0', + 'kubernetes.io/metadata.name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/namespace': 'flux-system', + 'pod-security.kubernetes.io/warn': 'restricted', + 'pod-security.kubernetes.io/warn-version': 'latest', + }, + }, + } as Namespace; + + const deployment = { + apiVersion: 'meta.k8s.io/v1', + kind: 'PartialObjectMetadata', + metadata: { + name: 'image-automation-controller', + namespace: 'flux-system', + uid: 'b062d329-538d-4bb3-b4df-b2ac4b06dba8', + resourceVersion: '1001263', + generation: 1, + creationTimestamp: '2023-10-19T16:34:14Z', + labels: { + 'app.kubernetes.io/component': 'image-automation-controller', + 'app.kubernetes.io/instance': 'flux-system', + 'app.kubernetes.io/part-of': 'flux', + 'app.kubernetes.io/version': 'v2.1.2', + 'control-plane': 'controller', + 'kustomize.toolkit.fluxcd.io/name': 'flux-system', + 'kustomize.toolkit.fluxcd.io/namespace': 'flux-system', + }, + annotations: { + 'deployment.kubernetes.io/revision': '1', + }, + }, + } as FluxController; + + it('should get a Deployments list', async () => { + const kubernetesApi = makeMockKubernetesApi(); + + kubernetesApi.getClusters.mockImplementation(async () => { + return [{ name: 'mock-cluster-1', authProvider: 'serviceAccount1' }]; + }); + + kubernetesApi.proxy.mockImplementation( + async ({ init, path, clusterName }) => { + if (!init?.method && path === NAMESPACES_PATH) { + if (clusterName === 'mock-cluster-1') { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'NamespacesList', + apiVersion: 'meta.k8s.io/v1', + items: [namespace], + }), + } as Response; + } + } + if (!init?.method && path === getDeploymentsPath('flux-system')) { + return { + ok: true, + json: () => + Promise.resolve({ + kind: 'DeploymentList', + apiVersion: 'apps/v1', + items: [deployment], + }), + } as Response; + } + return { + ok: true, + json: () => Promise.resolve(), + } as Response; + }, + ); + + await getDeploymentsList(kubernetesApi); + + // Assert we tried to GET the namespaces that exist in the clusters + expect(kubernetesApi.proxy).toHaveBeenCalledWith({ + clusterName: 'mock-cluster-1', + path: NAMESPACES_PATH, + }); + + // Assert we tried to GET the deployments for mock-cluster-1 + expect(kubernetesApi.proxy).toHaveBeenCalledWith({ + clusterName: 'mock-cluster-1', + path: getDeploymentsPath('flux-system'), + }); + }); +}); diff --git a/plugins/backstage-plugin-flux/src/hooks/useGetDeployments.ts b/plugins/backstage-plugin-flux/src/hooks/useGetDeployments.ts new file mode 100644 index 0000000..d679167 --- /dev/null +++ b/plugins/backstage-plugin-flux/src/hooks/useGetDeployments.ts @@ -0,0 +1,83 @@ +import { useApi } from '@backstage/core-plugin-api'; +import { KubernetesApi, kubernetesApiRef } from '@backstage/plugin-kubernetes'; +import { FluxController, FluxControllerEnriched, Namespace } from '../objects'; +import { useQuery } from 'react-query'; +import _ from 'lodash'; + +export const NAMESPACES_PATH = `/api/v1/namespaces?labelSelector=app.kubernetes.io%2Fpart-of%3Dflux&limit=500`; +export const getDeploymentsPath = (ns: string) => + `/apis/apps/v1/namespaces/${ns}/deployments?labelSelector=app.kubernetes.io%2Fpart-of%3Dflux&limit=500`; + +export async function getDeploymentsList(kubernetesApi: KubernetesApi) { + const clusters = await kubernetesApi?.getClusters(); + + const namespacesListsProxyData = await Promise.all( + clusters?.map(async cluster => { + return { + clusterName: cluster.name, + proxy: await kubernetesApi.proxy({ + clusterName: cluster.name, + path: NAMESPACES_PATH, + }), + }; + }), + ); + + const namespacesLists = async () => { + let nsLists: { clusterName: string; namespaces: Namespace[] }[] = []; + for (const namespacesList of namespacesListsProxyData) { + const { clusterName } = namespacesList; + const namespaces = await namespacesList.proxy.json(); + nsLists = [...nsLists, { clusterName, namespaces: namespaces.items }]; + } + return _.uniqWith(nsLists, _.isEqual); + }; + + const namespacesList = await namespacesLists(); + + const deploymentsListsProxyData = await Promise.all( + namespacesList?.flatMap(nsList => + nsList.namespaces.map(async ns => { + return { + clusterName: nsList.clusterName, + proxy: await kubernetesApi.proxy({ + clusterName: nsList.clusterName, + path: getDeploymentsPath(ns.metadata.name), + }), + }; + }), + ), + ); + + const deploymentsLists = async () => { + let items: FluxControllerEnriched[] = []; + for (const item of deploymentsListsProxyData) { + const { clusterName } = item; + const i = await item.proxy.json(); + items = [ + ...items, + ...i.items.map((fc: FluxController) => { + return { ...fc, clusterName }; + }), + ]; + } + return _.uniqWith(items, _.isEqual); + }; + + return await deploymentsLists(); +} + +/** + * + * @public + */ +export function useGetDeployments() { + const kubernetesApi = useApi(kubernetesApiRef); + + const { isLoading, data, error } = useQuery( + 'deployments', + () => getDeploymentsList(kubernetesApi), + ); + + return { isLoading, data, error }; +} diff --git a/plugins/backstage-plugin-flux/src/hooks/useWeaveFluxDeepLink.ts b/plugins/backstage-plugin-flux/src/hooks/useWeaveFluxDeepLink.ts index 46845d7..1d8e780 100644 --- a/plugins/backstage-plugin-flux/src/hooks/useWeaveFluxDeepLink.ts +++ b/plugins/backstage-plugin-flux/src/hooks/useWeaveFluxDeepLink.ts @@ -1,5 +1,4 @@ import { configApiRef, useApi } from '@backstage/core-plugin-api'; - import { FluxObject, GitRepository, diff --git a/plugins/backstage-plugin-flux/src/images/icons.tsx b/plugins/backstage-plugin-flux/src/images/icons.tsx index c802ed6..1892100 100644 --- a/plugins/backstage-plugin-flux/src/images/icons.tsx +++ b/plugins/backstage-plugin-flux/src/images/icons.tsx @@ -87,14 +87,17 @@ export const oci = ( ); -export const imagepolicy = ( +export const flux = ( ); + +export const FluxIcon = () => flux; diff --git a/plugins/backstage-plugin-flux/src/index.ts b/plugins/backstage-plugin-flux/src/index.ts index 2341120..d64c411 100644 --- a/plugins/backstage-plugin-flux/src/index.ts +++ b/plugins/backstage-plugin-flux/src/index.ts @@ -8,4 +8,7 @@ export { EntityFluxDeploymentsCard, EntityFluxSourcesCard, EntityFluxImagePoliciesCard, + FluxRuntimeCard, + FluxRuntimePage, + FluxIcon, } from './plugin'; diff --git a/plugins/backstage-plugin-flux/src/objects.ts b/plugins/backstage-plugin-flux/src/objects.ts index 4c58533..a593388 100644 --- a/plugins/backstage-plugin-flux/src/objects.ts +++ b/plugins/backstage-plugin-flux/src/objects.ts @@ -89,6 +89,34 @@ export interface ImgPolicy { value?: string; } +export interface Namespace { + metadata: { + name: string; + labels: { [key: string]: string }; + uid: string; + resourceVersion: string; + creationTimestamp: string; + }; +} + +export type FluxController = { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace: string; + uid: string; + resourceVersion: string; + generation: number; + creationTimestamp: string; + labels: { [key: string]: string }; + annotations: { [key: string]: string }; + }; + clusterName?: string; +}; + +export type FluxControllerEnriched = FluxController & { clusterName: string }; + export class FluxObject { obj: any; clusterName: string; diff --git a/plugins/backstage-plugin-flux/src/plugin.ts b/plugins/backstage-plugin-flux/src/plugin.ts index 6a71bec..85fc446 100644 --- a/plugins/backstage-plugin-flux/src/plugin.ts +++ b/plugins/backstage-plugin-flux/src/plugin.ts @@ -1,6 +1,7 @@ import { createComponentExtension, createPlugin, + createRoutableExtension, } from '@backstage/core-plugin-api'; import { rootRouteRef } from './routes'; @@ -143,3 +144,43 @@ export const EntityFluxImagePoliciesCard = weaveworksFluxPlugin.provide( }, }), ); + +/** + * Card used to show the state of Flux Runtime. + * @public + */ +export const FluxRuntimeCard = weaveworksFluxPlugin.provide( + createComponentExtension({ + name: 'FluxRuntimeCard', + component: { + lazy: () => + import('./components/FluxRuntimeCard').then(m => m.FluxRuntimeCard), + }, + }), +); + +/** + * Page used to show Flux Controllers / Deployments in Flux Runtime + * @public + */ +export const FluxRuntimePage = weaveworksFluxPlugin.provide( + createRoutableExtension({ + name: 'FluxRuntimePage', + component: () => + import('./components/FluxRuntimePage').then(m => m.FluxRuntimePage), + mountPoint: rootRouteRef, + }), +); + +/** + * Export for Flux Icon to use in nav + * @public + */ +export const FluxIcon = weaveworksFluxPlugin.provide( + createComponentExtension({ + name: 'FluxIcon', + component: { + lazy: () => import('./images/icons').then(m => m.FluxIcon), + }, + }), +); diff --git a/tsconfig.json b/tsconfig.json index ba3f901..e6f7038 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "packages/*/src", "plugins/*/src", "plugins/*/dev", - "plugins/*/migrations" + "plugins/*/migrations", + "plugins/backstage-plugin-flux/src/components/FluxRuntimePage" ], "exclude": ["node_modules"], "compilerOptions": { diff --git a/yarn.lock b/yarn.lock index cc38a85..96297b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8097,9 +8097,9 @@ integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== "@types/react-dom@*", "@types/react-dom@<18.0.0", "@types/react-dom@^17": - version "17.0.20" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.20.tgz#e0c8901469d732b36d8473b40b679ad899da1b53" - integrity sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA== + version "17.0.22" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.22.tgz#34317e08be27b33fa9e7cdb56125b22538261bad" + integrity sha512-wHt4gkdSMb4jPp1vc30MLJxoWGsZs88URfmt3FRXoOEYrrqK3I8IuZLE/uFBb4UT6MRfI0wXFu4DS7LS0kUC7Q== dependencies: "@types/react" "^17"