feat(pulumi): @mcpctl/pulumi — generic Pulumi provider for mcpctl
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 2m46s
CI/CD / build (pull_request) Successful in 2m25s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / typecheck (pull_request) Successful in 2m5s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 2m46s
CI/CD / build (pull_request) Successful in 2m25s
CI/CD / publish (pull_request) Has been skipped
New workspace package: a Pulumi dynamic provider (TypeScript, in-process) that
manages mcpctl resources declaratively from any Pulumi program. Motivated by
model drift: when the deployed vLLM/LiteLLM model changes, mcpctl's llm target
must follow, and a Pulumi resource makes that automatic on `pulumi up`.
- Generic core `McpctlResource({ kind, name, spec, mcpd })` round-trips ANY
mcpctl resource kind; typed `Llm` wrapper for ergonomics.
- Engine talks direct HTTP to mcpd's REST API (ported from cli/api-client.ts),
mirroring apply's name-keyed upsert (PUT strips immutable name/type) and
get -o json's read field-stripping. No mcpctl binary needed → CI-friendly.
- CRUD/diff/check with correct replace semantics (name/type/kind/mcpd.url force
delete-before-replace), 429 retry, secret token in state.
- Validation deferred to mcpd's Zod schemas, so new resource kinds work with no
provider release.
- CommonJS build so the serialized dynamic provider's module refs are captured.
- 18 Vitest tests (mocked mcpd + Pulumi mocks); build + lint clean; README with
auth (PAT) setup and consumer example for kubernetes-deployment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
70
src/pulumi/tests/engine.test.ts
Normal file
70
src/pulumi/tests/engine.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { upsert, findByName, del, McpdHttpError } from '../src/engine';
|
||||
import { startMockMcpd, type MockMcpd } from './mock-mcpd';
|
||||
|
||||
let mock: MockMcpd;
|
||||
const conn = () => ({ url: mock.baseUrl, token: 'test-token' });
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMockMcpd();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await mock.close();
|
||||
});
|
||||
|
||||
describe('engine.upsert', () => {
|
||||
it('creates when absent: GET lookup then POST full spec', async () => {
|
||||
const row = await upsert(conn(), 'llms', { name: 'qwen', type: 'openai', model: 'qwen3' }, ['name', 'type']);
|
||||
expect(row.id).toMatch(/^cmock/);
|
||||
const methods = mock.requests.map((r) => `${r.method} ${r.path}`);
|
||||
expect(methods).toEqual(['GET /api/v1/llms', 'POST /api/v1/llms']);
|
||||
const post = mock.requests.find((r) => r.method === 'POST')!;
|
||||
expect(post.body).toEqual({ name: 'qwen', type: 'openai', model: 'qwen3' });
|
||||
});
|
||||
|
||||
it('updates when present: PUT with immutable name+type STRIPPED', async () => {
|
||||
mock.seed('llms', { id: 'cmock9', name: 'qwen', type: 'openai', model: 'old' });
|
||||
await upsert(conn(), 'llms', { name: 'qwen', type: 'openai', model: 'new-model' }, ['name', 'type']);
|
||||
const put = mock.requests.find((r) => r.method === 'PUT')!;
|
||||
expect(put.path).toBe('/api/v1/llms/cmock9');
|
||||
// The immutability contract: name and type must NOT be in the PUT body.
|
||||
expect(put.body).toEqual({ model: 'new-model' });
|
||||
expect(mock.store['llms']!.get('cmock9')).toMatchObject({ model: 'new-model', type: 'openai' });
|
||||
});
|
||||
|
||||
it('sends Authorization: Bearer on every call', async () => {
|
||||
await upsert(conn(), 'llms', { name: 'q', type: 'openai', model: 'm' }, ['name', 'type']);
|
||||
expect(mock.requests.every((r) => r.auth === 'Bearer test-token')).toBe(true);
|
||||
});
|
||||
|
||||
it('retries on 429 then succeeds', async () => {
|
||||
mock.failNextWith(429, 1); // first request (the GET lookup) 429s once
|
||||
const row = await upsert(conn(), 'llms', { name: 'q', type: 'openai', model: 'm' }, ['name', 'type']);
|
||||
expect(row.id).toMatch(/^cmock/);
|
||||
expect(mock.requests.filter((r) => r.method === 'GET').length).toBe(2); // retried
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine.findByName / del', () => {
|
||||
it('findByName returns null when absent', async () => {
|
||||
expect(await findByName(conn(), 'llms', 'nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('del is idempotent when the row is already gone', async () => {
|
||||
await expect(del(conn(), 'llms', 'ghost')).resolves.toBeUndefined();
|
||||
expect(mock.requests.some((r) => r.method === 'DELETE')).toBe(false);
|
||||
});
|
||||
|
||||
it('del removes an existing row by resolved id', async () => {
|
||||
mock.seed('llms', { id: 'cmock3', name: 'gone', type: 'openai', model: 'm' });
|
||||
await del(conn(), 'llms', 'gone');
|
||||
expect(mock.store['llms']!.has('cmock3')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine error surface', () => {
|
||||
it('throws McpdHttpError on a 500', async () => {
|
||||
mock.failNextWith(500, 10);
|
||||
await expect(findByName(conn(), 'llms', 'x')).rejects.toBeInstanceOf(McpdHttpError);
|
||||
});
|
||||
});
|
||||
43
src/pulumi/tests/llm.test.ts
Normal file
43
src/pulumi/tests/llm.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as pulumi from '@pulumi/pulumi';
|
||||
|
||||
let captured: Record<string, any> | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
pulumi.runtime.setMocks({
|
||||
newResource: (args: pulumi.runtime.MockResourceArgs) => {
|
||||
// Dynamic resources carry a serialized __provider plus our inputs.
|
||||
captured = args.inputs;
|
||||
return { id: `${args.inputs.name}-id`, state: args.inputs };
|
||||
},
|
||||
call: () => ({}),
|
||||
});
|
||||
});
|
||||
|
||||
function promiseOf<T>(o: pulumi.Output<T>): Promise<T> {
|
||||
return new Promise((resolve) => o.apply(resolve));
|
||||
}
|
||||
|
||||
describe('Llm wrapper', () => {
|
||||
it('maps typed args onto a generic {kind:"llm", name, spec, mcpd} resource', async () => {
|
||||
// Import after setMocks so resource registration is intercepted.
|
||||
const { Llm } = await import('../src/llm');
|
||||
const llm = new Llm('homelab-qwen', {
|
||||
name: 'qwen3-thinking',
|
||||
type: 'openai',
|
||||
model: 'qwen3-thinking',
|
||||
url: 'http://litellm:4000',
|
||||
tier: 'heavy',
|
||||
mcpd: { url: 'http://mcpd', token: 'secret-pat' },
|
||||
});
|
||||
await promiseOf(llm.urn);
|
||||
|
||||
expect(captured).toBeDefined();
|
||||
expect(captured!.kind).toBe('llm');
|
||||
expect(captured!.name).toBe('qwen3-thinking');
|
||||
expect(captured!.spec).toMatchObject({ type: 'openai', model: 'qwen3-thinking', url: 'http://litellm:4000', tier: 'heavy' });
|
||||
// name is a top-level field, not part of spec.
|
||||
expect(captured!.spec).not.toHaveProperty('name');
|
||||
expect(captured!.mcpd).toMatchObject({ url: 'http://mcpd', token: 'secret-pat' });
|
||||
});
|
||||
});
|
||||
107
src/pulumi/tests/mock-mcpd.ts
Normal file
107
src/pulumi/tests/mock-mcpd.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* In-memory mcpd mock for provider/engine tests. Implements the generic REST
|
||||
* shape the provider relies on: GET list, POST create, PUT /:id, DELETE /:id.
|
||||
* Records every request for assertions.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
path: string;
|
||||
auth: string | undefined;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface MockMcpd {
|
||||
baseUrl: string;
|
||||
requests: RecordedRequest[];
|
||||
/** Seed/read the store directly: store[resource][id] = row. */
|
||||
store: Record<string, Map<string, Record<string, unknown>>>;
|
||||
seed(resource: string, row: Record<string, unknown>): void;
|
||||
/** Force the next N responses (any method) to a given status (e.g. 429). */
|
||||
failNextWith(status: number, times: number): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startMockMcpd(): Promise<MockMcpd> {
|
||||
const store: Record<string, Map<string, Record<string, unknown>>> = {};
|
||||
const requests: RecordedRequest[] = [];
|
||||
let idCounter = 0;
|
||||
let forcedStatus: number | null = null;
|
||||
let forcedTimes = 0;
|
||||
|
||||
const ensure = (resource: string) => {
|
||||
if (!store[resource]) store[resource] = new Map();
|
||||
return store[resource]!;
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c: Buffer) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
const body = raw ? JSON.parse(raw) : undefined;
|
||||
const url = new URL(req.url ?? '/', 'http://mock');
|
||||
requests.push({ method: req.method ?? '', path: url.pathname, auth: req.headers['authorization'], body });
|
||||
|
||||
if (forcedStatus !== null && forcedTimes > 0) {
|
||||
forcedTimes -= 1;
|
||||
res.statusCode = forcedStatus;
|
||||
res.end(JSON.stringify({ error: 'forced' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// /api/v1/<resource>[/<id>]
|
||||
const parts = url.pathname.split('/').filter(Boolean); // ["api","v1","<resource>", "<id>?"]
|
||||
const resource = parts[2] ?? '';
|
||||
const id = parts[3];
|
||||
const table = ensure(resource);
|
||||
|
||||
const send = (status: number, payload?: unknown) => {
|
||||
res.statusCode = status;
|
||||
res.end(payload === undefined ? '' : JSON.stringify(payload));
|
||||
};
|
||||
|
||||
if (req.method === 'GET' && !id) return send(200, [...table.values()]);
|
||||
if (req.method === 'POST' && !id) {
|
||||
idCounter += 1;
|
||||
const row = { id: `cmock${idCounter}0000000000000000000`, ...(body as Record<string, unknown>) };
|
||||
table.set(row.id, row);
|
||||
return send(201, row);
|
||||
}
|
||||
if (req.method === 'PUT' && id) {
|
||||
const existing = table.get(id);
|
||||
if (!existing) return send(404, { error: 'not found' });
|
||||
const updated = { ...existing, ...(body as Record<string, unknown>) };
|
||||
table.set(id, updated);
|
||||
return send(200, updated);
|
||||
}
|
||||
if (req.method === 'DELETE' && id) {
|
||||
if (!table.has(id)) return send(404, { error: 'not found' });
|
||||
table.delete(id);
|
||||
return send(200, { ok: true });
|
||||
}
|
||||
return send(404, { error: 'unhandled' });
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
store,
|
||||
seed(resource, row) {
|
||||
ensure(resource).set(String(row['id']), row);
|
||||
},
|
||||
failNextWith(status, times) {
|
||||
forcedStatus = status;
|
||||
forcedTimes = times;
|
||||
},
|
||||
close() {
|
||||
return new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
99
src/pulumi/tests/provider.test.ts
Normal file
99
src/pulumi/tests/provider.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mcpctlProvider } from '../src/provider';
|
||||
import { startMockMcpd, type MockMcpd } from './mock-mcpd';
|
||||
|
||||
let mock: MockMcpd;
|
||||
const mcpd = () => ({ url: mock.baseUrl, token: 'tok' });
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMockMcpd();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await mock.close();
|
||||
});
|
||||
|
||||
const llmInputs = (over: Record<string, unknown> = {}) => ({
|
||||
kind: 'llm',
|
||||
name: 'qwen3-thinking',
|
||||
spec: { type: 'openai', model: 'qwen3-thinking', url: 'http://litellm:4000', tier: 'heavy' },
|
||||
mcpd: mcpd(),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('check', () => {
|
||||
it('accepts a valid llm and applies defaults', async () => {
|
||||
const res = await mcpctlProvider.check!(undefined, llmInputs());
|
||||
expect(res.failures).toBeUndefined();
|
||||
// description/extraConfig/poolName defaults injected.
|
||||
expect(res.inputs.spec).toMatchObject({ tier: 'heavy', description: '', extraConfig: {}, poolName: null });
|
||||
});
|
||||
|
||||
it('rejects a bad name and missing mcpd', async () => {
|
||||
const res = await mcpctlProvider.check!(undefined, { kind: 'llm', name: 'Bad Name', spec: {}, mcpd: { url: '', token: '' } });
|
||||
const props = (res.failures ?? []).map((f) => f.property);
|
||||
expect(props).toContain('name');
|
||||
expect(props).toContain('mcpd');
|
||||
});
|
||||
|
||||
it('does not reject unknown kinds (forward-compatible)', async () => {
|
||||
const res = await mcpctlProvider.check!(undefined, { kind: 'somenewkind', name: 'x', spec: {}, mcpd: mcpd() });
|
||||
expect(res.failures).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('diff', () => {
|
||||
const base = { kind: 'llm', name: 'a', mcpd: { url: 'http://x' }, spec: { type: 'openai', model: 'm1' } };
|
||||
|
||||
it('model change → in-place update (changes, no replace)', async () => {
|
||||
const r = await mcpctlProvider.diff!('llm/a', base, { ...base, spec: { type: 'openai', model: 'm2' } });
|
||||
expect(r.changes).toBe(true);
|
||||
expect(r.replaces ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('name change → replace + deleteBeforeReplace', async () => {
|
||||
const r = await mcpctlProvider.diff!('llm/a', base, { ...base, name: 'b' });
|
||||
expect(r.replaces).toContain('name');
|
||||
expect(r.deleteBeforeReplace).toBe(true);
|
||||
});
|
||||
|
||||
it('type change (immutable) → replace on spec.type', async () => {
|
||||
const r = await mcpctlProvider.diff!('llm/a', base, { ...base, spec: { type: 'vllm', model: 'm1' } });
|
||||
expect(r.replaces).toContain('spec.type');
|
||||
expect(r.deleteBeforeReplace).toBe(true);
|
||||
});
|
||||
|
||||
it('no change → changes:false', async () => {
|
||||
const r = await mcpctlProvider.diff!('llm/a', base, { ...base });
|
||||
expect(r.changes).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create / read / update / delete round-trip', () => {
|
||||
it('create posts, read reflects, update puts, delete removes', async () => {
|
||||
const created = await mcpctlProvider.create!(llmInputs());
|
||||
expect(created.id).toBe('llm/qwen3-thinking');
|
||||
expect(created.outs.resourceId).toMatch(/^cmock/);
|
||||
|
||||
const read = await mcpctlProvider.read!('llm/qwen3-thinking', { ...llmInputs(), ...created.outs });
|
||||
expect(read.props?.spec).toMatchObject({ model: 'qwen3-thinking', type: 'openai' });
|
||||
expect(read.props?.spec).not.toHaveProperty('name'); // name lives at top level
|
||||
|
||||
const updated = await mcpctlProvider.update!(
|
||||
'llm/qwen3-thinking',
|
||||
created.outs,
|
||||
llmInputs({ spec: { type: 'openai', model: 'qwen3-next', url: 'http://litellm:4000', tier: 'heavy' } }),
|
||||
);
|
||||
expect(updated.outs.spec.model).toBe('qwen3-next');
|
||||
const put = mock.requests.find((r) => r.method === 'PUT')!;
|
||||
expect(put.body).not.toHaveProperty('type'); // immutable stripped
|
||||
expect(put.body).not.toHaveProperty('name');
|
||||
|
||||
await mcpctlProvider.delete!('llm/qwen3-thinking', created.outs);
|
||||
expect([...mock.store['llms']!.values()]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('read returns {} when the row is gone (triggers recreate)', async () => {
|
||||
const res = await mcpctlProvider.read!('llm/ghost', { kind: 'llm', name: 'ghost', mcpd: mcpd(), spec: {} });
|
||||
expect(res).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user