Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions workspaces/konflux/.changeset/better-melons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@red-hat-developer-hub/backstage-plugin-konflux-backend': patch
---

- Fix: replace unfiltered catalog scan in getRelatedEntities with targeted lookup using hasPart relations and getEntitiesByRefs.
- Perf: cache K8s API clients per cluster and catalog lookups (30s TTL) to avoid redundant work across parallel requests.
- Perf: strip metadata.managedFields from K8s and Kubearchive responses to reduce payload size.
5 changes: 5 additions & 0 deletions workspaces/konflux/.changeset/fifty-moose-appear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-konflux': patch
---

- Increase react-query staleTime from 30s to 5min and disable refetchOnWindowFocus to reduce unnecessary re-fetches.
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ describe('config', () => {
mockCatalog = {
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getEntitiesByRefs: jest.fn().mockResolvedValue({ items: [] }),
} as unknown as CatalogService;

mockCredentials = {} as BackstageCredentials;
Expand Down Expand Up @@ -269,8 +270,13 @@ describe('config', () => {

mockParseEntityKonfluxConfig.mockReturnValue(clusterConfigs);

const entity = createMockEntity('test-entity');
(mockCatalog.getEntities as jest.Mock).mockResolvedValue({
const entity = createMockEntity('test-entity', undefined, [
{
type: 'hasPart',
targetRef: 'component:default/subcomponent1',
},
]);
(mockCatalog.getEntitiesByRefs as jest.Mock).mockResolvedValue({
items: [subcomponent1],
});

Expand Down Expand Up @@ -502,14 +508,18 @@ describe('config', () => {
authProvider: 'serviceAccount',
};

const entity = createMockEntity('test-entity');
const subcomponent1 = createMockEntity('sub1', {}, [
{ type: 'partOf', targetRef: 'component:default/test-entity' },
]);
const subcomponent2 = createMockEntity('sub2', {}, [
{ type: 'partOf', targetRef: 'component:default/test-entity' },
]);

const entity = createMockEntity('test-entity', undefined, [
{ type: 'hasPart', targetRef: 'component:default/sub1' },
{ type: 'hasPart', targetRef: 'component:default/sub2' },
]);

mockParseSubcomponentClusterConfigurations.mockReturnValue([
createMockSubcomponentClusterConfig('sub1', 'cluster1', 'ns1', [
'app1',
Expand All @@ -519,7 +529,7 @@ describe('config', () => {
]),
]);

(mockCatalog.getEntities as jest.Mock).mockResolvedValue({
(mockCatalog.getEntitiesByRefs as jest.Mock).mockResolvedValue({
items: [subcomponent1, subcomponent2],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,21 @@
* limitations under the License.
*/
import { KonfluxConfig } from '@red-hat-developer-hub/backstage-plugin-konflux-common';
import type { KubeConfig } from '@kubernetes/client-node';
import type { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node';
import { KonfluxLogger } from './logger';
import { getKubeClient } from './kube-client';

/**
* Cache for CustomObjectsApi clients keyed by "cluster:apiUrl".
*
* Per-request auth headers are injected via middleware in the callers,
* so cached clients are safe to share across users.
*
* Caching avoids creating a new KubeConfig, CustomObjectsApi, and TLS
* connection on every API call.
*/
const clientCache = new Map<string, CustomObjectsApi>();

/**
* Creates a KubeConfig for connecting to a Kubernetes cluster.
*
Expand Down Expand Up @@ -94,3 +105,54 @@ export const createKubeConfig = async (
return null;
}
};

/**
* Returns a cached CustomObjectsApi client for the given cluster, creating
* one on first access. The client is safe to share across requests because
* auth headers are injected per-request via middleware.
*
* @param konfluxConfig - Konflux configuration
* @param cluster - Cluster name
* @param konfluxLogger - Logger instance
* @param useKubearchiveUrl - If true, targets the kubearchive API URL
* @returns CustomObjectsApi instance or null if client creation fails
*/
export const getOrCreateClient = async (
konfluxConfig: KonfluxConfig | undefined,
cluster: string,
konfluxLogger: KonfluxLogger,
useKubearchiveUrl = false,
): Promise<CustomObjectsApi | null> => {
if (!konfluxConfig) {
return null;
}

const clusterConfig = konfluxConfig.clusters?.[cluster];
const apiUrl = useKubearchiveUrl
? clusterConfig?.kubearchiveApiUrl
: clusterConfig?.apiUrl;

const cacheKey = `${cluster}:${apiUrl}`;

const cached = clientCache.get(cacheKey);
if (cached) {
return cached;
}

const kc = await createKubeConfig(
konfluxConfig,
cluster,
konfluxLogger,
clusterConfig?.serviceAccountToken,
useKubearchiveUrl,
);

if (!kc) {
return null;
}

const { CustomObjectsApi } = await getKubeClient();
const client = kc.makeApiClient(CustomObjectsApi);
clientCache.set(cacheKey, client);
return client;
};
29 changes: 16 additions & 13 deletions workspaces/konflux/plugins/konflux-backend/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
import { BackstageCredentials } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';

import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { CatalogService } from '@backstage/plugin-catalog-node';
import { KonfluxLogger } from './logger';

Expand All @@ -37,8 +37,9 @@ import { KonfluxLogger } from './logger';
*
* In Konflux plugin, a "subcomponent" refers to a Backstage Component that has a
* `subcomponentOf` relationship to the current Component being viewed.
* Backstage automatically creates a `partOf` relation from the subcomponent
* entity to the parent entity, which is what we query for.
* Backstage automatically creates a `hasPart` relation on the parent entity
* for each subcomponent, so we read those refs and batch-fetch the entities
* instead of scanning the entire catalog.
*
* @param entity - The main/parent entity to find related entities for
* @param credentials - Backstage credentials for authentication
Expand All @@ -53,20 +54,22 @@ const getRelatedEntities = async (
): Promise<Entity[] | null> => {
try {
if (catalog) {
const entityRef = stringifyEntityRef(entity);
const hasPartRefs = (entity.relations || [])
.filter(rel => rel.type === 'hasPart')
.map(rel => rel.targetRef);

const allEntitiesForFiltering = await catalog.getEntities(
{},
if (hasPartRefs.length === 0) {
return [];
}

const response = await catalog.getEntitiesByRefs(
{ entityRefs: hasPartRefs },
{ credentials },
);
const filteredRelatedEntities = allEntitiesForFiltering.items.filter(
item =>
item.relations?.some(
rel => rel.type === 'partOf' && rel.targetRef === entityRef,
),
);

return filteredRelatedEntities;
return response.items.filter(
(item): item is Entity => item !== undefined,
);
}
return null;
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@

import { KubearchiveService } from '../kubearchive-service';
import { LoggerService } from '@backstage/backend-plugin-api';
import type { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node';
import type { CustomObjectsApi } from '@kubernetes/client-node';
import {
KonfluxConfig,
K8sResourceCommonWithClusterInfo,
} from '@red-hat-developer-hub/backstage-plugin-konflux-common';
import { KonfluxLogger } from '../../helpers/logger';
import { createKubeConfig } from '../../helpers/client-factory';
import { getOrCreateClient } from '../../helpers/client-factory';
import { getKubeClient } from '../../helpers/kube-client';

jest.mock('../../helpers/logger');
Expand All @@ -32,7 +32,6 @@ jest.mock('../../helpers/kube-client');
describe('KubearchiveService', () => {
let mockLogger: jest.Mocked<LoggerService>;
let mockKonfluxLogger: jest.Mocked<KonfluxLogger>;
let mockKubeConfig: jest.Mocked<KubeConfig>;
let mockCustomObjectsApi: jest.Mocked<CustomObjectsApi>;
let service: KubearchiveService;

Expand Down Expand Up @@ -104,18 +103,13 @@ describe('KubearchiveService', () => {
listNamespacedCustomObjectWithHttpInfo: jest.fn(),
} as unknown as jest.Mocked<CustomObjectsApi>;

mockKubeConfig = {
makeApiClient: jest.fn().mockReturnValue(mockCustomObjectsApi),
loadFromOptions: jest.fn(),
} as unknown as jest.Mocked<KubeConfig>;

(
KonfluxLogger as jest.MockedClass<typeof KonfluxLogger>
).mockImplementation(() => mockKonfluxLogger);

(
createKubeConfig as jest.MockedFunction<typeof createKubeConfig>
).mockResolvedValue(mockKubeConfig);
getOrCreateClient as jest.MockedFunction<typeof getOrCreateClient>
).mockResolvedValue(mockCustomObjectsApi);

class MockObservable<T> {
constructor(public value: T) {}
Expand Down Expand Up @@ -545,10 +539,10 @@ describe('KubearchiveService', () => {
});

(
createKubeConfig as jest.MockedFunction<typeof createKubeConfig>
getOrCreateClient as jest.MockedFunction<typeof getOrCreateClient>
).mockResolvedValue(null);

// provide OIDC token so it passes token check and reaches createKubeConfig
// provide OIDC token so it passes token check and reaches getOrCreateClient
await expect(
service.fetchResources({
konfluxConfig,
Expand All @@ -564,7 +558,7 @@ describe('KubearchiveService', () => {
).rejects.toThrow(`Cluster '${cluster}' not found`);

expect(mockKonfluxLogger.error).toHaveBeenCalledWith(
'Failed to create KubeConfig - cluster not found',
'Failed to create API client - cluster not found',
undefined,
expect.objectContaining({
cluster,
Expand All @@ -586,7 +580,7 @@ describe('KubearchiveService', () => {
});

(
createKubeConfig as jest.MockedFunction<typeof createKubeConfig>
getOrCreateClient as jest.MockedFunction<typeof getOrCreateClient>
).mockResolvedValue(null);

await expect(
Expand All @@ -602,7 +596,7 @@ describe('KubearchiveService', () => {
).rejects.toThrow(`Cluster '${cluster}' not found`);

expect(mockKonfluxLogger.error).toHaveBeenCalledWith(
'Failed to create KubeConfig - cluster not found',
'Failed to create API client - cluster not found',
undefined,
expect.objectContaining({
cluster,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import {
FetchOptions,
} from '../resource-fetcher';
import { LoggerService } from '@backstage/backend-plugin-api';
import type { KubeConfig, CustomObjectsApi } from '@kubernetes/client-node';
import type { CustomObjectsApi } from '@kubernetes/client-node';
import { K8sResourceCommonWithClusterInfo } from '@red-hat-developer-hub/backstage-plugin-konflux-common';
import { KubearchiveService } from '../kubearchive-service';
import { createKubeConfig } from '../../helpers/client-factory';
import { getOrCreateClient } from '../../helpers/client-factory';
import { KonfluxLogger } from '../../helpers/logger';
import { getKubeClient } from '../../helpers/kube-client';

Expand All @@ -36,7 +36,6 @@ describe('ResourceFetcherService', () => {
let mockLogger: jest.Mocked<LoggerService>;
let mockKonfluxLogger: jest.Mocked<KonfluxLogger>;
let mockKubearchiveService: jest.Mocked<KubearchiveService>;
let mockKubeConfig: jest.Mocked<KubeConfig>;
let mockCustomObjectsApi: jest.Mocked<CustomObjectsApi>;
let service: ResourceFetcherService;

Expand Down Expand Up @@ -122,10 +121,6 @@ describe('ResourceFetcherService', () => {
listNamespacedCustomObjectWithHttpInfo: jest.fn(),
} as unknown as jest.Mocked<CustomObjectsApi>;

mockKubeConfig = {
makeApiClient: jest.fn().mockReturnValue(mockCustomObjectsApi),
} as unknown as jest.Mocked<KubeConfig>;

(
KonfluxLogger as jest.MockedClass<typeof KonfluxLogger>
).mockImplementation(() => mockKonfluxLogger);
Expand All @@ -135,8 +130,8 @@ describe('ResourceFetcherService', () => {
).mockImplementation(() => mockKubearchiveService);

(
createKubeConfig as jest.MockedFunction<typeof createKubeConfig>
).mockResolvedValue(mockKubeConfig);
getOrCreateClient as jest.MockedFunction<typeof getOrCreateClient>
).mockResolvedValue(mockCustomObjectsApi);

class MockObservable<T> {
constructor(public value: T) {}
Expand Down Expand Up @@ -174,11 +169,10 @@ describe('ResourceFetcherService', () => {

expect(result.items).toEqual(mockItems);
expect(result.continueToken).toBeUndefined();
expect(createKubeConfig).toHaveBeenCalledWith(
expect(getOrCreateClient).toHaveBeenCalledWith(
context.konfluxConfig,
'cluster1',
mockKonfluxLogger,
'service-token-123',
);
expect(
mockCustomObjectsApi.listNamespacedCustomObjectWithHttpInfo,
Expand Down Expand Up @@ -301,11 +295,10 @@ describe('ResourceFetcherService', () => {

await service.fetchFromKubernetes(context);

expect(createKubeConfig).toHaveBeenCalledWith(
expect.anything(),
expect(getOrCreateClient).toHaveBeenCalledWith(
expect.anything(),
'cluster1',
expect.anything(),
'oidc-token-456',
);
expect(mockKonfluxLogger.debug).toHaveBeenCalledWith(
'Using OIDC token for authentication',
Expand Down Expand Up @@ -423,7 +416,7 @@ describe('ResourceFetcherService', () => {
it('should throw error when cluster not found', async () => {
const context = createMockFetchContext();
(
createKubeConfig as jest.MockedFunction<typeof createKubeConfig>
getOrCreateClient as jest.MockedFunction<typeof getOrCreateClient>
).mockResolvedValue(null);

await expect(service.fetchFromKubernetes(context)).rejects.toThrow(
Expand Down
Loading
Loading