Skip to content
Closed
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
59 changes: 59 additions & 0 deletions workspaces/boost/app-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
app:
title: Red Hat Developer Hub - Boost
baseUrl: http://localhost:3000

organization:
name: Red Hat

backend:
baseUrl: http://localhost:7007
listen:
port: 7007
csp:
connect-src: ["'self'", 'http:', 'https:']
cors:
origin: http://localhost:3000
methods: [GET, HEAD, PATCH, POST, PUT, DELETE]
credentials: true
database:
client: better-sqlite3
connection: ':memory:'

auth:
environment: development
providers:
guest: {}

catalog:
import:
entityFilename: catalog-info.yaml
pullRequestBranchName: backstage-integration
rules:
- allow: [Component, System, API, Resource, Location, Template]

boost:
security:
mode: 'development-only-no-auth'
adminUsers:
- 'user:default/admin'
- 'user:default/guest'
providers:
llamastack:
baseUrl: ${BOOST_LLAMA_STACK_URL:-https://llamastack-llamastack.apps.gmontero420.rhdh-pai.devfile-ci.com}
defaultModel: ${BOOST_MODEL:-vllm-inference/gpt-4.1}
kagenti:
baseUrl: ${KAGENTI_BASE_URL:-https://kagenti-api-kagenti-system.apps.gmontero420.rhdh-pai.devfile-ci.com}
defaultAgent: default
namespaces:
- team1
- team2
kagenti:
baseUrl: ${KAGENTI_BASE_URL:-https://kagenti-api-kagenti-system.apps.gmontero420.rhdh-pai.devfile-ci.com}
namespace: ${KAGENTI_NAMESPACE:-team1}
auth:
tokenEndpoint: ${KAGENTI_TOKEN_ENDPOINT:-https://keycloak-keycloak.apps.gmontero420.rhdh-pai.devfile-ci.com/realms/kagenti/protocol/openid-connect/token}
clientId: ${KAGENTI_CLIENT_ID:-spiffe://apps.gmontero420.rhdh-pai.devfile-ci.com/sa/kagenti-keycloak-client}
clientSecret: ${KAGENTI_CLIENT_SECRET}
showAllNamespaces: true
skipTlsVerify: true
verboseStreamLogging: true
1 change: 1 addition & 0 deletions workspaces/boost/packages/backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
50 changes: 50 additions & 0 deletions workspaces/boost/packages/backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "backend",
"version": "0.0.0",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/redhat-developer/rhdh-plugins",
"directory": "workspaces/boost/packages/backend"
},
"backstage": {
"role": "backend"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean"
},
"dependencies": {
"@backstage/backend-defaults": "^0.17.3",
"@backstage/backend-plugin-api": "^1.9.2",
"@backstage/config": "^1.3.6",
"@backstage/plugin-auth-backend": "^0.25.6",
"@backstage/plugin-auth-backend-module-guest-provider": "^0.2.14",
"@backstage/plugin-auth-node": "^0.6.9",
"@backstage/plugin-catalog-backend": "^3.2.0",
"@backstage/plugin-catalog-backend-module-logs": "^0.1.16",
"@backstage/plugin-permission-backend": "^0.7.8",
"@backstage/plugin-permission-backend-module-allow-all-policy": "^0.2.18",
"@backstage/plugin-permission-common": "^0.9.3",
"@backstage/plugin-permission-node": "^0.10.6",
"@backstage/plugin-proxy-backend": "^0.6.8",
"@red-hat-developer-hub/backstage-plugin-boost-backend": "workspace:*",
"@red-hat-developer-hub/backstage-plugin-boost-backend-module-kagenti": "workspace:*",
"@red-hat-developer-hub/backstage-plugin-boost-backend-module-llamastack": "workspace:*",
"@red-hat-developer-hub/backstage-plugin-kagenti-entity-provider": "workspace:*",
"@red-hat-developer-hub/backstage-plugin-llamastack-entity-provider": "workspace:*",
"better-sqlite3": "^12.0.0",
"node-gyp": "^9.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.36.3"
},
"files": [
"dist"
]
}
78 changes: 78 additions & 0 deletions workspaces/boost/packages/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright Red Hat, Inc.
*
* 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 { createBackend } from '@backstage/backend-defaults';
import { createServiceFactory } from '@backstage/backend-plugin-api';
import { metricsServiceRef } from '@backstage/backend-plugin-api/alpha';

// No-op metrics service factory: catalog-backend depends on metricsServiceRef
// but backend-defaults does not yet provide a default factory (still absent as
// of Backstage 1.52 / backend-defaults 0.17.3). Remove this shim when
// backend-defaults adds a default metrics service factory.
const noop = () => {};
const noopMetric = {
add: noop,
record: noop,
addCallback: noop,
removeCallback: noop,
};
const noopMetricsFactory = createServiceFactory({
service: metricsServiceRef,
deps: {},
factory: () => ({
createCounter: () => noopMetric,
createUpDownCounter: () => noopMetric,
createHistogram: () => noopMetric,
createGauge: () => noopMetric,
createObservableCounter: () => noopMetric,
createObservableUpDownCounter: () => noopMetric,
createObservableGauge: () => noopMetric,
}),
});

const backend = createBackend();

backend.add(noopMetricsFactory);

backend.add(import('@backstage/plugin-proxy-backend'));

backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));

backend.add(import('@backstage/plugin-catalog-backend'));
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));

