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

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:
Michal
2026-07-17 22:02:22 +01:00
parent e2db8d9003
commit f3d92235db
16 changed files with 2222 additions and 10 deletions

View 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);
});
});

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

View 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()));
},
};
}

View 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({});
});
});