From 8fa8d87e3b45377db57315a8a8a688794425b4b0 Mon Sep 17 00:00:00 2001 From: KoB <63000843+kingbj940429@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:35:34 +0900 Subject: [PATCH] feat(kubernetes-plugin): add secrets rendering (#31415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: mask secret datas Signed-off-by: 김병준 * feat: add secrets accordion Signed-off-by: 김병준 * feat: add test code for secrets accordion Signed-off-by: 김병준 * feat: add test code for secrets fetch Signed-off-by: 김병준 * chore: changeset Signed-off-by: 김병준 * chore: yarn build:api-reports Signed-off-by: 김병준 * chore: remove secrets from default object and rollback test code Signed-off-by: 김병준 * chore: extract to helper function Signed-off-by: 김병준 * chore: add test code for helper function Signed-off-by: 김병준 --------- Signed-off-by: 김병준 --- .changeset/legal-otters-punch.md | 7 + .../src/service/KubernetesFetcher.test.ts | 155 ++++++++++++++++++ .../src/service/KubernetesFetcher.ts | 37 ++++- plugins/kubernetes-common/report.api.md | 11 ++ plugins/kubernetes-common/src/types.ts | 8 + .../kubernetes-common/src/util/response.ts | 4 + .../src/__fixtures__/1-secrets.json | 26 +++ .../src/__fixtures__/2-secrets.json | 47 ++++++ .../src/components/Cluster/Cluster.tsx | 6 + .../SecretsAccordions.test.tsx | 48 ++++++ .../SecretsAccordions/SecretsAccordions.tsx | 108 ++++++++++++ .../SecretsAccordions/SecretsDrawer.test.tsx | 37 +++++ .../SecretsAccordions/SecretsDrawer.tsx | 64 ++++++++ .../src/components/SecretsAccordions/index.ts | 16 ++ .../src/hooks/GroupedResponses.ts | 1 + 15 files changed, 568 insertions(+), 7 deletions(-) create mode 100644 .changeset/legal-otters-punch.md create mode 100644 plugins/kubernetes-react/src/__fixtures__/1-secrets.json create mode 100644 plugins/kubernetes-react/src/__fixtures__/2-secrets.json create mode 100644 plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.test.tsx create mode 100644 plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.tsx create mode 100644 plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.test.tsx create mode 100644 plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.tsx create mode 100644 plugins/kubernetes-react/src/components/SecretsAccordions/index.ts diff --git a/.changeset/legal-otters-punch.md b/.changeset/legal-otters-punch.md new file mode 100644 index 0000000000..2003492e29 --- /dev/null +++ b/.changeset/legal-otters-punch.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +'@backstage/plugin-kubernetes-react': patch +--- + +Add Kubernetes Plugin Secrets Accordion with masked secret datas diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts index e525c74077..fbacadd2a2 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.test.ts @@ -1263,5 +1263,160 @@ describe('KubernetesFetcher', () => { ]); expect(result.responses).toMatchObject(POD_METRICS_FIXTURE); }); + it('should mask secret data values with ***', async () => { + worker.use( + rest.get('http://localhost:9999/api/v1/secrets', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'secret-name' }, + data: { + username: 'dXNlcm5hbWU=', + password: 'cGFzc3dvcmQ=', + apiKey: 'YXBpS2V5', + }, + }, + ], + }), + ), + ), + ); + + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'http://localhost:9999', + authMetadata: {}, + }, + credential: { type: 'bearer token', token: 'token' }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'secrets', + objectType: 'secrets', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(result).toStrictEqual({ + errors: [], + responses: [ + { + type: 'secrets', + resources: [ + { + metadata: { + name: 'secret-name', + labels: {}, + }, + data: { + username: '***', + password: '***', + apiKey: '***', + }, + }, + ], + }, + ], + }); + }); + + it('should handle secrets without data field', async () => { + worker.use( + rest.get('http://localhost:9999/api/v1/secrets', (req, res, ctx) => + res( + checkToken(req, ctx, 'token'), + withLabels(req, ctx, { + items: [ + { + metadata: { name: 'secret-without-data' }, + }, + ], + }), + ), + ), + ); + + const result = await sut.fetchObjectsForService({ + serviceId: 'some-service', + clusterDetails: { + name: 'cluster1', + url: 'http://localhost:9999', + authMetadata: {}, + }, + credential: { type: 'bearer token', token: 'token' }, + objectTypesToFetch: new Set([ + { + group: '', + apiVersion: 'v1', + plural: 'secrets', + objectType: 'secrets', + }, + ]), + labelSelector: '', + customResources: [], + }); + + expect(result).toStrictEqual({ + errors: [], + responses: [ + { + type: 'secrets', + resources: [ + { + metadata: { + name: 'secret-without-data', + labels: {}, + }, + }, + ], + }, + ], + }); + }); + }); + + describe('transformResources', () => { + let fetcher: KubernetesClientBasedFetcher; + + beforeEach(() => { + fetcher = new KubernetesClientBasedFetcher({ + logger: mockServices.logger.mock(), + }); + }); + + it('removes List suffix from custom resource kinds', () => { + const result = (fetcher as any).transformResources( + 'customresources', + 'HelloWorldList', + [{ name: 'foo' }], + ); + expect(result[0].kind).toBe('HelloWorld'); + }); + + it('masks secret data values', () => { + const result = (fetcher as any).transformResources( + 'secrets', + 'SecretList', + [{ data: { password: 'secret123' } }], + ); + expect(result[0].data.password).toBe('***'); + }); + + it('returns other types unchanged', () => { + const items = [{ name: 'pod' }]; + const result = (fetcher as any).transformResources( + 'pods', + 'PodList', + items, + ); + expect(result).toBe(items); + }); }); }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts index 4b9b73e488..e89b86a8e0 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFetcher.ts @@ -103,13 +103,7 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { ? r.json().then( ({ kind, items }): FetchResponse => ({ type: objectType, - resources: - objectType === 'customresources' - ? items.map((item: JsonObject) => ({ - ...item, - kind: kind.replace(/(List)$/, ''), - })) - : items, + resources: this.transformResources(objectType, kind, items), }), ) : this.handleUnsuccessfulResponse(params.clusterDetails.name, r), @@ -320,4 +314,33 @@ export class KubernetesClientBasedFetcher implements KubernetesFetcher { } return [url, requestInit]; } + + private transformResources( + objectType: string, + kind: string, + items: JsonObject[], + ): JsonObject[] { + if (objectType === 'customresources') { + return items.map((item: JsonObject) => ({ + ...item, + kind: kind.replace(/(List)$/, ''), + })); + } + + if (objectType === 'secrets') { + return items.map((item: JsonObject) => { + if (item.data && typeof item.data === 'object') { + return { + ...item, + data: Object.fromEntries( + Object.keys(item.data).map(key => [key, '***']), + ), + }; + } + return item; + }); + } + + return items; + } } diff --git a/plugins/kubernetes-common/report.api.md b/plugins/kubernetes-common/report.api.md index 748ae94656..bf99cdaddd 100644 --- a/plugins/kubernetes-common/report.api.md +++ b/plugins/kubernetes-common/report.api.md @@ -254,6 +254,7 @@ export type FetchResponse = | PodFetchResponse | ServiceFetchResponse | ConfigMapFetchResponse + | SecretFetchResponse | DeploymentFetchResponse | LimitRangeFetchResponse | ResourceQuotaFetchResponse @@ -283,6 +284,8 @@ export interface GroupedResponses extends DeploymentResources { // (undocumented) jobs: V1Job[]; // (undocumented) + secrets: V1Secret[]; + // (undocumented) services: V1Service[]; // (undocumented) statefulsets: V1StatefulSet[]; @@ -447,6 +450,14 @@ export interface ResourceRef { namespace: string; } +// @public (undocumented) +export interface SecretFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'secrets'; +} + // @public (undocumented) export interface SecretsFetchResponse { // (undocumented) diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 8ce3d80409..30846e3b8e 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -129,6 +129,7 @@ export type FetchResponse = | PodFetchResponse | ServiceFetchResponse | ConfigMapFetchResponse + | SecretFetchResponse | DeploymentFetchResponse | LimitRangeFetchResponse | ResourceQuotaFetchResponse @@ -161,6 +162,12 @@ export interface ConfigMapFetchResponse { resources: Array; } +/** @public */ +export interface SecretFetchResponse { + type: 'secrets'; + resources: Array; +} + /** @public */ export interface DeploymentFetchResponse { type: 'deployments'; @@ -297,6 +304,7 @@ export interface DeploymentResources { export interface GroupedResponses extends DeploymentResources { services: V1Service[]; configMaps: V1ConfigMap[]; + secrets: V1Secret[]; ingresses: V1Ingress[]; jobs: V1Job[]; cronJobs: V1CronJob[]; diff --git a/plugins/kubernetes-common/src/util/response.ts b/plugins/kubernetes-common/src/util/response.ts index b62fa162d1..ba181c48e2 100644 --- a/plugins/kubernetes-common/src/util/response.ts +++ b/plugins/kubernetes-common/src/util/response.ts @@ -40,6 +40,9 @@ export const groupResponses = ( case 'configmaps': prev.configMaps.push(...next.resources); break; + case 'secrets': + prev.secrets.push(...next.resources); + break; case 'horizontalpodautoscalers': prev.horizontalPodAutoscalers.push(...next.resources); break; @@ -71,6 +74,7 @@ export const groupResponses = ( deployments: [], services: [], configMaps: [], + secrets: [], horizontalPodAutoscalers: [], ingresses: [], jobs: [], diff --git a/plugins/kubernetes-react/src/__fixtures__/1-secrets.json b/plugins/kubernetes-react/src/__fixtures__/1-secrets.json new file mode 100644 index 0000000000..c11f7ff816 --- /dev/null +++ b/plugins/kubernetes-react/src/__fixtures__/1-secrets.json @@ -0,0 +1,26 @@ +{ + "secrets": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "app-secret", + "namespace": "default", + "uid": "1ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732552", + "creationTimestamp": "2021-07-16T22:39:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "app": "dice-roller" + }, + "annotations": {} + }, + "data": { + "database.password": "***", + "api.key": "***", + "jwt.secret": "***", + "ssl.cert": "***" + } + } + ] +} diff --git a/plugins/kubernetes-react/src/__fixtures__/2-secrets.json b/plugins/kubernetes-react/src/__fixtures__/2-secrets.json new file mode 100644 index 0000000000..bdc881230a --- /dev/null +++ b/plugins/kubernetes-react/src/__fixtures__/2-secrets.json @@ -0,0 +1,47 @@ +{ + "secrets": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "app-secret", + "namespace": "default", + "uid": "1ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732552", + "creationTimestamp": "2021-07-16T22:39:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "app": "dice-roller" + }, + "annotations": {} + }, + "data": { + "database.password": "***", + "api.key": "***", + "jwt.secret": "***", + "ssl.cert": "***" + } + }, + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "redis-secret", + "namespace": "default", + "uid": "2ea073bc-7a4b-4b99-8321-0305bce85568", + "resourceVersion": "1362732553", + "creationTimestamp": "2021-07-16T22:40:58Z", + "labels": { + "backstage.io/kubernetes-id": "dice-roller", + "app": "redis" + }, + "annotations": {} + }, + "data": { + "redis.password": "***", + "redis.auth": "***", + "admin.token": "***" + } + } + ] +} diff --git a/plugins/kubernetes-react/src/components/Cluster/Cluster.tsx b/plugins/kubernetes-react/src/components/Cluster/Cluster.tsx index 65fc44e170..ca0f57e855 100644 --- a/plugins/kubernetes-react/src/components/Cluster/Cluster.tsx +++ b/plugins/kubernetes-react/src/components/Cluster/Cluster.tsx @@ -44,6 +44,7 @@ import { StatusError, StatusOK } from '@backstage/core-components'; import { PodMetricsContext } from '../../hooks/usePodMetrics'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { kubernetesReactTranslationRef } from '../../translation'; +import { SecretsAccordions } from '../SecretsAccordions'; type ClusterSummaryProps = { clusterName: string; @@ -181,6 +182,11 @@ export const Cluster = ({ clusterObjects, podsWithErrors }: ClusterProps) => { ) : undefined} + {groupedResponses.secrets.length > 0 ? ( + + + + ) : undefined} {groupedResponses.cronJobs.length > 0 ? ( diff --git a/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.test.tsx b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.test.tsx new file mode 100644 index 0000000000..1bdafa293b --- /dev/null +++ b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2021 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 { screen } from '@testing-library/react'; +import { SecretsAccordions } from './SecretsAccordions'; +import * as oneSecretsFixture from '../../__fixtures__/1-secrets.json'; +import * as twoSecretsFixture from '../../__fixtures__/2-secrets.json'; +import { renderInTestApp } from '@backstage/test-utils'; +import { kubernetesProviders } from '../../hooks/test-utils'; + +describe('SecretsAccordions', () => { + it('should render 1 secret', async () => { + const wrapper = kubernetesProviders(oneSecretsFixture, new Set()); + + await renderInTestApp(wrapper()); + + expect(screen.getByText('app-secret')).toBeInTheDocument(); + expect(screen.getByText('Secret')).toBeInTheDocument(); + expect(screen.getByText('namespace: default')).toBeInTheDocument(); + expect(screen.getByText('Data Count: 4')).toBeInTheDocument(); + }); + + it('should render 2 secrets', async () => { + const wrapper = kubernetesProviders(twoSecretsFixture, new Set()); + + await renderInTestApp(wrapper()); + + expect(screen.getByText('app-secret')).toBeInTheDocument(); + expect(screen.getByText('redis-secret')).toBeInTheDocument(); + expect(screen.getAllByText('Secret')).toHaveLength(2); + expect(screen.getAllByText('namespace: default')).toHaveLength(2); + expect(screen.getByText('Data Count: 4')).toBeInTheDocument(); + expect(screen.getByText('Data Count: 3')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.tsx b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.tsx new file mode 100644 index 0000000000..4271a2af8b --- /dev/null +++ b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsAccordions.tsx @@ -0,0 +1,108 @@ +/* + * Copyright 2021 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 { useContext } from 'react'; +import Accordion from '@material-ui/core/Accordion'; +import AccordionDetails from '@material-ui/core/AccordionDetails'; +import AccordionSummary from '@material-ui/core/AccordionSummary'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import type { V1Secret } from '@kubernetes/client-node'; +import { SecretsDrawer } from './SecretsDrawer.tsx'; +import { GroupedResponsesContext } from '../../hooks'; +import { StructuredMetadataTable } from '@backstage/core-components'; + +type SecretSummaryProps = { + secret: V1Secret; +}; + +const SecretSummary = ({ secret }: SecretSummaryProps) => { + return ( + + + + + + + + Data Count: {secret.data ? Object.keys(secret.data).length : 0} + + + + ); +}; + +type SecretsCardProps = { + secret: V1Secret; +}; + +const SecretCard = ({ secret }: SecretsCardProps) => { + const metadata: any = {}; + + metadata.data = secret.data; + + return ( + + ); +}; + +export type SecretsAccordionsProps = {}; + +type SecretsAccordionProps = { + secret: V1Secret; +}; + +const SecretsAccordion = ({ secret }: SecretsAccordionProps) => { + return ( + + }> + + + + + + + ); +}; + +export const SecretsAccordions = ({}: SecretsAccordionsProps) => { + const groupedResponses = useContext(GroupedResponsesContext); + return ( + + {groupedResponses.secrets.map((secret, i) => ( + + + + ))} + + ); +}; diff --git a/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.test.tsx b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.test.tsx new file mode 100644 index 0000000000..6dd1230ee4 --- /dev/null +++ b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright 2021 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 * as oneSecretsFixture from '../../__fixtures__/1-secrets.json'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { SecretsDrawer } from './SecretsDrawer'; +import { kubernetesClusterLinkFormatterApiRef } from '../../api'; + +describe('SecretsDrawer', () => { + it('should render secret drawer', async () => { + const { getByText, getAllByText } = await renderInTestApp( + + + , + ); + + expect(getAllByText('app-secret')).toHaveLength(3); + expect(getAllByText('Secret')).toHaveLength(3); + expect(getByText('YAML')).toBeInTheDocument(); + expect(getByText('namespace: default')).toBeInTheDocument(); + }); +}); diff --git a/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.tsx b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.tsx new file mode 100644 index 0000000000..72df6bdae9 --- /dev/null +++ b/plugins/kubernetes-react/src/components/SecretsAccordions/SecretsDrawer.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2020 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 { KubernetesStructuredMetadataTableDrawer } from '../KubernetesDrawer'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; +import Chip from '@material-ui/core/Chip'; +import type { V1Secret } from '@kubernetes/client-node'; + +export const SecretsDrawer = ({ + secret, + expanded, +}: { + secret: V1Secret; + expanded?: boolean; +}) => { + const namespace = secret.metadata?.namespace; + return ( + { + return secretObject || {}; + }} + > + + + + {secret.metadata?.name ?? 'unknown object'} + + + + + Secret + + + {namespace && ( + + + + )} + + + ); +}; diff --git a/plugins/kubernetes-react/src/components/SecretsAccordions/index.ts b/plugins/kubernetes-react/src/components/SecretsAccordions/index.ts new file mode 100644 index 0000000000..461537fe72 --- /dev/null +++ b/plugins/kubernetes-react/src/components/SecretsAccordions/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2021 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 * from './SecretsAccordions.tsx'; diff --git a/plugins/kubernetes-react/src/hooks/GroupedResponses.ts b/plugins/kubernetes-react/src/hooks/GroupedResponses.ts index 1dbf16c57e..750ee58846 100644 --- a/plugins/kubernetes-react/src/hooks/GroupedResponses.ts +++ b/plugins/kubernetes-react/src/hooks/GroupedResponses.ts @@ -28,6 +28,7 @@ export const GroupedResponsesContext = createContext({ daemonSets: [], services: [], configMaps: [], + secrets: [], horizontalPodAutoscalers: [], ingresses: [], jobs: [],