backend.add(import('@backstage/plugin-permission-backend'));
backend.add(
import('@backstage/plugin-permission-backend-module-allow-all-policy'),
);

// Boost plugins
backend.add(import('@red-hat-developer-hub/backstage-plugin-boost-backend'));
backend.add(
import('@red-hat-developer-hub/backstage-plugin-boost-backend-module-llamastack'),
);
backend.add(
import('@red-hat-developer-hub/backstage-plugin-boost-backend-module-kagenti'),
);
backend.add(
import('@red-hat-developer-hub/backstage-plugin-kagenti-entity-provider'),
);
backend.add(
import('@red-hat-developer-hub/backstage-plugin-llamastack-entity-provider'),
);

backend.start();
2 changes: 2 additions & 0 deletions workspaces/boost/plugins/boost-backend/src/mcp/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@
}
}

// TODO GGM cross reference on gets / puts with catalog, new API entity for MCP servers

Check warning on line 158 in workspaces/boost/plugins/boost-backend/src/mcp/routes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ8ZxLSWuzdEoGROBPa4&open=AZ8ZxLSWuzdEoGROBPa4&pullRequest=3644

// GET /mcp/servers — list registered MCP servers
router.get('/mcp/servers', requireMcpManage, async (_req, res, next) => {
try {
Expand Down
2 changes: 2 additions & 0 deletions workspaces/boost/plugins/boost-backend/src/skills/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@
return { status: response.status, body };
}

// TODO GGM cross reference on gets / puts with catalog, AIResources entities for skills

Check warning on line 192 in workspaces/boost/plugins/boost-backend/src/skills/routes.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-plugins&issues=AZ8ZxLaRuzdEoGROBPa5&open=AZ8ZxLaRuzdEoGROBPa5&pullRequest=3644

// 5.1: GET /skills — list available skills
router.get(
'/skills',
Expand Down
23 changes: 22 additions & 1 deletion workspaces/boost/plugins/kagenti-entity-provider/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'

import { KagentiAgentEntityProvider } from './providers/KagentiAgentEntityProvider';
import { KagentiToolEntityProvider } from './providers/KagentiToolEntityProvider';
import type { KagentiEntityProviderConfig } from './types';
import type { KagentiAuthConfig, KagentiEntityProviderConfig } from './types';

/**
* Default upstream refresh interval for agent entities (5 minutes).
Expand Down Expand Up @@ -112,9 +112,27 @@ export const catalogModuleKagentiEntityProvider = createBackendModule({
/**
* Read Kagenti entity provider configuration from app-config.yaml.
*/
function readKagentiAuthConfig(
config: typeof coreServices.rootConfig extends { T: infer T } ? T : never,
): KagentiAuthConfig | undefined {
const authConfig = config.getOptionalConfig('boost.kagenti.auth');
if (!authConfig) {
return undefined;
}
const tokenEndpoint = authConfig.getOptionalString('tokenEndpoint');
const clientId = authConfig.getOptionalString('clientId');
const clientSecret = authConfig.getOptionalString('clientSecret');
if (!tokenEndpoint || !clientId || !clientSecret) {
return undefined;
}
return { tokenEndpoint, clientId, clientSecret };
}

function readKagentiEntityProviderConfig(
config: typeof coreServices.rootConfig extends { T: infer T } ? T : never,
): KagentiEntityProviderConfig {
const auth = readKagentiAuthConfig(config);

// Try the entity-provider-specific config first
const epConfig = config.getOptionalConfig('boost.entityProviders.kagenti');

Expand All @@ -128,6 +146,7 @@ function readKagentiEntityProviderConfig(
toolRefreshIntervalSeconds: epConfig.getOptionalNumber(
'toolRefreshIntervalSeconds',
),
auth,
};
}

Expand All @@ -138,11 +157,13 @@ function readKagentiEntityProviderConfig(
return {
baseUrl: providerConfig.getString('baseUrl'),
namespaces: providerConfig.getOptionalStringArray('namespaces'),
auth,
};
}

// Default to localhost
return {
baseUrl: 'http://localhost:8080',
auth,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,58 @@ describe('KagentiAgentEntityProvider', () => {
expect(mutation.entities).toHaveLength(0);
});

it('should fetch from /api/v1/agents URL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => [],
} as Response);

const provider = new KagentiAgentEntityProvider({
config: defaultConfig,
logger: mockServices.logger.mock(),
taskRunner,
});

await provider.connect(mockConnection);
await taskRunner.runAll();

expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/agents?namespace=default',
expect.objectContaining({ headers: expect.any(Object) }),
);
});

