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:
1249
pnpm-lock.yaml
generated
1249
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
96
src/pulumi/README.md
Normal file
96
src/pulumi/README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# @mcpctl/pulumi
|
||||
|
||||
A [Pulumi](https://www.pulumi.com/) dynamic provider for [mcpctl](../../). Manage
|
||||
mcpctl resources — `llm`, `server`, `project`, `secret`, `agent`, and any other
|
||||
kind mcpctl supports — declaratively from any TypeScript Pulumi program.
|
||||
|
||||
It talks **directly to mcpd's REST API** (the same endpoints the `mcpctl` CLI
|
||||
uses) and mirrors the CLI's two round-trip guarantees:
|
||||
|
||||
- `apply`'s name-keyed **upsert** (look up by `name`; PUT with immutable fields
|
||||
stripped if it exists, else POST), and
|
||||
- `get -o json`'s field-stripping on read, so state does not churn.
|
||||
|
||||
No `mcpctl` binary or `~/.mcpctl` credentials are required on the machine
|
||||
running `pulumi up`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @mcpctl/pulumi @pulumi/pulumi
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Typed `Llm` wrapper
|
||||
|
||||
```ts
|
||||
import * as pulumi from "@pulumi/pulumi";
|
||||
import * as mcpctl from "@mcpctl/pulumi";
|
||||
|
||||
const cfg = new pulumi.Config();
|
||||
const mcpd = { url: cfg.require("mcpdUrl"), token: cfg.requireSecret("mcpdPat") };
|
||||
|
||||
new mcpctl.Llm("homelab-qwen", {
|
||||
name: "qwen3-thinking", // upsert key (immutable — changing it replaces)
|
||||
type: "openai", // immutable
|
||||
model: servedModelName, // pulumi.Input<string> — e.g. from another resource
|
||||
url: "http://litellm.nvidia-nim.svc.cluster.local:4000",
|
||||
tier: "heavy",
|
||||
mcpd,
|
||||
});
|
||||
```
|
||||
|
||||
When `model` (or `url`, `tier`, `description`, ...) changes, Pulumi issues an
|
||||
in-place `PUT /api/v1/llms/qwen3-thinking` on the next `up`. Changing `name` or
|
||||
`type` forces a replacement (delete-before-replace, since `name` is the identity).
|
||||
|
||||
### Generic resource — any kind
|
||||
|
||||
```ts
|
||||
new mcpctl.McpctlResource("my-server", {
|
||||
kind: "server",
|
||||
name: "my-server",
|
||||
spec: { /* sent verbatim to mcpd; mcpd's Zod schema validates it */ },
|
||||
mcpd,
|
||||
});
|
||||
```
|
||||
|
||||
The provider does no per-kind schema validation beyond the name shape and the
|
||||
required connection — it defers to mcpd. New mcpctl resource kinds work the day
|
||||
mcpd ships them, without a provider release.
|
||||
|
||||
## Auth
|
||||
|
||||
The provider authenticates with a **long-lived PAT** (`mcpctl_pat_…`), passed as
|
||||
`mcpd.token` (use `cfg.requireSecret(...)`).
|
||||
|
||||
Mint one with resource-wide `view:llms` + `edit:llms` bindings, e.g. via
|
||||
`mcpctl apply -f -`:
|
||||
|
||||
```yaml
|
||||
kind: mcptoken
|
||||
name: pulumi-automation
|
||||
projectId: <any-project-id> # currently required (see note)
|
||||
expiresAt: null # never expires
|
||||
bindings:
|
||||
- { role: view, resource: llms }
|
||||
- { role: edit, resource: llms }
|
||||
```
|
||||
|
||||
The bindings are resource-wide, so the token drives `/api/v1/llms/*` regardless
|
||||
of the project it is attached to (the project-scope guard only gates
|
||||
`/api/v1/projects/*`). Grant broader bindings if you manage other kinds.
|
||||
|
||||
> **Note:** mcpd currently requires `projectId` when minting an mcptoken, so a
|
||||
> management-scope PAT must attach to some (throwaway) project. A future mcpd
|
||||
> change may make `projectId` optional for management tokens.
|
||||
|
||||
## Notes
|
||||
|
||||
- **Secrets in state:** dynamic-provider inputs (including the token) are stored,
|
||||
encrypted, in Pulumi state. Use an encrypted state backend.
|
||||
- **Serialization:** this package is CommonJS on purpose — Pulumi serializes the
|
||||
provider object, and the CJS build lets its module references (`./engine`,
|
||||
`./kinds`) be captured and re-required at runtime. Validate end-to-end with a
|
||||
real `pulumi up` against a disposable mcpd before relying on it in production.
|
||||
34
src/pulumi/package.json
Normal file
34
src/pulumi/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@mcpctl/pulumi",
|
||||
"version": "0.0.1",
|
||||
"description": "Pulumi dynamic provider for mcpctl resources (llms, servers, projects, ...). Manage mcpctl declaratively from any TypeScript Pulumi program.",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"clean": "rimraf dist",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"keywords": [
|
||||
"pulumi",
|
||||
"mcpctl",
|
||||
"mcp",
|
||||
"provider"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@pulumi/pulumi": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pulumi/pulumi": "^3.0.0"
|
||||
}
|
||||
}
|
||||
179
src/pulumi/src/engine.ts
Normal file
179
src/pulumi/src/engine.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Dependency-free HTTP engine for the mcpctl Pulumi provider.
|
||||
*
|
||||
* Talks to mcpd's REST API directly — the *same* endpoints the `mcpctl` CLI
|
||||
* uses (`GET/POST/PUT/DELETE /api/v1/<plural>[/<id>]`) — and mirrors two
|
||||
* contracts the CLI guarantees:
|
||||
*
|
||||
* 1. `apply`'s name-keyed upsert: look the resource up by `name`; if it
|
||||
* exists, PUT with immutable fields stripped; otherwise POST.
|
||||
* (see src/cli/src/commands/apply.ts `applyConfig`)
|
||||
* 2. name resolution by listing `/api/v1/<plural>` and matching `name`,
|
||||
* which works uniformly for every resource kind.
|
||||
* (see src/cli/src/commands/shared.ts `resolveNameOrId`)
|
||||
*
|
||||
* No `mcpctl` binary or `~/.mcpctl` credentials are required — every call
|
||||
* carries its own `{ url, token }`. Ported from src/cli/src/api-client.ts.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
/** Connection + auth for a single mcpd daemon. */
|
||||
export interface McpdConn {
|
||||
url: string;
|
||||
token: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** A resource row as mcpd returns it (always carries an `id`). */
|
||||
export interface McpctlEntity {
|
||||
id: string;
|
||||
name?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class McpdHttpError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: string;
|
||||
constructor(status: number, body: string, method: string, path: string) {
|
||||
super(`mcpd ${method} ${path} -> HTTP ${status}: ${body}`);
|
||||
this.name = 'McpdHttpError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function rawRequest(
|
||||
method: string,
|
||||
fullUrl: string,
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
body?: unknown,
|
||||
): Promise<RawResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(fullUrl);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
||||
let payload: string | undefined;
|
||||
if (body !== undefined) {
|
||||
payload = JSON.stringify(body);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = String(Buffer.byteLength(payload));
|
||||
}
|
||||
const driver = isHttps ? https : http;
|
||||
const req = driver.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (isHttps ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method,
|
||||
timeout: timeoutMs,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Request to ${fullUrl} timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
if (payload !== undefined) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a request, retrying on 429 with exponential backoff (mirrors the
|
||||
* CLI's apply backoff). Throws {@link McpdHttpError} on any >=400 status;
|
||||
* callers that tolerate 404 catch it.
|
||||
*/
|
||||
async function request<T>(conn: McpdConn, method: string, path: string, body?: unknown): Promise<T | null> {
|
||||
const timeout = conn.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const base = conn.url.replace(/\/$/, '');
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
const res = await rawRequest(method, `${base}${path}`, conn.token, timeout, body);
|
||||
if (res.status === 429 && attempt < MAX_RETRIES) {
|
||||
attempt += 1;
|
||||
await sleep(250 * 2 ** (attempt - 1));
|
||||
continue;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
throw new McpdHttpError(res.status, res.body, method, path);
|
||||
}
|
||||
if (!res.body) return null;
|
||||
try {
|
||||
return JSON.parse(res.body) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** List all rows of a resource kind (RBAC-filtered by the token). */
|
||||
export async function listResource(conn: McpdConn, resource: string): Promise<McpctlEntity[]> {
|
||||
const data = await request<McpctlEntity[]>(conn, 'GET', `/api/v1/${resource}`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
/** Resolve a resource by human name (list + match), like the CLI does. */
|
||||
export async function findByName(conn: McpdConn, resource: string, name: string): Promise<McpctlEntity | null> {
|
||||
const items = await listResource(conn, resource);
|
||||
return items.find((item) => item.name === name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name-keyed upsert. `doc` is the full body (must include `name`). If a row
|
||||
* with that name exists, PUT it with `immutable` fields stripped; otherwise
|
||||
* POST `doc`. Mirrors apply.ts exactly.
|
||||
*/
|
||||
export async function upsert(
|
||||
conn: McpdConn,
|
||||
resource: string,
|
||||
doc: Record<string, unknown>,
|
||||
immutable: string[],
|
||||
): Promise<McpctlEntity> {
|
||||
const name = String(doc['name'] ?? '');
|
||||
const existing = await findByName(conn, resource, name);
|
||||
if (existing) {
|
||||
const updateBody: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(doc)) {
|
||||
if (!immutable.includes(key)) updateBody[key] = value;
|
||||
}
|
||||
const updated = await request<McpctlEntity>(conn, 'PUT', `/api/v1/${resource}/${existing.id}`, updateBody);
|
||||
return updated ?? { ...existing, ...updateBody };
|
||||
}
|
||||
const created = await request<McpctlEntity>(conn, 'POST', `/api/v1/${resource}`, doc);
|
||||
if (!created || typeof created.id !== 'string') {
|
||||
throw new Error(`create ${resource}/${name}: mcpd returned no id`);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Delete a resource by name. No-op if it is already gone (idempotent). */
|
||||
export async function del(conn: McpdConn, resource: string, name: string): Promise<void> {
|
||||
const existing = await findByName(conn, resource, name);
|
||||
if (!existing) return;
|
||||
try {
|
||||
await request(conn, 'DELETE', `/api/v1/${resource}/${existing.id}`);
|
||||
} catch (err) {
|
||||
if (err instanceof McpdHttpError && err.status === 404) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
20
src/pulumi/src/index.ts
Normal file
20
src/pulumi/src/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @mcpctl/pulumi — a Pulumi dynamic provider for mcpctl.
|
||||
*
|
||||
* Manage mcpctl resources (llms, servers, projects, ...) declaratively from any
|
||||
* TypeScript Pulumi program. Talks directly to mcpd's REST API; no `mcpctl`
|
||||
* binary required. See README for auth setup.
|
||||
*/
|
||||
export { McpctlResource } from './resource';
|
||||
export type { McpctlResourceArgs, McpdConnInput } from './resource';
|
||||
|
||||
export { Llm, LLM_TYPES, LLM_TIERS } from './llm';
|
||||
export type { LlmArgs, LlmType, LlmTier, LlmApiKeyRef } from './llm';
|
||||
|
||||
export { mcpctlProvider } from './provider';
|
||||
export type { McpctlResourceInputs } from './provider';
|
||||
|
||||
export { KIND_TO_RESOURCE } from './kinds';
|
||||
|
||||
export { McpdHttpError } from './engine';
|
||||
export type { McpdConn, McpctlEntity } from './engine';
|
||||
88
src/pulumi/src/kinds.ts
Normal file
88
src/pulumi/src/kinds.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Per-kind metadata, kept deliberately small and generic. The provider defers
|
||||
* all real validation to mcpd's own Zod schemas — this table only covers the
|
||||
* few things a client must know to round-trip correctly:
|
||||
*
|
||||
* - singular kind -> plural REST resource (mirrors apply.ts KIND_TO_RESOURCE)
|
||||
* - which fields mcpd cannot change in place (stripped from PUT bodies)
|
||||
* - which server-managed/runtime fields to drop on read so state does not
|
||||
* churn (mirrors get.ts toApplyDocs)
|
||||
* - the Zod defaults mcpd applies, so `check` can normalize inputs and avoid
|
||||
* spurious diffs
|
||||
*
|
||||
* Unknown kinds still work (resource falls back to the kind string) — new mcpd
|
||||
* resource kinds are usable the day they ship, without a provider release.
|
||||
*/
|
||||
|
||||
/** singular kind -> plural resource key (mirror of apply.ts KIND_TO_RESOURCE). */
|
||||
export const KIND_TO_RESOURCE: Record<string, string> = {
|
||||
server: 'servers',
|
||||
project: 'projects',
|
||||
secret: 'secrets',
|
||||
template: 'templates',
|
||||
user: 'users',
|
||||
group: 'groups',
|
||||
rbac: 'rbac',
|
||||
prompt: 'prompts',
|
||||
promptrequest: 'promptrequests',
|
||||
serverattachment: 'serverattachments',
|
||||
mcptoken: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
llm: 'llms',
|
||||
agent: 'agents',
|
||||
};
|
||||
|
||||
/**
|
||||
* Fields mcpd treats as immutable on update (beyond `name`, which is always
|
||||
* immutable). Changing one forces a replacement. Mirrors the update bodies in
|
||||
* apply.ts (e.g. llms strip `type`).
|
||||
*/
|
||||
export const KIND_IMMUTABLE_FIELDS: Record<string, string[]> = {
|
||||
llm: ['type'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-managed/runtime fields dropped on read so desired-vs-live comparison
|
||||
* stays clean. Mirrors get.ts `toApplyDocs`. `ALWAYS_STRIPPED` applies to every
|
||||
* kind.
|
||||
*/
|
||||
export const ALWAYS_STRIPPED: readonly string[] = ['id', 'createdAt', 'updatedAt', 'version'];
|
||||
|
||||
export const KIND_STRIPPED_ON_READ: Record<string, string[]> = {
|
||||
llm: ['kind', 'status', 'lastHeartbeatAt', 'inactiveSince', 'providerSessionId'],
|
||||
agent: ['kind', 'status', 'lastHeartbeatAt', 'inactiveSince', 'providerSessionId'],
|
||||
};
|
||||
|
||||
/** Zod defaults mcpd applies; mirrored so `check` normalization avoids diffs. */
|
||||
export const KIND_SPEC_DEFAULTS: Record<string, Record<string, unknown>> = {
|
||||
llm: { tier: 'fast', description: '', extraConfig: {}, poolName: null },
|
||||
};
|
||||
|
||||
export function resourceForKind(kind: string): string {
|
||||
return KIND_TO_RESOURCE[kind] ?? kind;
|
||||
}
|
||||
|
||||
/** Immutable fields for a kind, including the always-immutable `name`. */
|
||||
export function immutableFieldsForKind(kind: string): string[] {
|
||||
return ['name', ...(KIND_IMMUTABLE_FIELDS[kind] ?? [])];
|
||||
}
|
||||
|
||||
/** Drop undefined-valued keys and apply the kind's defaults (spec wins). */
|
||||
export function normalizeSpec(kind: string, spec: Record<string, unknown>): Record<string, unknown> {
|
||||
const defaults = KIND_SPEC_DEFAULTS[kind] ?? {};
|
||||
const out: Record<string, unknown> = { ...defaults };
|
||||
for (const [key, value] of Object.entries(spec)) {
|
||||
if (value !== undefined) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip server-managed + kind-specific runtime fields from a live row. */
|
||||
export function stripForRead(kind: string, entity: Record<string, unknown>): Record<string, unknown> {
|
||||
const drop = new Set<string>([...ALWAYS_STRIPPED, ...(KIND_STRIPPED_ON_READ[kind] ?? [])]);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(entity)) {
|
||||
if (!drop.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
51
src/pulumi/src/llm.ts
Normal file
51
src/pulumi/src/llm.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Typed convenience wrapper for the mcpctl `llm` resource. Compiles down to a
|
||||
* generic {@link McpctlResource} with `kind: "llm"`. Field types mirror mcpd's
|
||||
* CreateLlmSchema / UpdateLlmSchema (src/mcpd/src/validation/llm.schema.ts).
|
||||
*
|
||||
* new mcpctl.Llm("homelab-qwen", {
|
||||
* name: "qwen3-thinking",
|
||||
* type: "openai",
|
||||
* model: servedModelName, // pulumi.Input<string>
|
||||
* url: litellmUrl,
|
||||
* tier: "heavy",
|
||||
* mcpd: { url, token: cfg.requireSecret("mcpdPat") },
|
||||
* });
|
||||
*/
|
||||
import type { Input, CustomResourceOptions } from '@pulumi/pulumi';
|
||||
import { McpctlResource, McpdConnInput } from './resource';
|
||||
|
||||
export const LLM_TYPES = ['anthropic', 'openai', 'deepseek', 'vllm', 'ollama', 'gemini-cli'] as const;
|
||||
export type LlmType = (typeof LLM_TYPES)[number];
|
||||
|
||||
export const LLM_TIERS = ['fast', 'heavy'] as const;
|
||||
export type LlmTier = (typeof LLM_TIERS)[number];
|
||||
|
||||
/** Reference to a key inside a Secret; mcpd resolves it server-side. */
|
||||
export interface LlmApiKeyRef {
|
||||
name: Input<string>;
|
||||
key: Input<string>;
|
||||
}
|
||||
|
||||
export interface LlmArgs {
|
||||
/** llm resource name (upsert key). Immutable — changing it replaces. */
|
||||
name: Input<string>;
|
||||
/** Provider type. Immutable — changing it replaces. */
|
||||
type: Input<LlmType>;
|
||||
model: Input<string>;
|
||||
/** Endpoint URL; omit for the provider default. */
|
||||
url?: Input<string>;
|
||||
tier?: Input<LlmTier>;
|
||||
description?: Input<string>;
|
||||
apiKeyRef?: Input<LlmApiKeyRef>;
|
||||
poolName?: Input<string>;
|
||||
extraConfig?: Input<Record<string, unknown>>;
|
||||
mcpd: Input<McpdConnInput>;
|
||||
}
|
||||
|
||||
export class Llm extends McpctlResource {
|
||||
constructor(name: string, args: LlmArgs, opts?: CustomResourceOptions) {
|
||||
const { name: llmName, mcpd, ...spec } = args;
|
||||
super(name, { kind: 'llm', name: llmName, spec: spec as Record<string, unknown>, mcpd }, opts);
|
||||
}
|
||||
}
|
||||
123
src/pulumi/src/provider.ts
Normal file
123
src/pulumi/src/provider.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* The mcpctl Pulumi dynamic provider.
|
||||
*
|
||||
* A dynamic provider's object is *serialized* into the deployment. This package
|
||||
* compiles to CommonJS, so these top-level imports emit as `require(...)` in the
|
||||
* built output; Pulumi's closure serializer captures those module references
|
||||
* (the helpers are pure and dependency-light) and re-requires them at runtime.
|
||||
* The `@pulumi/pulumi` import is type-only, so it is erased and leaves no
|
||||
* runtime reference in the serialized object.
|
||||
*
|
||||
* CRUD maps onto mcpd's REST API via ./engine, mirroring the CLI's `apply`
|
||||
* (upsert) and `get -o json` (round-trip) contracts. Validation is deferred to
|
||||
* mcpd's Zod schemas — this provider only enforces the few client-side
|
||||
* invariants (name shape, required connection).
|
||||
*/
|
||||
import type * as pulumi from '@pulumi/pulumi';
|
||||
import { upsert, findByName, del } from './engine';
|
||||
import {
|
||||
resourceForKind,
|
||||
immutableFieldsForKind,
|
||||
normalizeSpec,
|
||||
stripForRead,
|
||||
KIND_IMMUTABLE_FIELDS,
|
||||
} from './kinds';
|
||||
|
||||
/** Inputs for a generic mcpctl resource (all values resolved by the time the
|
||||
* provider sees them). */
|
||||
export interface McpctlResourceInputs {
|
||||
kind: string;
|
||||
name: string;
|
||||
spec: Record<string, unknown>;
|
||||
mcpd: { url: string; token: string; timeoutMs?: number };
|
||||
}
|
||||
|
||||
/** Deterministic JSON for order-insensitive equality checks. */
|
||||
function stableStringify(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
||||
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`;
|
||||
}
|
||||
|
||||
export const mcpctlProvider: pulumi.dynamic.ResourceProvider = {
|
||||
async check(_olds: unknown, news: unknown): Promise<pulumi.dynamic.CheckResult> {
|
||||
const input = (news ?? {}) as Partial<McpctlResourceInputs>;
|
||||
const failures: pulumi.dynamic.CheckFailure[] = [];
|
||||
|
||||
if (typeof input.kind !== 'string' || input.kind.length === 0) {
|
||||
failures.push({ property: 'kind', reason: 'kind is required (e.g. "llm", "server", "project")' });
|
||||
}
|
||||
if (typeof input.name !== 'string' || !/^[a-z0-9-]+$/.test(input.name)) {
|
||||
failures.push({ property: 'name', reason: 'name is required and must be lowercase alphanumeric with hyphens' });
|
||||
}
|
||||
if (input.spec === null || typeof input.spec !== 'object' || Array.isArray(input.spec)) {
|
||||
failures.push({ property: 'spec', reason: 'spec must be an object' });
|
||||
}
|
||||
const mcpd = input.mcpd;
|
||||
if (mcpd === undefined || typeof mcpd.url !== 'string' || mcpd.url.length === 0) {
|
||||
failures.push({ property: 'mcpd', reason: 'mcpd.url is required' });
|
||||
}
|
||||
if (mcpd === undefined || typeof mcpd.token !== 'string' || mcpd.token.length === 0) {
|
||||
failures.push({ property: 'mcpd', reason: 'mcpd.token is required' });
|
||||
}
|
||||
if (failures.length > 0) return { failures };
|
||||
|
||||
const kind = input.kind as string;
|
||||
const spec = input.spec as Record<string, unknown>;
|
||||
return { inputs: { ...input, spec: normalizeSpec(kind, spec) } };
|
||||
},
|
||||
|
||||
async diff(_id: string, olds: unknown, news: unknown): Promise<pulumi.dynamic.DiffResult> {
|
||||
const o = olds as McpctlResourceInputs;
|
||||
const n = news as McpctlResourceInputs;
|
||||
const replaces: string[] = [];
|
||||
|
||||
if (o.kind !== n.kind) replaces.push('kind');
|
||||
if (o.name !== n.name) replaces.push('name');
|
||||
// Re-pointing at a different daemon is a different resource.
|
||||
if (o.mcpd?.url !== n.mcpd?.url) replaces.push('mcpd');
|
||||
|
||||
const immutable = KIND_IMMUTABLE_FIELDS[n.kind] ?? [];
|
||||
for (const field of immutable) {
|
||||
if (stableStringify(o.spec?.[field]) !== stableStringify(n.spec?.[field])) {
|
||||
replaces.push(`spec.${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
const specChanged = stableStringify(o.spec ?? {}) !== stableStringify(n.spec ?? {});
|
||||
const changes = replaces.length > 0 || specChanged;
|
||||
// Any replacement keeps or reuses the unique `name`, so the old row must go
|
||||
// first to avoid a uniqueness collision (or an orphan on rename).
|
||||
return { changes, replaces, deleteBeforeReplace: replaces.length > 0 };
|
||||
},
|
||||
|
||||
async create(inputs: unknown): Promise<pulumi.dynamic.CreateResult> {
|
||||
const inp = inputs as McpctlResourceInputs;
|
||||
const spec = normalizeSpec(inp.kind, inp.spec ?? {});
|
||||
const entity = await upsert(inp.mcpd, resourceForKind(inp.kind), { name: inp.name, ...spec }, immutableFieldsForKind(inp.kind));
|
||||
return { id: `${inp.kind}/${inp.name}`, outs: { kind: inp.kind, name: inp.name, spec, mcpd: inp.mcpd, resourceId: entity.id } };
|
||||
},
|
||||
|
||||
async read(id: string, props: unknown): Promise<pulumi.dynamic.ReadResult> {
|
||||
const p = props as McpctlResourceInputs;
|
||||
const entity = await findByName(p.mcpd, resourceForKind(p.kind), p.name);
|
||||
if (!entity) return {}; // gone — Pulumi will recreate
|
||||
const spec = stripForRead(p.kind, entity);
|
||||
delete spec['name'];
|
||||
return { id, props: { kind: p.kind, name: p.name, mcpd: p.mcpd, spec, resourceId: entity.id } };
|
||||
},
|
||||
|
||||
async update(_id: string, _olds: unknown, news: unknown): Promise<pulumi.dynamic.UpdateResult> {
|
||||
const n = news as McpctlResourceInputs;
|
||||
const spec = normalizeSpec(n.kind, n.spec ?? {});
|
||||
const entity = await upsert(n.mcpd, resourceForKind(n.kind), { name: n.name, ...spec }, immutableFieldsForKind(n.kind));
|
||||
return { outs: { kind: n.kind, name: n.name, spec, mcpd: n.mcpd, resourceId: entity.id } };
|
||||
},
|
||||
|
||||
async delete(_id: string, props: unknown): Promise<void> {
|
||||
const p = props as McpctlResourceInputs;
|
||||
await del(p.mcpd, resourceForKind(p.kind), p.name);
|
||||
},
|
||||
};
|
||||
50
src/pulumi/src/resource.ts
Normal file
50
src/pulumi/src/resource.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Generic mcpctl resource. Round-trips ANY mcpctl resource kind through the
|
||||
* dynamic provider: give it a `kind`, a `name`, an opaque `spec` (sent
|
||||
* verbatim to mcpd, which validates it), and an `mcpd` connection.
|
||||
*
|
||||
* new mcpctl.McpctlResource("my-server", {
|
||||
* kind: "server",
|
||||
* name: "my-server",
|
||||
* spec: { image: "...", transport: "stdio", ... },
|
||||
* mcpd: { url, token },
|
||||
* });
|
||||
*/
|
||||
import * as pulumi from '@pulumi/pulumi';
|
||||
import { mcpctlProvider } from './provider';
|
||||
|
||||
/** mcpd connection + auth. `token` should be a Pulumi secret. */
|
||||
export interface McpdConnInput {
|
||||
url: pulumi.Input<string>;
|
||||
token: pulumi.Input<string>;
|
||||
timeoutMs?: pulumi.Input<number>;
|
||||
}
|
||||
|
||||
export interface McpctlResourceArgs {
|
||||
/** Singular resource kind, e.g. "llm", "server", "project", "secret". */
|
||||
kind: pulumi.Input<string>;
|
||||
/** Human name — the upsert key (must be unique for the kind). */
|
||||
name: pulumi.Input<string>;
|
||||
/** Resource body sent verbatim to mcpd (mcpd's Zod schema validates it). */
|
||||
spec: pulumi.Input<Record<string, unknown>>;
|
||||
/** Target daemon + credentials. */
|
||||
mcpd: pulumi.Input<McpdConnInput>;
|
||||
}
|
||||
|
||||
export class McpctlResource extends pulumi.dynamic.Resource {
|
||||
/** mcpd's own resource id (CUID) for the managed row. */
|
||||
public readonly resourceId!: pulumi.Output<string>;
|
||||
|
||||
constructor(name: string, args: McpctlResourceArgs, opts?: pulumi.CustomResourceOptions) {
|
||||
super(
|
||||
mcpctlProvider,
|
||||
name,
|
||||
{ ...args, resourceId: undefined },
|
||||
{
|
||||
...opts,
|
||||
// The connection carries a bearer token — keep it encrypted in state.
|
||||
additionalSecretOutputs: [...(opts?.additionalSecretOutputs ?? []), 'mcpd'],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
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({});
|
||||
});
|
||||
});
|
||||
12
src/pulumi/tsconfig.json
Normal file
12
src/pulumi/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
src/pulumi/vitest.config.ts
Normal file
8
src/pulumi/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
{ "path": "src/db" },
|
||||
{ "path": "src/cli" },
|
||||
{ "path": "src/mcpd" },
|
||||
{ "path": "src/mcplocal" }
|
||||
{ "path": "src/mcplocal" },
|
||||
{ "path": "src/pulumi" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user