diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index 2e96d31b4a..ca48dcb418 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -170,6 +170,51 @@ export const boostConfigFields: { readonly configScope: ConfigScope; readonly description: string; }; + readonly 'boost.skillsMarketplace.runtimes': { + readonly schema: z.ZodOptional< + z.ZodArray< + z.ZodObject< + { + id: z.ZodString; + name: z.ZodString; + description: z.ZodOptional; + image: z.ZodString; + language: z.ZodOptional; + footprint: z.ZodOptional>; + features: z.ZodOptional>; + status: z.ZodOptional< + z.ZodEnum<['active', 'deprecated', 'experimental']> + >; + }, + 'strip', + z.ZodTypeAny, + { + name: string; + id: string; + image: string; + description?: string | undefined; + status?: 'active' | 'deprecated' | 'experimental' | undefined; + language?: string | undefined; + footprint?: 'small' | 'medium' | 'large' | undefined; + features?: string[] | undefined; + }, + { + name: string; + id: string; + image: string; + description?: string | undefined; + status?: 'active' | 'deprecated' | 'experimental' | undefined; + language?: string | undefined; + footprint?: 'small' | 'medium' | 'large' | undefined; + features?: string[] | undefined; + } + >, + 'many' + > + >; + readonly configScope: ConfigScope; + readonly description: string; + }; readonly 'boost.kagenti.auth.tokenExchange.enabled': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts index 5c3a85ca60..924e33c939 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts @@ -163,6 +163,47 @@ export const boostConfigFields = { 'URL of the external skills catalog backend service. ' + 'Boost proxies browse/filter requests to this endpoint.', }, + 'boost.skillsMarketplace.runtimes': { + schema: z + .array( + z.object({ + id: z.string().min(1).describe('Unique runtime identifier'), + name: z.string().min(1).describe('Human-readable runtime name'), + description: z + .string() + .optional() + .describe('Short description of the runtime'), + image: z + .string() + .min(1) + .describe('OCI container image for the runtime'), + language: z + .string() + .optional() + .describe('Primary programming language'), + footprint: z + .enum(['small', 'medium', 'large']) + .optional() + .describe('Resource footprint category'), + features: z + .array(z.string()) + .optional() + .describe('List of supported features'), + status: z + .enum(['active', 'deprecated', 'experimental']) + .optional() + .describe('Runtime availability status'), + }), + ) + .optional() + .describe('Supported skill execution runtimes'), + configScope: 'yaml-only' as ConfigScope, + description: + 'List of supported agent execution runtimes. Each entry defines ' + + 'a runtime framework (e.g. DocsClaw, ZeroClaw) with its container ' + + 'image and metadata. The backend resolves runtimeId to an image ' + + 'server-side so frontends never see registry URLs.', + }, // -- Kagenti auth / token exchange -- 'boost.kagenti.auth.tokenExchange.enabled': { schema: z.boolean().optional().describe('Enable RFC 8693 token exchange'), diff --git a/workspaces/boost/plugins/boost-backend/src/skills/index.ts b/workspaces/boost/plugins/boost-backend/src/skills/index.ts index adbcfacbf6..2b62d425d1 100644 --- a/workspaces/boost/plugins/boost-backend/src/skills/index.ts +++ b/workspaces/boost/plugins/boost-backend/src/skills/index.ts @@ -15,3 +15,9 @@ */ export { createSkillsRoutes, type SkillsRoutesOptions } from './routes'; +export { + buildDeploymentManifest, + validateRfc1123Label, + type ManifestParams, + type DeploymentResources, +} from './manifestBuilder'; diff --git a/workspaces/boost/plugins/boost-backend/src/skills/manifestBuilder.test.ts b/workspaces/boost/plugins/boost-backend/src/skills/manifestBuilder.test.ts new file mode 100644 index 0000000000..cfa8158a14 --- /dev/null +++ b/workspaces/boost/plugins/boost-backend/src/skills/manifestBuilder.test.ts @@ -0,0 +1,265 @@ +/* + * 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 { + buildDeploymentManifest, + validateRfc1123Label, +} from './manifestBuilder'; + +describe('manifestBuilder', () => { + describe('buildDeploymentManifest', () => { + const baseParams = { + deploymentId: 'skill-test-12345', + skillId: 'test-skill', + image: 'registry.example.com/runtime:latest', + namespace: 'boost-skills', + name: 'skill-test-skill', + }; + + it('generates a valid K8s Deployment manifest', () => { + const manifest = buildDeploymentManifest(baseParams) as Record< + string, + unknown + >; + + expect(manifest.apiVersion).toBe('apps/v1'); + expect(manifest.kind).toBe('Deployment'); + }); + + it('sets correct metadata', () => { + const manifest = buildDeploymentManifest(baseParams) as { + metadata: { + name: string; + namespace: string; + labels: Record; + }; + }; + + expect(manifest.metadata.name).toBe('skill-test-skill'); + expect(manifest.metadata.namespace).toBe('boost-skills'); + expect(manifest.metadata.labels['app.kubernetes.io/name']).toBe( + 'skill-test-skill', + ); + expect(manifest.metadata.labels['app.kubernetes.io/managed-by']).toBe( + 'boost', + ); + expect(manifest.metadata.labels['boost.redhat.com/skill-id']).toBe( + 'test-skill', + ); + expect(manifest.metadata.labels['boost.redhat.com/deployment-id']).toBe( + 'skill-test-12345', + ); + }); + + it('includes OCI init container', () => { + const manifest = buildDeploymentManifest(baseParams) as { + spec: { + template: { + spec: { + initContainers: Array<{ + name: string; + image: string; + command: string[]; + }>; + }; + }; + }; + }; + + const initContainers = manifest.spec.template.spec.initContainers; + expect(initContainers).toHaveLength(1); + expect(initContainers[0].name).toBe('oci-init'); + expect(initContainers[0].image).toBe( + 'registry.example.com/runtime:latest', + ); + expect(initContainers[0].command).toEqual([ + 'cp', + '-r', + '/skill/.', + '/shared/skill', + ]); + }); + + it('includes skill-agent container with correct image', () => { + const manifest = buildDeploymentManifest(baseParams) as { + spec: { + template: { + spec: { + containers: Array<{ + name: string; + image: string; + env: Array<{ name: string; value: string }>; + }>; + }; + }; + }; + }; + + const containers = manifest.spec.template.spec.containers; + expect(containers).toHaveLength(1); + expect(containers[0].name).toBe('skill-agent'); + expect(containers[0].image).toBe('registry.example.com/runtime:latest'); + expect(containers[0].env).toContainEqual({ + name: 'SKILL_ID', + value: 'test-skill', + }); + }); + + it('uses default resource values when not specified', () => { + const manifest = buildDeploymentManifest(baseParams) as { + spec: { + template: { + spec: { + containers: Array<{ + resources: { + requests: { cpu: string; memory: string }; + limits: { cpu: string; memory: string }; + }; + }>; + }; + }; + }; + }; + + const resources = manifest.spec.template.spec.containers[0].resources; + expect(resources.requests.cpu).toBe('100m'); + expect(resources.requests.memory).toBe('256Mi'); + expect(resources.limits.cpu).toBe('500m'); + expect(resources.limits.memory).toBe('512Mi'); + }); + + it('accepts separate requests and limits', () => { + const manifest = buildDeploymentManifest({ + ...baseParams, + resources: { + requests: { cpu: '200m', memory: '512Mi' }, + limits: { cpu: '1', memory: '1Gi' }, + }, + }) as { + spec: { + template: { + spec: { + containers: Array<{ + resources: { + requests: { cpu: string; memory: string }; + limits: { cpu: string; memory: string }; + }; + }>; + }; + }; + }; + }; + + const resources = manifest.spec.template.spec.containers[0].resources; + expect(resources.requests.cpu).toBe('200m'); + expect(resources.requests.memory).toBe('512Mi'); + expect(resources.limits.cpu).toBe('1'); + expect(resources.limits.memory).toBe('1Gi'); + }); + + it('includes shared volume between init and main containers', () => { + const manifest = buildDeploymentManifest(baseParams) as { + spec: { + template: { + spec: { + volumes: Array<{ name: string; emptyDir: object }>; + initContainers: Array<{ + volumeMounts: Array<{ name: string; mountPath: string }>; + }>; + containers: Array<{ + volumeMounts: Array<{ + name: string; + mountPath: string; + readOnly: boolean; + }>; + }>; + }; + }; + }; + }; + + const spec = manifest.spec.template.spec; + expect(spec.volumes).toContainEqual({ + name: 'shared-skill', + emptyDir: {}, + }); + expect(spec.initContainers[0].volumeMounts[0].name).toBe('shared-skill'); + expect(spec.containers[0].volumeMounts[0].name).toBe('shared-skill'); + expect(spec.containers[0].volumeMounts[0].readOnly).toBe(true); + }); + + it('sets selector matchLabels', () => { + const manifest = buildDeploymentManifest(baseParams) as { + spec: { + selector: { matchLabels: Record }; + }; + }; + + expect(manifest.spec.selector.matchLabels['app.kubernetes.io/name']).toBe( + 'skill-test-skill', + ); + }); + }); + + describe('validateRfc1123Label', () => { + it('accepts valid labels', () => { + expect(() => validateRfc1123Label('my-skill', 'test')).not.toThrow(); + expect(() => validateRfc1123Label('abc123', 'test')).not.toThrow(); + expect(() => validateRfc1123Label('a', 'test')).not.toThrow(); + expect(() => validateRfc1123Label('a-b-c-d', 'test')).not.toThrow(); + }); + + it('rejects labels starting with hyphen', () => { + expect(() => validateRfc1123Label('-invalid', 'test')).toThrow( + 'RFC 1123', + ); + }); + + it('rejects labels ending with hyphen', () => { + expect(() => validateRfc1123Label('invalid-', 'test')).toThrow( + 'RFC 1123', + ); + }); + + it('rejects labels with uppercase', () => { + expect(() => validateRfc1123Label('Invalid', 'test')).toThrow('RFC 1123'); + }); + + it('rejects labels with underscores', () => { + expect(() => validateRfc1123Label('my_skill', 'test')).toThrow( + 'RFC 1123', + ); + }); + + it('rejects empty string', () => { + expect(() => validateRfc1123Label('', 'test')).toThrow('RFC 1123'); + }); + + it('rejects labels longer than 63 characters', () => { + const long = 'a'.repeat(64); + expect(() => validateRfc1123Label(long, 'test')).toThrow('RFC 1123'); + }); + + it('accepts label of exactly 63 characters', () => { + const exact = 'a'.repeat(63); + expect(() => validateRfc1123Label(exact, 'test')).not.toThrow(); + }); + + it('includes field name in error message', () => { + expect(() => validateRfc1123Label('BAD', 'skillId')).toThrow('skillId'); + }); + }); +}); diff --git a/workspaces/boost/plugins/boost-backend/src/skills/manifestBuilder.ts b/workspaces/boost/plugins/boost-backend/src/skills/manifestBuilder.ts new file mode 100644 index 0000000000..8ac72014ea --- /dev/null +++ b/workspaces/boost/plugins/boost-backend/src/skills/manifestBuilder.ts @@ -0,0 +1,174 @@ +/* + * 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. + */ + +/** + * Resource requirements for a skill deployment container. + * + * @public + */ +export interface DeploymentResources { + requests?: { + cpu?: string; + memory?: string; + }; + limits?: { + cpu?: string; + memory?: string; + }; +} + +/** + * Parameters for building a K8s Deployment manifest. + * + * @public + */ +export interface ManifestParams { + /** Unique deployment identifier. */ + deploymentId: string; + /** The skill identifier. */ + skillId: string; + /** OCI container image for the skill runtime. */ + image: string; + /** K8s namespace for the deployment. */ + namespace: string; + /** K8s deployment name. */ + name: string; + /** Resource requests and limits. */ + resources?: DeploymentResources; +} + +/** + * K8s RFC 1123 label validation regex. + * Must be lowercase alphanumeric or '-', start and end with alphanumeric, + * and be at most 63 characters. + */ +const RFC_1123_LABEL_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/; + +/** + * Validates a string against K8s RFC 1123 DNS label rules. + * + * @param value - The string to validate. + * @param fieldName - Field name for error messages. + * @throws Error if the value does not conform to RFC 1123. + * + * @public + */ +export function validateRfc1123Label(value: string, fieldName: string): void { + if (!RFC_1123_LABEL_RE.test(value)) { + throw new Error( + `${fieldName} must conform to RFC 1123: lowercase alphanumeric or '-', ` + + `start and end with alphanumeric, max 63 characters. Got: "${value}"`, + ); + } +} + +/** + * Builds a Kubernetes apps/v1 Deployment manifest for a skill agent. + * + * The manifest includes an OCI init container that copies the skill + * artifact into a shared volume, and a main container that runs the + * skill agent. + * + * @param params - Manifest generation parameters. + * @returns A Kubernetes Deployment manifest object. + * + * @public + */ +export function buildDeploymentManifest(params: ManifestParams): object { + const { deploymentId, skillId, image, namespace, name, resources } = params; + + return { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { + name, + namespace, + labels: { + 'app.kubernetes.io/name': name, + 'app.kubernetes.io/managed-by': 'boost', + 'boost.redhat.com/skill-id': skillId, + 'boost.redhat.com/deployment-id': deploymentId, + }, + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + 'app.kubernetes.io/name': name, + }, + }, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': name, + 'boost.redhat.com/skill-id': skillId, + }, + }, + spec: { + initContainers: [ + { + name: 'oci-init', + image, + command: ['cp', '-r', '/skill/.', '/shared/skill'], + volumeMounts: [ + { + name: 'shared-skill', + mountPath: '/shared/skill', + }, + ], + }, + ], + containers: [ + { + name: 'skill-agent', + image, + ports: [{ containerPort: 8080, name: 'http' }], + resources: { + requests: { + cpu: resources?.requests?.cpu || '100m', + memory: resources?.requests?.memory || '256Mi', + }, + limits: { + cpu: resources?.limits?.cpu || '500m', + memory: resources?.limits?.memory || '512Mi', + }, + }, + env: [ + { + name: 'SKILL_ID', + value: skillId, + }, + ], + volumeMounts: [ + { + name: 'shared-skill', + mountPath: '/shared/skill', + readOnly: true, + }, + ], + }, + ], + volumes: [ + { + name: 'shared-skill', + emptyDir: {}, + }, + ], + }, + }, + }, + }; +} diff --git a/workspaces/boost/plugins/boost-backend/src/skills/routes.test.ts b/workspaces/boost/plugins/boost-backend/src/skills/routes.test.ts index 2e0057a97f..f446a9a79a 100644 --- a/workspaces/boost/plugins/boost-backend/src/skills/routes.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/skills/routes.test.ts @@ -25,6 +25,20 @@ import type { } from '@backstage/backend-plugin-api'; import { createSkillsRoutes } from './routes'; +// --------------------------------------------------------------------------- +// Mock global fetch for proxy tests +// --------------------------------------------------------------------------- + +const mockFetch = jest.fn(); + +beforeAll(() => { + (global as Record).fetch = mockFetch; +}); + +afterEach(() => { + mockFetch.mockReset(); +}); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -58,16 +72,30 @@ function createMockHttpAuth(): HttpAuthService { }; } -function createMockConfig(overrides?: { +interface MockConfigOverrides { skillsEnabled?: boolean; skillsEndpoint?: string; -}): RootConfigService { + runtimes?: Array<{ + id: string; + name: string; + description?: string; + image: string; + language?: string; + footprint?: string; + features?: string[]; + status?: string; + }>; +} + +function createMockConfig(overrides?: MockConfigOverrides): RootConfigService { const values: Record = { 'boost.features.skillsMarketplace': overrides?.skillsEnabled ?? true, 'boost.skillsMarketplace.endpoint': overrides?.skillsEndpoint ?? 'http://skills.example.com', }; + const runtimes = overrides?.runtimes ?? []; + return { getOptionalString: jest.fn( (key: string) => values[key] as string | undefined, @@ -82,6 +110,30 @@ function createMockConfig(overrides?: { } return v as string; }), + getOptionalConfigArray: jest.fn((key: string) => { + if (key === 'boost.skillsMarketplace.runtimes') { + if (runtimes.length === 0) { + return undefined; + } + return runtimes.map(r => ({ + getString: jest.fn((field: string) => { + const val = r[field as keyof typeof r]; + if (val === undefined) { + throw new Error(`Missing required config: ${field}`); + } + return val as string; + }), + getOptionalString: jest.fn( + (field: string) => r[field as keyof typeof r] as string | undefined, + ), + getOptionalStringArray: jest.fn( + (field: string) => + r[field as keyof typeof r] as string[] | undefined, + ), + })); + } + return undefined; + }), // Minimal stubs for the rest of the config interface has: jest.fn(() => false), keys: jest.fn(() => []), @@ -90,7 +142,6 @@ function createMockConfig(overrides?: { getConfig: jest.fn(), getOptionalConfig: jest.fn(), getConfigArray: jest.fn(), - getOptionalConfigArray: jest.fn(), getNumber: jest.fn(), getOptionalNumber: jest.fn(), getBoolean: jest.fn(), @@ -202,6 +253,22 @@ describe('skills marketplace routes', () => { const res = await fetchJson(testApp.url, '/skills'); expect(res.status).toBe(404); }); + + it('returns 404 for runtimes when skills marketplace is disabled', async () => { + const config = createMockConfig({ skillsEnabled: false }); + testApp = await createTestApp({ config }); + + const res = await fetchJson(testApp.url, '/skills/runtimes'); + expect(res.status).toBe(404); + }); + + it('returns 404 for domains when skills marketplace is disabled', async () => { + const config = createMockConfig({ skillsEnabled: false }); + testApp = await createTestApp({ config }); + + const res = await fetchJson(testApp.url, '/skills/domains'); + expect(res.status).toBe(404); + }); }); describe('permission checks', () => { @@ -212,17 +279,287 @@ describe('skills marketplace routes', () => { const res = await fetchJson(testApp.url, '/skills'); expect(res.status).toBe(403); }); + + it('allows access with admin permission when access denied', async () => { + const permissions: PermissionsService = { + authorize: jest + .fn() + .mockResolvedValueOnce([{ result: AuthorizeResult.DENY }]) + .mockResolvedValueOnce([{ result: AuthorizeResult.ALLOW }]), + authorizeConditional: jest.fn(), + }; + + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ skills: [] }), + }); + + testApp = await createTestApp({ permissions }); + + const res = await fetchJson(testApp.url, '/skills'); + expect(res.status).toBe(200); + }); + + it('returns 403 when both access and admin denied', async () => { + const permissions: PermissionsService = { + authorize: jest + .fn() + .mockResolvedValueOnce([{ result: AuthorizeResult.DENY }]) + .mockResolvedValueOnce([{ result: AuthorizeResult.DENY }]), + authorizeConditional: jest.fn(), + }; + + testApp = await createTestApp({ permissions }); + + const res = await fetchJson(testApp.url, '/skills'); + expect(res.status).toBe(403); + }); + }); + + // ------------------------------------------------------------------------- + // 8a.3: Proxy tests for GET /skills and GET /skills/domains + // ------------------------------------------------------------------------- + + describe('GET /skills (proxy)', () => { + it('constructs URL from configured endpoint and proxies response', async () => { + const config = createMockConfig({ + skillsEndpoint: 'http://catalog.example.com/api', + }); + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ skills: [{ id: 's1' }] }), + }); + + testApp = await createTestApp({ config }); + const res = await fetchJson(testApp.url, '/skills'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ skills: [{ id: 's1' }] }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toBe('http://catalog.example.com/api/skills'); + }); + + it('forwards query parameters to upstream', async () => { + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ skills: [] }), + }); + + testApp = await createTestApp(); + await fetchJson(testApp.url, '/skills?domain=ai&language=python'); + + const calledUrl = mockFetch.mock.calls[0][0]; + const parsed = new URL(calledUrl); + expect(parsed.searchParams.get('domain')).toBe('ai'); + expect(parsed.searchParams.get('language')).toBe('python'); + }); + + it('returns 404 when endpoint is not configured', async () => { + const config = createMockConfig({}); + // Override to remove the endpoint + (config.getOptionalString as jest.Mock).mockImplementation( + (key: string) => { + if (key === 'boost.skillsMarketplace.endpoint') return undefined; + return undefined; + }, + ); + + testApp = await createTestApp({ config }); + const res = await fetchJson(testApp.url, '/skills'); + + expect(res.status).toBe(404); + expect((res.body as { error: string }).error).toContain( + 'endpoint is not configured', + ); + }); + + it('handles non-JSON upstream response', async () => { + mockFetch.mockResolvedValue({ + status: 502, + json: async () => { + throw new Error('not json'); + }, + }); + + testApp = await createTestApp(); + const res = await fetchJson(testApp.url, '/skills'); + + expect(res.status).toBe(502); + expect((res.body as { error: string }).error).toContain( + 'non-JSON response', + ); + }); + + it('passes AbortSignal.timeout to fetch', async () => { + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ skills: [] }), + }); + + testApp = await createTestApp(); + await fetchJson(testApp.url, '/skills'); + + const fetchOptions = mockFetch.mock.calls[0][1]; + expect(fetchOptions).toHaveProperty('signal'); + }); + }); + + describe('GET /skills/domains (proxy)', () => { + it('constructs correct URL and proxies response', async () => { + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ domains: ['ai', 'devops'] }), + }); + + testApp = await createTestApp(); + const res = await fetchJson(testApp.url, '/skills/domains'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ domains: ['ai', 'devops'] }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain('/skills/domains'); + }); + + it('forwards query parameters to upstream', async () => { + mockFetch.mockResolvedValue({ + status: 200, + json: async () => ({ domains: [] }), + }); + + testApp = await createTestApp(); + await fetchJson(testApp.url, '/skills/domains?limit=10'); + + const calledUrl = mockFetch.mock.calls[0][0]; + const parsed = new URL(calledUrl); + expect(parsed.searchParams.get('limit')).toBe('10'); + }); + + it('returns 403 when access denied', async () => { + const permissions: PermissionsService = { + authorize: jest + .fn() + .mockResolvedValueOnce([{ result: AuthorizeResult.DENY }]) + .mockResolvedValueOnce([{ result: AuthorizeResult.DENY }]), + authorizeConditional: jest.fn(), + }; + + testApp = await createTestApp({ permissions }); + const res = await fetchJson(testApp.url, '/skills/domains'); + + expect(res.status).toBe(403); + }); + }); + + // ------------------------------------------------------------------------- + // 8b.3: Tests for GET /skills/runtimes + // ------------------------------------------------------------------------- + + describe('GET /skills/runtimes', () => { + it('returns runtime list from config', async () => { + const config = createMockConfig({ + runtimes: [ + { + id: 'docsclaw', + name: 'DocsClaw', + description: 'Document processing runtime', + image: 'registry.example.com/docsclaw:latest', + language: 'python', + footprint: 'medium', + features: ['rag', 'summarization'], + status: 'active', + }, + { + id: 'zeroclaw', + name: 'ZeroClaw', + description: 'Zero-shot agent runtime', + image: 'registry.example.com/zeroclaw:latest', + language: 'python', + footprint: 'large', + features: ['tool-use'], + status: 'experimental', + }, + ], + }); + + testApp = await createTestApp({ config }); + const res = await fetchJson(testApp.url, '/skills/runtimes'); + + expect(res.status).toBe(200); + const data = res.body as { runtimes: Array<{ id: string }> }; + expect(data.runtimes).toHaveLength(2); + expect(data.runtimes[0].id).toBe('docsclaw'); + expect(data.runtimes[1].id).toBe('zeroclaw'); + }); + + it('returns empty list when no runtimes configured', async () => { + const config = createMockConfig({ runtimes: [] }); + + testApp = await createTestApp({ config }); + const res = await fetchJson(testApp.url, '/skills/runtimes'); + + expect(res.status).toBe(200); + const data = res.body as { runtimes: unknown[] }; + expect(data.runtimes).toEqual([]); + }); + + it('returns 404 when skills marketplace is disabled', async () => { + const config = createMockConfig({ skillsEnabled: false }); + + testApp = await createTestApp({ config }); + const res = await fetchJson(testApp.url, '/skills/runtimes'); + + expect(res.status).toBe(404); + }); + + it('does not proxy to external catalog', async () => { + const config = createMockConfig({ + runtimes: [ + { + id: 'test', + name: 'Test', + image: 'registry.example.com/test:latest', + }, + ], + }); + + testApp = await createTestApp({ config }); + await fetchJson(testApp.url, '/skills/runtimes'); + + // fetch should not have been called — runtimes are local + expect(mockFetch).not.toHaveBeenCalled(); + }); }); + // ------------------------------------------------------------------------- + // 8c.3: Deploy tests with runtimeId resolution + // ------------------------------------------------------------------------- + describe('POST /skills/deploy', () => { + const testRuntimes = [ + { + id: 'docsclaw', + name: 'DocsClaw', + image: 'registry.example.com/docsclaw:latest', + language: 'python', + }, + { + id: 'zeroclaw', + name: 'ZeroClaw', + image: 'registry.example.com/zeroclaw:v2', + }, + ]; + it('returns 201 with manifest when valid request', async () => { - testApp = await createTestApp({}); + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); const res = await fetchJson(testApp.url, '/skills/deploy', { method: 'POST', body: { skillId: 'test-skill', - ociImage: 'registry.example.com/skill:latest', + runtimeId: 'docsclaw', namespace: 'test-ns', }, }); @@ -233,7 +570,7 @@ describe('skills marketplace routes', () => { skillId: string; namespace: string; status: string; - manifest: { kind: string }; + manifest: { kind: string; metadata: { name: string } }; }; expect(data.skillId).toBe('test-skill'); expect(data.namespace).toBe('test-ns'); @@ -242,19 +579,55 @@ describe('skills marketplace routes', () => { expect(data.status).toBe('pending'); }); + it('resolves container image from runtimeId', async () => { + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); + + const res = await fetchJson(testApp.url, '/skills/deploy', { + method: 'POST', + body: { + skillId: 'test-skill', + runtimeId: 'docsclaw', + }, + }); + + expect(res.status).toBe(201); + const data = res.body as { + manifest: { + spec: { + template: { + spec: { + initContainers: Array<{ image: string }>; + containers: Array<{ image: string }>; + }; + }; + }; + }; + }; + const spec = data.manifest.spec.template.spec; + expect(spec.initContainers[0].image).toBe( + 'registry.example.com/docsclaw:latest', + ); + expect(spec.containers[0].image).toBe( + 'registry.example.com/docsclaw:latest', + ); + }); + it('returns 400 when skillId missing', async () => { - testApp = await createTestApp({}); + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); const res = await fetchJson(testApp.url, '/skills/deploy', { method: 'POST', - body: { ociImage: 'registry.example.com/skill:latest' }, + body: { runtimeId: 'docsclaw' }, }); expect(res.status).toBe(400); }); - it('returns 400 when ociImage missing', async () => { - testApp = await createTestApp({}); + it('returns 400 when runtimeId missing', async () => { + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); const res = await fetchJson(testApp.url, '/skills/deploy', { method: 'POST', @@ -264,14 +637,49 @@ describe('skills marketplace routes', () => { expect(res.status).toBe(400); }); + it('returns 400 when runtimeId is unknown', async () => { + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); + + const res = await fetchJson(testApp.url, '/skills/deploy', { + method: 'POST', + body: { + skillId: 'test-skill', + runtimeId: 'nonexistent', + }, + }); + + expect(res.status).toBe(400); + expect((res.body as { error: string }).error).toContain( + 'Unknown runtimeId', + ); + }); + + it('returns 400 when skillId violates RFC 1123', async () => { + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); + + const res = await fetchJson(testApp.url, '/skills/deploy', { + method: 'POST', + body: { + skillId: 'Invalid_Skill!', + runtimeId: 'docsclaw', + }, + }); + + expect(res.status).toBe(500); + expect((res.body as { error: string }).error).toContain('RFC 1123'); + }); + it('uses default namespace when not provided', async () => { - testApp = await createTestApp({}); + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); const res = await fetchJson(testApp.url, '/skills/deploy', { method: 'POST', body: { skillId: 'test-skill', - ociImage: 'registry.example.com/skill:latest', + runtimeId: 'docsclaw', }, }); @@ -281,13 +689,14 @@ describe('skills marketplace routes', () => { }); it('includes chatEndpoint when provided', async () => { - testApp = await createTestApp({}); + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); const res = await fetchJson(testApp.url, '/skills/deploy', { method: 'POST', body: { skillId: 'test-skill', - ociImage: 'registry.example.com/skill:latest', + runtimeId: 'docsclaw', chatEndpoint: 'http://skill:8080/chat', }, }); @@ -297,15 +706,56 @@ describe('skills marketplace routes', () => { expect(data.chatEndpoint).toBe('http://skill:8080/chat'); }); + it('accepts separate resources.requests and resources.limits', async () => { + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ config }); + + const res = await fetchJson(testApp.url, '/skills/deploy', { + method: 'POST', + body: { + skillId: 'test-skill', + runtimeId: 'docsclaw', + resources: { + requests: { cpu: '200m', memory: '512Mi' }, + limits: { cpu: '1', memory: '1Gi' }, + }, + }, + }); + + expect(res.status).toBe(201); + const data = res.body as { + manifest: { + spec: { + template: { + spec: { + containers: Array<{ + resources: { + requests: { cpu: string; memory: string }; + limits: { cpu: string; memory: string }; + }; + }>; + }; + }; + }; + }; + }; + const container = data.manifest.spec.template.spec.containers[0]; + expect(container.resources.requests.cpu).toBe('200m'); + expect(container.resources.requests.memory).toBe('512Mi'); + expect(container.resources.limits.cpu).toBe('1'); + expect(container.resources.limits.memory).toBe('1Gi'); + }); + it('returns 403 when admin permission denied', async () => { const permissions = createMockPermissions(AuthorizeResult.DENY); - testApp = await createTestApp({ permissions }); + const config = createMockConfig({ runtimes: testRuntimes }); + testApp = await createTestApp({ permissions, config }); const res = await fetchJson(testApp.url, '/skills/deploy', { method: 'POST', body: { skillId: 'test-skill', - ociImage: 'registry.example.com/skill:latest', + runtimeId: 'docsclaw', }, }); diff --git a/workspaces/boost/plugins/boost-backend/src/skills/routes.ts b/workspaces/boost/plugins/boost-backend/src/skills/routes.ts index 8888a4898f..4d09bea3e9 100644 --- a/workspaces/boost/plugins/boost-backend/src/skills/routes.ts +++ b/workspaces/boost/plugins/boost-backend/src/skills/routes.ts @@ -27,6 +27,14 @@ import { boostAdminPermission, } from '@red-hat-developer-hub/backstage-plugin-boost-common'; import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + buildDeploymentManifest, + validateRfc1123Label, + type DeploymentResources, +} from './manifestBuilder'; + +/** Default timeout for upstream fetch calls (10 seconds). */ +const PROXY_TIMEOUT_MS = 10_000; /** * Options for creating skills marketplace routes. @@ -44,6 +52,22 @@ export interface SkillsRoutesOptions { config: RootConfigService; } +/** + * A runtime entry from local app-config. + * + * @public + */ +export interface SkillRuntime { + id: string; + name: string; + description?: string; + image: string; + language?: string; + footprint?: string; + features?: string[]; + status?: string; +} + /** * Creates an Express router with skills marketplace proxy routes. * @@ -54,7 +78,7 @@ export interface SkillsRoutesOptions { * * Routes: * - GET /skills — list available skills (proxied) - * - GET /skills/runtimes — list skill runtimes (proxied) + * - GET /skills/runtimes — list skill runtimes (from local config) * - GET /skills/domains — list skill domains (proxied) * - POST /skills/deploy — generate K8s manifest and deploy a skill * - GET /skills/deployments/:id — poll deployment progress @@ -83,7 +107,32 @@ export function createSkillsRoutes(options: SkillsRoutesOptions): Router { } /** - * Middleware to require boost access permission. + * Read runtimes from local app-config. + */ + function getRuntimes(): SkillRuntime[] { + const runtimeConfigs = config.getOptionalConfigArray( + 'boost.skillsMarketplace.runtimes', + ); + if (!runtimeConfigs) { + return []; + } + return runtimeConfigs.map(c => ({ + id: c.getString('id'), + name: c.getString('name'), + description: c.getOptionalString('description'), + image: c.getString('image'), + language: c.getOptionalString('language'), + footprint: c.getOptionalString('footprint'), + features: c.getOptionalStringArray('features'), + status: c.getOptionalString('status'), + })); + } + + /** + * Middleware to require boost access permission with admin fallback. + * + * Checks `boostAccessPermission` first; if denied, falls back to + * `boostAdminPermission` so admins always have access. */ async function requireAccess( req: import('express').Request, @@ -96,10 +145,22 @@ export function createSkillsRoutes(options: SkillsRoutesOptions): Router { [{ permission: boostAccessPermission }], { credentials }, ); - if (decision.result !== AuthorizeResult.ALLOW) { - throw new NotAllowedError('Unauthorized'); + + if (decision.result === AuthorizeResult.ALLOW) { + return next(); } - return next(); + + // Fall back to coarse-grained admin permission + const [adminDecision] = await permissions.authorize( + [{ permission: boostAdminPermission }], + { credentials }, + ); + + if (adminDecision.result === AuthorizeResult.ALLOW) { + return next(); + } + + throw new NotAllowedError('Unauthorized'); } catch (error) { return next(error); } @@ -176,6 +237,7 @@ export function createSkillsRoutes(options: SkillsRoutesOptions): Router { const response = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(PROXY_TIMEOUT_MS), }); let body: unknown; @@ -207,18 +269,15 @@ export function createSkillsRoutes(options: SkillsRoutesOptions): Router { }, ); - // 5.1: GET /skills/runtimes — list skill runtimes + // 8b.2: GET /skills/runtimes — list skill runtimes from local config router.get( '/skills/runtimes', requireSkillsEnabled, requireAccess, - async (req, res, next) => { + async (_req, res, next) => { try { - const result = await proxyToSkillsCatalog( - '/skills/runtimes', - req.query as Record, - ); - res.status(result.status).json(result.body); + const runtimes = getRuntimes(); + res.json({ runtimes }); } catch (error) { next(error); } @@ -243,117 +302,58 @@ export function createSkillsRoutes(options: SkillsRoutesOptions): Router { }, ); - // 5.3: POST /skills/deploy — generate K8s manifest with OCI init - // containers and deploy a skill agent + // 8c.1: POST /skills/deploy — generate K8s manifest with runtime + // resolution and deploy a skill agent router.post( '/skills/deploy', requireSkillsEnabled, requireAdmin, async (req, res, next) => { try { - const { skillId, namespace, name, ociImage, chatEndpoint, resources } = + const { skillId, namespace, name, runtimeId, chatEndpoint, resources } = req.body as { skillId: string; namespace?: string; name?: string; - ociImage: string; + runtimeId: string; chatEndpoint?: string; - resources?: { - cpu?: string; - memory?: string; - }; + resources?: DeploymentResources; }; - if (!skillId || !ociImage) { - throw new InputError('skillId and ociImage are required'); + if (!skillId || !runtimeId) { + throw new InputError('skillId and runtimeId are required'); } - const deploymentId = `skill-${skillId}-${Date.now()}`; + // Validate skillId against K8s RFC 1123 naming rules + validateRfc1123Label(skillId, 'skillId'); + const deploymentName = name || `skill-${skillId}`; + + // Validate deployment name against K8s RFC 1123 naming rules + validateRfc1123Label(deploymentName, 'name'); + + // Resolve container image from configured runtimes + const runtimes = getRuntimes(); + const runtime = runtimes.find(r => r.id === runtimeId); + if (!runtime) { + throw new InputError( + `Unknown runtimeId "${runtimeId}". ` + + `Available runtimes: ${runtimes.map(r => r.id).join(', ') || 'none configured'}`, + ); + } + + const deploymentId = `skill-${skillId}-${Date.now()}`; const deploymentNamespace = namespace || 'boost-skills'; - // Generate K8s manifest with OCI init container - const manifest = { - apiVersion: 'apps/v1', - kind: 'Deployment', - metadata: { - name: deploymentName, - namespace: deploymentNamespace, - labels: { - 'app.kubernetes.io/name': deploymentName, - 'app.kubernetes.io/managed-by': 'boost', - 'boost.redhat.com/skill-id': skillId, - 'boost.redhat.com/deployment-id': deploymentId, - }, - }, - spec: { - replicas: 1, - selector: { - matchLabels: { - 'app.kubernetes.io/name': deploymentName, - }, - }, - template: { - metadata: { - labels: { - 'app.kubernetes.io/name': deploymentName, - 'boost.redhat.com/skill-id': skillId, - }, - }, - spec: { - initContainers: [ - { - name: 'oci-init', - image: ociImage, - command: ['cp', '-r', '/skill/.', '/shared/skill'], - volumeMounts: [ - { - name: 'shared-skill', - mountPath: '/shared/skill', - }, - ], - }, - ], - containers: [ - { - name: 'skill-agent', - image: ociImage, - ports: [{ containerPort: 8080, name: 'http' }], - resources: { - requests: { - cpu: resources?.cpu || '100m', - memory: resources?.memory || '256Mi', - }, - limits: { - cpu: resources?.cpu || '500m', - memory: resources?.memory || '512Mi', - }, - }, - env: [ - { - name: 'SKILL_ID', - value: skillId, - }, - ], - volumeMounts: [ - { - name: 'shared-skill', - mountPath: '/shared/skill', - readOnly: true, - }, - ], - }, - ], - volumes: [ - { - name: 'shared-skill', - emptyDir: {}, - }, - ], - }, - }, - }, - }; + // Generate K8s manifest via manifestBuilder + const manifest = buildDeploymentManifest({ + deploymentId, + skillId, + image: runtime.image, + namespace: deploymentNamespace, + name: deploymentName, + resources, + }); logger.info( `Generated K8s manifest for skill deployment ${deploymentId}`,