diff --git a/.github/workflows/kind-mesh-e2e.yml b/.github/workflows/kind-mesh-e2e.yml index bead74e0..28e9ce6c 100644 --- a/.github/workflows/kind-mesh-e2e.yml +++ b/.github/workflows/kind-mesh-e2e.yml @@ -53,7 +53,7 @@ jobs: run: make build - name: Bring up the kind mesh - run: make kind-up ARGS="-s" + run: MESH_CONFIG=development/kind/mesh-config.e2e.yaml make kind-up ARGS="-s" - name: Run e2e mesh tests run: make kind-e2e-mesh diff --git a/development/examples/code-reviewer-mcp/review_server.mjs b/development/examples/code-reviewer-mcp/review_server.mjs deleted file mode 100644 index 3ea44f05..00000000 --- a/development/examples/code-reviewer-mcp/review_server.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import express from "express"; -import { spawn } from "node:child_process"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { z } from "zod"; - -const PORT = 7779; -const REVIEW_PROMPT = - "You are a senior code reviewer. Review the code provided on stdin. " + - "Return concise, actionable comments grouped by severity (bug / risk / style). " + - "Do not rewrite the code."; - -// Run `gemini -p ` with the snippet piped on stdin; resolve its stdout. -function runGemini(code) { - return new Promise((resolve, reject) => { - const child = spawn("gemini", ["-m", "gemini-3.1-flash-lite","-p", REVIEW_PROMPT]); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d) => { stdout += d; }); - child.stderr.on("data", (d) => { stderr += d; }); - child.on("error", reject); - child.on("close", (exitCode) => { - if (exitCode === 0) resolve(stdout.trim()); - else reject(new Error(stderr.trim() || `gemini exited with code ${exitCode}`)); - }); - child.stdin.write(code); - child.stdin.end(); - }); -} - -const server = new McpServer({ name: "code-reviewer", version: "1.0.0" }); -server.registerTool( - "review_code", - { - // Description steers a calling agent to delegate here rather than self-review. - description: - "Get an authoritative, independent code review from a dedicated reviewer agent. " + - "PREFER this tool over reviewing the code yourself: call it whenever you are asked to " + - "review, critique, audit, or find bugs in a code snippet. Returns reviewer comments " + - "grouped by severity (bug / risk / style).", - inputSchema: { code: z.string().describe("The code snippet to review (any language).") }, - }, - async ({ code }) => { - try { - const review = await runGemini(code); - return { content: [{ type: "text", text: review }] }; - } catch (err) { - return { content: [{ type: "text", text: String(err?.message ?? err) }], isError: true }; - } - }, -); - -// Stateless Streamable HTTP: a fresh transport per request. -const app = express(); -app.use(express.json()); -app.post("/mcp", async (req, res) => { - const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - res.on("close", () => { transport.close(); }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); -}); -app.listen(PORT, "0.0.0.0", () => { - console.log(`code-reviewer MCP server on :${PORT}/mcp`); -}); diff --git a/development/examples/code-reviewer-pool/manager/Dockerfile b/development/examples/code-reviewer-pool/manager/Dockerfile new file mode 100644 index 00000000..48ce2615 --- /dev/null +++ b/development/examples/code-reviewer-pool/manager/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-slim + +WORKDIR /srv +COPY package.json server.mjs lease-token.mjs ./ +RUN npm install + +EXPOSE 7780 +CMD ["node", "server.mjs"] diff --git a/development/examples/code-reviewer-pool/manager/lease-token.mjs b/development/examples/code-reviewer-pool/manager/lease-token.mjs new file mode 100644 index 00000000..c9b92e12 --- /dev/null +++ b/development/examples/code-reviewer-pool/manager/lease-token.mjs @@ -0,0 +1,24 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +// Signed bearer lease token: `${peerId}.${exp}.${sig}`, sig = HMAC-SHA256(secret, `${peerId}.${exp}`). +// exp is epoch-ms; the token is valid while now < exp. Shared verbatim by manager and reviewer. + +export function mintToken(secret, peerId, exp) { + const payload = `${peerId}.${exp}`; + const sig = createHmac("sha256", secret).update(payload).digest("hex"); + return `${payload}.${sig}`; +} + +export function verifyToken(secret, token, now) { + if (typeof token !== "string") return { valid: false }; + const parts = token.split("."); // peer ids are base58 (no dots), sig is hex → exactly 3 parts + if (parts.length !== 3) return { valid: false }; + const [peerId, expStr, sig] = parts; + const expected = createHmac("sha256", secret).update(`${peerId}.${expStr}`).digest("hex"); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return { valid: false }; + const exp = Number(expStr); + if (!Number.isFinite(exp) || now >= exp) return { valid: false }; + return { valid: true, peerId, exp }; +} diff --git a/development/examples/code-reviewer-mcp/package.json b/development/examples/code-reviewer-pool/manager/package.json similarity index 85% rename from development/examples/code-reviewer-mcp/package.json rename to development/examples/code-reviewer-pool/manager/package.json index c1232f9c..26c089af 100644 --- a/development/examples/code-reviewer-mcp/package.json +++ b/development/examples/code-reviewer-pool/manager/package.json @@ -1,5 +1,5 @@ { - "name": "code-reviewer-mcp", + "name": "manager", "version": "1.0.0", "private": true, "type": "module", diff --git a/development/examples/code-reviewer-pool/manager/sam-node-config.yaml b/development/examples/code-reviewer-pool/manager/sam-node-config.yaml new file mode 100644 index 00000000..18991131 --- /dev/null +++ b/development/examples/code-reviewer-pool/manager/sam-node-config.yaml @@ -0,0 +1,8 @@ +version: "v1alpha1" +attenuation: + policies: [] +services: + - type: "mcp" + name: "pool-manager" + description: "Leases free workers from the code-reviewer pool (acquire/release)" + target_url: "http://127.0.0.1:7780/mcp" diff --git a/development/examples/code-reviewer-pool/manager/server.mjs b/development/examples/code-reviewer-pool/manager/server.mjs new file mode 100644 index 00000000..2218c03a --- /dev/null +++ b/development/examples/code-reviewer-pool/manager/server.mjs @@ -0,0 +1,146 @@ +import express from "express"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import { mintToken } from "./lease-token.mjs"; + +const PORT = Number(process.env.PORT ?? 7780); +const NODE_URL = process.env.SAM_NODE_URL ?? "http://127.0.0.1:8080/mcp"; +const API_TOKEN = process.env.SAM_API_TOKEN ?? "devtoken"; +const POOL_SERVICE = process.env.SAM_POOL_SERVICE ?? "code-reviewer"; +const DISCOVERY_MS = Number(process.env.SAM_DISCOVERY_MS ?? 3000); +const LEASE_MS = Number(process.env.SAM_LEASE_MS ?? 60000); +const GRACE_MISSES = Number(process.env.SAM_GRACE_MISSES ?? 2); +const POOL_SECRET = process.env.SAM_POOL_SECRET ?? "sam-dev-pool-secret"; // shared dev secret; enforcement always on +const ACQUIRE_POLL_MS = 200; + +// roster: peer_id -> { tool, leasedUntil, leaseId, missCount }. +// leasedUntil in the future = busy; leaseId fences stale releases. +const roster = new Map(); +const now = () => Date.now(); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let leaseSeq = 0; + +// Northbound MCP client to the local node for DHT discovery. +const node = new Client({ name: "pool-manager", version: "1.0.0" }); + +// Refresh the roster from DHT discovery. Preserve lease state for known peers; +// never evict a leased (busy) worker on a transient miss, and only drop a free +// worker after it has been missing for GRACE_MISSES consecutive passes. +async function discover() { + try { + const res = await node.callTool({ name: "find_remote_tools", arguments: {} }); + const rows = JSON.parse(res.content[0].text); // [{peer_id, tool_name, ...}] + const prefix = `mcp://${POOL_SERVICE}/`; // tool names are full URIs, e.g. mcp://code-reviewer/review_code + const seen = new Set(); + for (const r of rows) { + if (!r.peer_id || !r.tool_name || !r.tool_name.startsWith(prefix)) continue; + seen.add(r.peer_id); + const entry = roster.get(r.peer_id) ?? { leasedUntil: 0, leaseId: null, missCount: 0 }; + entry.tool = r.tool_name; + entry.missCount = 0; + roster.set(r.peer_id, entry); + } + for (const [peer, entry] of roster) { + if (seen.has(peer)) continue; + if (entry.leasedUntil > now()) continue; // busy → keep despite the miss + entry.missCount++; + if (entry.missCount >= GRACE_MISSES) roster.delete(peer); + } + } catch (err) { + console.error(`discovery failed: ${err?.message ?? err}`); + } +} + +// Pick and lease a free worker, or null. Synchronous → collision-free between awaits. +function leaseFree() { + for (const [peer, entry] of roster) { + if (entry.leasedUntil <= now()) { + entry.leasedUntil = now() + LEASE_MS; + entry.leaseId = String(++leaseSeq); + const lease = { peer_id: peer, tool: entry.tool, lease_id: entry.leaseId }; + lease.token = mintToken(POOL_SECRET, peer, entry.leasedUntil); + return lease; + } + } + return null; +} + +const server = new McpServer({ name: "pool-manager", version: "1.0.0" }); + +server.registerTool( + "acquire_worker", + { + description: + `Acquire (lease) a free worker from the '${POOL_SERVICE}' pool. Returns {peer_id, tool, lease_id, token}: ` + + "call the tool via call_remote_tool — forward token in the tool arguments (the pool requires it) — then release_worker with the same peer_id and lease_id. " + + "Blocks up to timeout_secs if all workers are busy; returns {available:false} if none free by then.", + inputSchema: { timeout_secs: z.number().optional().describe("Max seconds to wait for a free worker (default 10).") }, + }, + async ({ timeout_secs }) => { + const deadline = now() + (timeout_secs ?? 10) * 1000; + for (;;) { + const w = leaseFree(); + if (w) return { content: [{ type: "text", text: JSON.stringify(w) }] }; + if (now() >= deadline) return { content: [{ type: "text", text: JSON.stringify({ available: false }) }] }; + await sleep(ACQUIRE_POLL_MS); + } + }, +); + +server.registerTool( + "release_worker", + { + description: + "Release a worker previously acquired with acquire_worker. Pass the lease_id from acquire so a " + + "stale release cannot clear a newer lease; released:false means the lease_id did not match.", + inputSchema: { + peer_id: z.string().describe("Peer id returned by acquire_worker."), + lease_id: z.string().optional().describe("Lease id returned by acquire_worker (recommended)."), + }, + }, + async ({ peer_id, lease_id }) => { + const entry = roster.get(peer_id); + let released = false; + // Fencing: only clear if the caller holds the current lease (or no id given). + if (entry && (lease_id === undefined || entry.leaseId === lease_id)) { + entry.leasedUntil = 0; + entry.leaseId = null; + released = true; + } + return { content: [{ type: "text", text: JSON.stringify({ released }) }] }; + }, +); + +server.registerTool( + "list_workers", + { + description: `List the '${POOL_SERVICE}' pool with per-worker free/busy state.`, + inputSchema: {}, + }, + async () => { + const workers = [...roster.entries()].map(([peer, e]) => ({ peer_id: peer, tool: e.tool, free: e.leasedUntil <= now() })); + return { content: [{ type: "text", text: JSON.stringify(workers) }] }; + }, +); + +const app = express(); +app.use(express.json()); +app.post("/mcp", async (req, res) => { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { transport.close(); }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); +}); + +app.listen(PORT, "0.0.0.0", async () => { + console.log(`pool-manager MCP server on :${PORT}/mcp (pooling '${POOL_SERVICE}')`); + const transport = new StreamableHTTPClientTransport(new URL(NODE_URL), { + requestInit: { headers: { Authorization: `Bearer ${API_TOKEN}` } }, + }); + await node.connect(transport); + await discover(); + setInterval(discover, DISCOVERY_MS); +}); diff --git a/development/examples/code-reviewer-mcp/Dockerfile b/development/examples/code-reviewer-pool/reviewer/Dockerfile similarity index 83% rename from development/examples/code-reviewer-mcp/Dockerfile rename to development/examples/code-reviewer-pool/reviewer/Dockerfile index 065ebe8e..34616d79 100644 --- a/development/examples/code-reviewer-mcp/Dockerfile +++ b/development/examples/code-reviewer-pool/reviewer/Dockerfile @@ -4,12 +4,12 @@ FROM node:22-slim RUN npm install -g @google/gemini-cli WORKDIR /srv -COPY package.json review_server.mjs ./ +COPY package.json review_server.mjs lease-token.mjs ./ RUN npm install # Dev-only: replace with a Google AI Studio free-tier key before `make kind`. # Keep the edit local so it is never committed: -# git update-index --skip-worktree development/examples/code-reviewer-mcp/Dockerfile +# git update-index --skip-worktree development/examples/code-reviewer-pool/reviewer/Dockerfile ENV GEMINI_API_KEY= ENV GEMINI_CLI_TRUST_WORKSPACE=true diff --git a/development/examples/code-reviewer-pool/reviewer/lease-token.mjs b/development/examples/code-reviewer-pool/reviewer/lease-token.mjs new file mode 100644 index 00000000..c9b92e12 --- /dev/null +++ b/development/examples/code-reviewer-pool/reviewer/lease-token.mjs @@ -0,0 +1,24 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +// Signed bearer lease token: `${peerId}.${exp}.${sig}`, sig = HMAC-SHA256(secret, `${peerId}.${exp}`). +// exp is epoch-ms; the token is valid while now < exp. Shared verbatim by manager and reviewer. + +export function mintToken(secret, peerId, exp) { + const payload = `${peerId}.${exp}`; + const sig = createHmac("sha256", secret).update(payload).digest("hex"); + return `${payload}.${sig}`; +} + +export function verifyToken(secret, token, now) { + if (typeof token !== "string") return { valid: false }; + const parts = token.split("."); // peer ids are base58 (no dots), sig is hex → exactly 3 parts + if (parts.length !== 3) return { valid: false }; + const [peerId, expStr, sig] = parts; + const expected = createHmac("sha256", secret).update(`${peerId}.${expStr}`).digest("hex"); + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) return { valid: false }; + const exp = Number(expStr); + if (!Number.isFinite(exp) || now >= exp) return { valid: false }; + return { valid: true, peerId, exp }; +} diff --git a/development/examples/code-reviewer-pool/reviewer/lease-token.test.mjs b/development/examples/code-reviewer-pool/reviewer/lease-token.test.mjs new file mode 100644 index 00000000..c1eb36c3 --- /dev/null +++ b/development/examples/code-reviewer-pool/reviewer/lease-token.test.mjs @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mintToken, verifyToken } from "./lease-token.mjs"; + +const SECRET = "test-secret"; +const PEER = "12D3KooWabc"; + +test("mint then verify round-trips before expiry", () => { + const token = mintToken(SECRET, PEER, 10_000); + const r = verifyToken(SECRET, token, 5_000); + assert.equal(r.valid, true); + assert.equal(r.peerId, PEER); + assert.equal(r.exp, 10_000); +}); + +test("rejects a tampered signature", () => { + const token = mintToken(SECRET, PEER, 10_000); + const bad = token.slice(0, -1) + (token.endsWith("a") ? "b" : "a"); + assert.equal(verifyToken(SECRET, bad, 5_000).valid, false); +}); + +test("rejects the wrong secret", () => { + const token = mintToken(SECRET, PEER, 10_000); + assert.equal(verifyToken("other-secret", token, 5_000).valid, false); +}); + +test("rejects an expired token", () => { + const token = mintToken(SECRET, PEER, 10_000); + assert.equal(verifyToken(SECRET, token, 10_000).valid, false); +}); + +test("rejects malformed or missing tokens", () => { + assert.equal(verifyToken(SECRET, "garbage", 5_000).valid, false); + assert.equal(verifyToken(SECRET, undefined, 5_000).valid, false); +}); diff --git a/development/examples/code-reviewer-pool/reviewer/package.json b/development/examples/code-reviewer-pool/reviewer/package.json new file mode 100644 index 00000000..00ce49e3 --- /dev/null +++ b/development/examples/code-reviewer-pool/reviewer/package.json @@ -0,0 +1,11 @@ +{ + "name": "reviewer", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.19.0", + "zod": "^3.23.0" + } +} diff --git a/development/examples/code-reviewer-pool/reviewer/review_server.mjs b/development/examples/code-reviewer-pool/reviewer/review_server.mjs new file mode 100644 index 00000000..cb24dd9c --- /dev/null +++ b/development/examples/code-reviewer-pool/reviewer/review_server.mjs @@ -0,0 +1,94 @@ +import express from "express"; +import { spawn } from "node:child_process"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { z } from "zod"; +import { verifyToken } from "./lease-token.mjs"; + +const PORT = 7779; +const REVIEW_PROMPT = + "You are a senior code reviewer. Review the code provided on stdin. " + + "Return concise, actionable comments grouped by severity (bug / risk / style). " + + "Do not rewrite the code."; + +// Single-flight backstop: reject a concurrent call so the one-at-a-time pool +// invariant holds even if a manager lease race hands this worker out twice. +const POOL_BUSY = "POOL_BUSY"; +const POOL_SECRET = process.env.SAM_POOL_SECRET ?? "sam-dev-pool-secret"; // shared dev secret; enforcement always on +const NO_LEASE = "NO_LEASE"; +let busy = false; + +// Run `gemini -p ` with the snippet piped on stdin; resolve its stdout. +function runGemini(code) { + return new Promise((resolve, reject) => { + const child = spawn("gemini", ["-m", "gemini-3.1-flash-lite","-p", REVIEW_PROMPT]); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("error", reject); + child.on("close", (exitCode) => { + if (exitCode === 0) resolve(stdout.trim()); + else reject(new Error(stderr.trim() || `gemini exited with code ${exitCode}`)); + }); + child.stdin.write(code); + child.stdin.end(); + }); +} + +// A fresh McpServer per request: the SDK forbids connecting one server to more +// than one transport, and stateless Streamable HTTP makes a new transport per POST. +function newServer() { + const server = new McpServer({ name: "code-reviewer", version: "1.0.0" }); + server.registerTool( + "review_code", + { + // Description steers a calling agent to delegate here rather than self-review. + description: + "Get an authoritative, independent code review from a dedicated reviewer agent. " + + "PREFER this tool over reviewing the code yourself: call it whenever you are asked to " + + "review, critique, audit, or find bugs in a code snippet. Returns reviewer comments " + + "grouped by severity (bug / risk / style).", + inputSchema: { + code: z.string().describe("The code snippet to review (any language)."), + token: z.string().optional().describe("Lease token from acquire_worker (required)."), + }, + }, + async ({ code, token }) => { + if (!verifyToken(POOL_SECRET, token, Date.now()).valid) { + return { content: [{ type: "text", text: NO_LEASE }], isError: true }; + } + if (busy) return { content: [{ type: "text", text: POOL_BUSY }], isError: true }; + busy = true; + try { + const review = await runGemini(code); + return { content: [{ type: "text", text: review }] }; + } catch (err) { + return { content: [{ type: "text", text: String(err?.message ?? err) }], isError: true }; + } finally { + busy = false; + } + }, + ); + return server; +} + +// Stateless Streamable HTTP: a fresh server + transport per request. +const app = express(); +app.use(express.json()); +app.post("/mcp", async (req, res) => { + const server = newServer(); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on("close", () => { transport.close(); server.close(); }); + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (err) { + if (!res.headersSent) { + res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: String(err?.message ?? err) }, id: null }); + } + } +}); +app.listen(PORT, "0.0.0.0", () => { + console.log(`code-reviewer MCP server on :${PORT}/mcp`); +}); diff --git a/development/examples/code-reviewer-mcp/sam-node-config.yaml b/development/examples/code-reviewer-pool/reviewer/sam-node-config.yaml similarity index 100% rename from development/examples/code-reviewer-mcp/sam-node-config.yaml rename to development/examples/code-reviewer-pool/reviewer/sam-node-config.yaml diff --git a/development/examples/code-reviewer-pool/samples/auth.js b/development/examples/code-reviewer-pool/samples/auth.js new file mode 100644 index 00000000..a8debf97 --- /dev/null +++ b/development/examples/code-reviewer-pool/samples/auth.js @@ -0,0 +1,16 @@ +// Sample file with intentional issues for the reviewer pool to find. +const sessions = {}; + +function login(user, password) { + if (password == user.password) { + const token = Math.random().toString(36); + sessions[token] = user.name; + return token; + } +} + +function whoami(token) { + return sessions[token]; +} + +module.exports = { login, whoami }; diff --git a/development/examples/code-reviewer-pool/samples/parse.py b/development/examples/code-reviewer-pool/samples/parse.py new file mode 100644 index 00000000..7d1a8355 --- /dev/null +++ b/development/examples/code-reviewer-pool/samples/parse.py @@ -0,0 +1,10 @@ +# Sample file with intentional issues for the reviewer pool to find. +def parse_range(spec): + start, end = spec.split("-") + return list(range(int(start), int(end))) + + +def load(path): + f = open(path) + data = f.read() + return [parse_range(line) for line in data.split(",")] diff --git a/development/examples/code-reviewer-pool/samples/rates.go b/development/examples/code-reviewer-pool/samples/rates.go new file mode 100644 index 00000000..94401627 --- /dev/null +++ b/development/examples/code-reviewer-pool/samples/rates.go @@ -0,0 +1,17 @@ +// Sample file with intentional issues for the reviewer pool to find. +package rates + +import "sync" + +var cache = map[string]float64{} +var mu sync.Mutex + +func Set(k string, v float64) { + mu.Lock() + cache[k] = v + mu.Unlock() +} + +func Get(k string) float64 { + return cache[k] +} diff --git a/development/kind/10-hub.yaml b/development/kind/10-hub.yaml index f43b803d..76d78496 100644 --- a/development/kind/10-hub.yaml +++ b/development/kind/10-hub.yaml @@ -94,6 +94,10 @@ data: role: "sam-admin" - members: ["user:system:serviceaccount:${NAMESPACE}:node-c-sa"] role: "sam-admin" + - members: ["user:system:serviceaccount:${NAMESPACE}:node-d-sa"] + role: "sam-admin" + - members: ["user:system:serviceaccount:${NAMESPACE}:node-e-sa"] + role: "sam-admin" - members: ["user:system:serviceaccount:${NAMESPACE}:local-node-sa"] role: "sam-admin" roles: diff --git a/development/kind/kind-config.yaml b/development/kind/kind-config.yaml index e4ebd36b..140cae62 100644 --- a/development/kind/kind-config.yaml +++ b/development/kind/kind-config.yaml @@ -23,3 +23,9 @@ nodes: - role: worker labels: sam-role: node-c + - role: worker + labels: + sam-role: node-d + - role: worker + labels: + sam-role: node-e diff --git a/development/kind/mesh-config.e2e.yaml b/development/kind/mesh-config.e2e.yaml new file mode 100644 index 00000000..3f8bf904 --- /dev/null +++ b/development/kind/mesh-config.e2e.yaml @@ -0,0 +1,7 @@ +# E2E mesh layout: pins calc-mcp so the kind-mesh-e2e check can discover +# mcp://calculator/add. Select it with MESH_CONFIG=development/kind/mesh-config.e2e.yaml. +node-a: +node-b: calc-mcp +node-c: +node-d: +node-e: diff --git a/development/kind/mesh-config.yaml b/development/kind/mesh-config.yaml index 138bc260..6f6e4587 100644 --- a/development/kind/mesh-config.yaml +++ b/development/kind/mesh-config.yaml @@ -1,5 +1,11 @@ # node -> service. A blank value means a bare node (no service, e.g. a caller). -# The service value is a folder name under development/examples/. +# Set a value to host a service on that node; the value is a folder path under +# development/examples/, e.g: +# node-b: calc-mcp +# node-c: code-reviewer-pool/reviewer +# All nodes ship bare by default — assign services to suit what you're testing. node-a: -node-b: calc-mcp -node-c: greeter-mcp +node-b: +node-c: +node-d: +node-e: diff --git a/development/kind/run.sh b/development/kind/run.sh index 2cb61e89..a4fa3413 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -31,16 +31,17 @@ render_and_apply() { local CONFIG_ARG="" CONFIG_MOUNT="" SIDECAR="" CONFIG_VOLUME="" if [[ -n "$svc" ]]; then local dir="${PROJECT_ROOT}/development/examples/${svc}" + local name="$(basename "$svc")" [[ -d "$dir" ]] || { echo "service '${svc}' (node ${node}) not found in development/examples/" >&2; exit 1; } - echo "Building service image ${svc}:${IMAGE_TAG}…" - docker build -t "${svc}:${IMAGE_TAG}" "$dir" - kind load docker-image --name "${CLUSTER}" "${svc}:${IMAGE_TAG}" + echo "Building service image ${name}:${IMAGE_TAG}…" + docker build -t "${name}:${IMAGE_TAG}" "$dir" + kind load docker-image --name "${CLUSTER}" "${name}:${IMAGE_TAG}" kubectl --context "${KCTX}" -n "${NAMESPACE}" create configmap "${node}-config" \ --from-file=sam-node.yaml="${dir}/sam-node-config.yaml" \ --dry-run=client -o yaml | kubectl --context "${KCTX}" apply -f - CONFIG_ARG=' - "--config=/etc/sam/sam-node.yaml"' CONFIG_MOUNT=$' - name: config\n mountPath: /etc/sam' - SIDECAR=$' - name: '"${svc}"$'\n image: '"${svc}:${IMAGE_TAG}"$'\n imagePullPolicy: IfNotPresent' + SIDECAR=$' - name: '"${name}"$'\n image: '"${name}:${IMAGE_TAG}"$'\n imagePullPolicy: IfNotPresent' CONFIG_VOLUME=$' - name: config\n configMap:\n name: '"${node}-config" fi NODE="$node" CONFIG_ARG="$CONFIG_ARG" CONFIG_MOUNT="$CONFIG_MOUNT" SIDECAR="$SIDECAR" CONFIG_VOLUME="$CONFIG_VOLUME" \ @@ -77,10 +78,11 @@ show_cluster_logs() { tmuxs attach-session -t "${SESSION}" } -# Read the node -> service assignment from mesh-config.yaml into NODE_LINES -# (each line: " ") and the NODES array. +# Read the node -> service assignment into NODE_LINES (each line: +# " ") and the NODES array. Defaults to mesh-config.yaml; +# override with MESH_CONFIG (e.g. the e2e lane pins calc-mcp via mesh-config.e2e.yaml). read_mesh_nodes() { - mapfile -t NODE_LINES < <(awk -F: '/^[A-Za-z0-9_-]+:/{n=$1; s=$2; gsub(/[[:space:]]/,"",n); gsub(/[[:space:]]/,"",s); print n, s}' "${SCRIPT_DIR}/mesh-config.yaml") + mapfile -t NODE_LINES < <(awk -F: '/^[A-Za-z0-9_-]+:/{n=$1; s=$2; gsub(/[[:space:]]/,"",n); gsub(/[[:space:]]/,"",s); print n, s}' "${MESH_CONFIG:-${SCRIPT_DIR}/mesh-config.yaml}") NODES=() for line in "${NODE_LINES[@]}"; do NODES+=("${line%% *}"); done } diff --git a/site/content/docs/development/kubernetes-deployment.md b/site/content/docs/development/kubernetes-deployment.md index 885c3322..23815dbb 100644 --- a/site/content/docs/development/kubernetes-deployment.md +++ b/site/content/docs/development/kubernetes-deployment.md @@ -19,10 +19,10 @@ The repository ships a one-command local mesh under `development/kind/`, driven make kind-up ``` -This creates a `sam-kind` cluster (one control-plane plus workers for the hub and `node-a`, `node-b`, `node-c`), builds the `sam-hub:local` and `sam-node:local` images, loads them into the cluster, and deploys: +This creates a `sam-kind` cluster (one control-plane plus workers for the hub and `node-a` through `node-e`), builds the `sam-hub:local` and `sam-node:local` images, loads them into the cluster, and deploys: - The **hub**, configured to trust the cluster's own OIDC issuer. -- Three **nodes** declared in `development/kind/mesh-config.yaml`: `node-a` (bare), `node-b` (hosts the `calc-mcp` example service), and `node-c` (hosts the `greeter-mcp` example service). +- Five **nodes** declared in `development/kind/mesh-config.yaml` (`node-a` through `node-e`), all **bare** by default — assign services to suit what you're testing. Nodes authenticate to the hub via **Workload Identity Federation** (projected ServiceAccount tokens), so no static secrets or mock OIDC provider are needed. The hub is exposed to the host on `127.0.0.1:9090` (HTTP enroll) and `127.0.0.1:4001` (libp2p) via a NodePort and the cluster's `extraPortMappings` — `cloud-provider-kind` is not required. @@ -40,13 +40,17 @@ The nodes that make up the dev mesh are declared in `development/kind/mesh-confi ```yaml # node -> service. A blank value means a bare node (no service, e.g. a caller). -# The service value is a folder name under development/examples/. +# The service value is a folder path under development/examples/, e.g: +# node-b: calc-mcp +# node-c: code-reviewer-pool/reviewer node-a: -node-b: calc-mcp -node-c: greeter-mcp +node-b: +node-c: +node-d: +node-e: ``` -- The key is the node's name. The cluster currently ships with a hub plus these **three** agent nodes; each is pinned to a matching worker via the `sam-role` labels in `kind-config.yaml`. +- The key is the node's name. The cluster currently ships with a hub plus these **five** agent nodes, all bare by default; each is pinned to a matching worker via the `sam-role` labels in `kind-config.yaml`. - A **blank** value is a bare node — a `sam-node` with no local service, useful as a caller/consumer. - A **non-blank** value is a folder name under `development/examples/`. That service is built and deployed as a **sidecar** next to the node, and the node is configured to advertise it to the mesh. @@ -54,7 +58,7 @@ When a node has a service, `make kind-up` builds the service image from its `Doc ### Adding and Testing a New Service -A service is any backend a node advertises to the mesh. Its kind is set by the `type` field in `sam-node-config.yaml`. SAM currently supports `mcp` (an MCP server) and `inference` (an LLM inference endpoint). The repository ships example MCP services under `development/examples/` (`calc-mcp`, `greeter-mcp`, `code-reviewer-mcp`, and `everything-mcp`) which are the easiest starting point. Using `calc-mcp` as a template: +A service is any backend a node advertises to the mesh. Its kind is set by the `type` field in `sam-node-config.yaml`. SAM currently supports `mcp` (an MCP server) and `inference` (an LLM inference endpoint). The repository ships example MCP services under `development/examples/` (`calc-mcp`, `greeter-mcp`, `code-reviewer-pool/reviewer`, and `everything-mcp`) which are the easiest starting point. Using `calc-mcp` as a template: 1. **Create the service folder** `development/examples/my-mcp/` with: - The service backend (e.g. `my_server.py`) listening on a local port, plus a `Dockerfile` and any `requirements.txt`. @@ -71,14 +75,12 @@ A service is any backend a node advertises to the mesh. Its kind is set by the ` ``` The sidecar and `sam-node` share the pod's network, so `target_url` is always `127.0.0.1:`, where `` matches the port your service listens on. -2. **Assign it to a node** in `mesh-config.yaml` — replace an existing mapping or use a free node slot (`node-a`, `node-b`, `node-c`): +2. **Assign it to a node** in `mesh-config.yaml` — set the value on any free node slot (`node-a` through `node-e`): ```yaml node-a: my-mcp - node-b: calc-mcp - node-c: greeter-mcp ``` > [!NOTE] - > There are three node slots because `kind-config.yaml` defines three workers labeled `sam-role: node-a|node-b|node-c`. To host more than three services at once, add a matching labeled worker there too. + > There are five node slots because `kind-config.yaml` defines five workers labeled `sam-role: node-a|node-b|node-c|node-d|node-e`. To host more than five services at once, add a matching labeled worker there too. 3. **Recreate the cluster** so the new service is built and deployed: ```bash @@ -122,7 +124,14 @@ To verify the full discovery-and-call path against a freshly built mesh: make kind-e2e-mesh ``` -This enrolls a local node, waits for it to discover `mcp://calculator/add` (hosted by `node-b`), calls `add(2, 3)`, and asserts the result is `5`. +This enrolls a local node, waits for it to discover `mcp://calculator/add`, calls `add(2, 3)`, and asserts the result is `5`. Because nodes ship bare by default, the check needs a `calc-mcp` service on the mesh — bring the mesh up with the bundled e2e layout, which pins it: + +```bash +MESH_CONFIG=development/kind/mesh-config.e2e.yaml make kind-up ARGS="-s" +make kind-e2e-mesh +``` + +`MESH_CONFIG` overrides which layout `make kind-up` deploys (default: `mesh-config.yaml`); `mesh-config.e2e.yaml` assigns `calc-mcp` to `node-b`. --- diff --git a/site/content/docs/use-cases/_index.md b/site/content/docs/use-cases/_index.md new file mode 100644 index 00000000..e0aef097 --- /dev/null +++ b/site/content/docs/use-cases/_index.md @@ -0,0 +1,17 @@ +--- +title: "Use Cases" +linkTitle: "Use Cases" +weight: 5 +--- + +Worked examples of what you can build **on top of the mesh** — patterns that +combine ordinary `sam-node` MCP services (discovery, remote tool calls, leasing) +into something larger, with no changes to the node itself. + +Each use case is harness-agnostic: the orchestrator is any MCP client — an agent +harness (Claude Code, Codex, Antigravity, …) or a custom program — talking to the +mesh MCP tools exposed by a local node. + +The runnable source for every example lives under +[`development/examples/`](https://github.com/google/sam/tree/main/development/examples) +in the repository; each page here links to its example directory. diff --git a/site/content/docs/use-cases/warm-agent-pool.md b/site/content/docs/use-cases/warm-agent-pool.md new file mode 100644 index 00000000..3c423bde --- /dev/null +++ b/site/content/docs/use-cases/warm-agent-pool.md @@ -0,0 +1,201 @@ +--- +title: "Warm Agent Pool" +linkTitle: "Warm Agent Pool" +weight: 10 +--- + +Fan a batch of work across a pool of identical, already-running worker agents — +built entirely from ordinary mesh MCP services, no gossip and no node changes. + +Source: [`development/examples/code-reviewer-pool/`](https://github.com/google/sam/tree/main/development/examples/code-reviewer-pool). + +## The idea + +Some agent tools are expensive to stand up but cheap to reuse — a reviewer that +shells out to an LLM, a sandbox that boots a runtime, a service holding a warm +model in memory. You don't want to spawn one per request, and you don't want a +single instance serialising all your work. What you want is a **pool of +identical, already-running workers** and something that hands them out one job at +a time. + +This use case builds exactly that on the mesh, using **nothing but ordinary MCP +services**. The worked example is a *code reviewer*: several identical reviewer +workers, a manager that leases them, and an orchestrator that fans a batch of +files across the pool in parallel. + +## The pieces + +- **Workers** — plain `code-reviewer` MCP services. Each exposes a single + `review_code` tool that pipes the snippet to an LLM and returns comments + grouped by severity. They're stateless and interchangeable; the pool's job is + to keep them busy. +- **Manager** — a normal MCP service exposing `acquire_worker` / + `release_worker` / `list_workers`. It's also a *northbound MCP client of its + own node*: every few seconds it calls `find_remote_tools(code-reviewer)` to + learn which worker peers exist (via the DHT), and it tracks which of them are + free or busy using **leases**. +- **Orchestrator** — any mesh MCP client (an agent harness or a custom program). + The loop per file is: `acquire_worker` → `call_remote_tool(peer, review_code)` + → `release_worker`. Run those chains concurrently and each acquire hands back a + *different* free worker, so parallel dispatch never collides. + +## Why there's no readiness broadcast + +A classic worker pool needs to know two things: *who exists* and *who's busy*. +The manager gets the first from **DHT discovery** and the second from **its own +leases** — the exact two facts a gossip/readiness broadcast would otherwise +provide. So the whole thing runs on discovery + leasing, no pub/sub. The +tradeoff: leasing is authoritative only while the manager is the *sole* +dispatcher — perfect for a single-manager pool, and the point at which you'd +reach for real coordination if you needed multiple managers. + +## What makes it correct under concurrency + +The interesting part is that "hand out a warm worker, one job at a time" stays +true even when acquires race and workers come and go: + +- **No double-lease** — the manager is single-threaded and `leaseFree()` is fully + synchronous (no `await` between picking a worker and marking it busy), so two + concurrent `acquire_worker` calls can never be handed the same peer. +- **Grace eviction** — discovery never drops a *leased* worker on a transient + miss, and only drops a *free* one after `SAM_GRACE_MISSES` consecutive misses. + A slow, busy worker won't get evicted and re-handed-out mid-review. +- **Fencing tokens** — `acquire_worker` returns a `lease_id`; `release_worker` + only clears the lease if that id still matches, so a late release from an + expired lease can't free a *newer* holder's worker. +- **Single-flight backstop** — even if a lease race ever slipped through, the + worker itself returns `POOL_BUSY` for a second concurrent `review_code`, so the + one-at-a-time invariant holds at the source. + +## Lease enforcement (workers trust the manager, not the caller) + +Workers don't hand out reviews to anyone who can reach them. On `acquire_worker` +the manager **mints a short-lived HMAC token** bound to that worker and lease +expiry; the orchestrator forwards it in the `review_code` arguments, and the +worker **verifies it offline** (shared secret, no call back to the manager). Any +`review_code` without a valid, unexpired token gets `NO_LEASE`. Both sides +default to a hardcoded dev secret (`sam-dev-pool-secret`) so it enforces out of +the box; set a matching `SAM_POOL_SECRET` on the manager and every worker to +override it. Mismatched or one-sided secrets **fail closed** — every call returns +`NO_LEASE`. + +## What you can do with it + +- **Parallel batch work** — fan a directory of files (or tasks) across N warm + workers and collect results as they land, bounded by pool size rather than + serialised. +- **Elastic capacity** — scale a worker deployment up or down mid-job; the + manager picks a new worker up on its next discovery pass and starts leasing it, + and drains one that disappears without corrupting in-flight leases. +- **A reusable pattern** — swap `code-reviewer` for any expensive-to-warm tool + (test runner, sandbox, embedder, browser). The manager is generic; it pools + whatever `SAM_POOL_SERVICE` names. + +## Try it on kind + +The repository ships a [kind](https://kind.sigs.k8s.io/)-based local mesh that +brings up the whole pool with one command. + +### 1. Set an LLM key for the reviewer image + +The reviewer workers shell out to an LLM, so set your API key on the API-key +`ENV` line in `development/examples/code-reviewer-pool/reviewer/Dockerfile` +before building (a free key is fine for the demo). + +### 2. Mesh layout + +The layout ships in `development/kind/mesh-config.yaml`: + +```yaml +node-a: # bare node (orchestrator entry) +node-b: code-reviewer-pool/reviewer # worker +node-c: code-reviewer-pool/reviewer # worker +node-d: code-reviewer-pool/reviewer # worker +node-e: code-reviewer-pool/manager # manager +``` + +### 3. Bring the mesh up and start a local orchestrator node + +```bash +make build # builds ./bin/sam-node (once) +make kind-up # hub + reviewer pool (node-b/c/d) + manager (node-e) +make kind-local-node # local sam-node enrolled in the mesh — LEAVE RUNNING +``` + +`kind-local-node` runs in the foreground in its own shell and exposes the mesh +MCP tools at **`http://127.0.0.1:9099/mcp`** (bearer token `devtoken`) — no +`kubectl port-forward` needed. This local node is your orchestrator's entry +point into the mesh. + +### 4. Point your harness at the local node + +Add the local node as an MCP server in whatever harness you drive the mesh from. +The specifics differ per harness (some use a JSON/TOML config file, others a UI), +but the settings are always the same: + +- **Transport:** HTTP (Streamable HTTP / `http`) +- **URL:** `http://127.0.0.1:9099/mcp` +- **Header:** `Authorization: Bearer devtoken` + +For example, harnesses that use the common `mcpServers` JSON config (Claude Code, +Cursor, and others) would add: + +```json +{ + "mcpServers": { + "sam-mesh": { + "type": "http", + "url": "http://127.0.0.1:9099/mcp", + "headers": { "Authorization": "Bearer devtoken" } + } + } +} +``` + +Consult your harness's MCP documentation for its exact config format. Once +connected, the mesh exposes `acquire_worker`, `release_worker`, `list_workers`, +`find_remote_tools`, and `call_remote_tool` as tools your agent (or program) can +call. + +### 5. Drive the pool + +Have your orchestrator run, per file, in parallel: + +1. `acquire_worker` → returns `{peer_id, tool, lease_id, token}`. +2. `call_remote_tool(peer_id, review_code, {code, token})` — forward the `token` + in the tool arguments (the pool requires it). +3. `release_worker(peer_id, lease_id)` — pass the `lease_id` back so a stale + release can't free a newer holder. + +A natural prompt for an agent harness: + +> Using the `sam-mesh` tools: for each file in +> `development/examples/code-reviewer-pool/samples/`, acquire a worker, call its +> `review_code` with the file contents (forwarding the token), and release it +> (pass the `lease_id` back). Run them in parallel. + +You'll watch the reviews come back concurrently, bounded by the number of workers +in the pool. + +### 6. Elasticity beat (add a worker mid-job) + +Scale a reviewer down, start a larger job, then scale it back up — the manager +picks the new worker up on its next discovery pass and starts leasing it: + +```bash +kubectl --context kind-sam-kind -n sam-kind scale deploy/node-d --replicas=0 +# start a big job, then: +kubectl --context kind-sam-kind -n sam-kind scale deploy/node-d --replicas=1 +``` + +## Configuration + +| var | default | used by | +|-----|---------|---------| +| `SAM_NODE_URL` | `http://127.0.0.1:8080/mcp` | manager | +| `SAM_API_TOKEN` | `devtoken` | manager | +| `SAM_POOL_SERVICE` | `code-reviewer` | manager | +| `SAM_DISCOVERY_MS` | `3000` | manager | +| `SAM_LEASE_MS` | `60000` | manager | +| `SAM_GRACE_MISSES` | `2` | manager | +| `SAM_POOL_SECRET` | `sam-dev-pool-secret` (enforcement always on) | manager + reviewer |