it('should handle { items: [...] } response shape', async () => {
const agents: AgentCard[] = [
{
id: 'wrapped-agent',
name: 'Wrapped Agent',
url: 'http://example.com',
namespace: 'default',
lifecycleStage: 'published',
},
];

mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ items: agents }),
} as Response);

const provider = new KagentiAgentEntityProvider({
config: defaultConfig,
logger: mockServices.logger.mock(),
taskRunner,
});

await provider.connect(mockConnection);
await taskRunner.runAll();

const mutation = (mockConnection.applyMutation as jest.Mock).mock
.calls[0][0];
expect(mutation.entities).toHaveLength(1);
expect(mutation.entities[0].entity.metadata.title).toBe('Wrapped Agent');
});

it('should scan multiple namespaces', async () => {
const ns1Agents: AgentCard[] = [
{ id: 'agent-1', name: 'Agent 1', url: 'http://example.com' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import {
mapLifecycleStage,
mapOwner,
sanitizeEntityName,
unwrapItems,
} from './entityHelpers';
import { KagentiAuthClient } from './kagentiAuth';

const PROVIDER_ID = 'kagenti-agent-entity-provider';

Expand All @@ -60,6 +62,7 @@ export class KagentiAgentEntityProvider implements EntityProvider {
private readonly config: KagentiEntityProviderConfig;
private readonly logger: LoggerService;
private readonly scheduleFn: () => Promise<void>;
private readonly authClient?: KagentiAuthClient;
private connection?: EntityProviderConnection;
private cachedEntities: Entity[] = [];

Expand All @@ -71,6 +74,9 @@ export class KagentiAgentEntityProvider implements EntityProvider {
this.config = options.config;
this.logger = options.logger.child({ target: this.getProviderName() });
this.scheduleFn = this.createScheduleFn(options.taskRunner);
if (options.config.auth) {
this.authClient = new KagentiAuthClient(options.config.auth);
}
}

getProviderName(): string {
Expand Down Expand Up @@ -126,22 +132,29 @@ export class KagentiAgentEntityProvider implements EntityProvider {
const namespaces = this.config.namespaces ?? ['default'];
Comment thread
gabemontero marked this conversation as resolved.
const allAgents: AgentCard[] = [];

const headers: Record<string, string> = { Accept: 'application/json' };
if (this.authClient) {
const token = await this.authClient.getBearerToken();
headers.Authorization = `Bearer ${token}`;
}

for (const ns of namespaces) {
const url = `${this.config.baseUrl}/a2a/agents?namespace=${encodeURIComponent(ns)}`;
const url = `${this.config.baseUrl}/api/v1/agents?namespace=${encodeURIComponent(ns)}`;
try {
const response = await fetch(url);
const response = await fetch(url, { headers });
if (!response.ok) {
this.logger.warn(
`Kagenti API returned ${response.status} for namespace ${ns}`,
);
continue;
}
const agents = (await response.json()) as AgentCard[];
if (Array.isArray(agents)) {
allAgents.push(
...agents.map(a => ({ ...a, namespace: a.namespace ?? ns })),
);
}
const body = (await response.json()) as
| { items: AgentCard[] }
| AgentCard[];
const agents = unwrapItems(body);
allAgents.push(
...agents.map(a => ({ ...a, namespace: a.namespace ?? ns })),
);
} catch (error) {
this.logger.warn(
`Failed to fetch agents for namespace ${ns}`,
Expand Down
Loading
Loading