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
45 changes: 45 additions & 0 deletions workspaces/boost/plugins/boost-backend/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<z.ZodString>;
image: z.ZodString;
language: z.ZodOptional<z.ZodString>;
footprint: z.ZodOptional<z.ZodEnum<['small', 'medium', 'large']>>;
features: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
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<z.ZodBoolean>;
readonly configScope: ConfigScope;
Expand Down
41 changes: 41 additions & 0 deletions workspaces/boost/plugins/boost-backend/src/config/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
6 changes: 6 additions & 0 deletions workspaces/boost/plugins/boost-backend/src/skills/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,9 @@
*/

export { createSkillsRoutes, type SkillsRoutesOptions } from './routes';
export {
buildDeploymentManifest,
validateRfc1123Label,
type ManifestParams,
type DeploymentResources,
} from './manifestBuilder';
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};
};

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<string, string> };
};
};

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');
});
});
});
Loading
Loading