Files
sistem-desa-mandiri/.mcp/deploy-stg/server.ts

450 lines
14 KiB
TypeScript

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { execFileSync } from "child_process";
import { readFileSync, writeFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, "../..");
const STACK_ENV = "stg";
const BASE_URL = process.env.BASE_URL ?? "";
const VERSION_PATH = process.env.VERSION_PATH ?? "/api/version-app";
const DEFAULT_STACK_NAME = process.env.STACK_NAME ?? "";
const GH_TOKEN = process.env.GH_TOKEN ?? "";
const GH_URL_RAW = process.env.GH_URL ?? "";
// support both "owner/repo" and "https://github.com/owner/repo" formats
const REPO = GH_URL_RAW.startsWith("http")
? GH_URL_RAW.replace(/^https?:\/\/[^/]+\//, "").replace(/\.git$/, "")
: GH_URL_RAW;
const GIT = (args: string[]) =>
execFileSync("git", args, { encoding: "utf-8", cwd: PROJECT_ROOT }).trim();
// --- GitHub API client (no gh CLI) ---
const GH_API = "https://api.github.com";
type WorkflowRun = {
id: number;
name: string;
status: string;
conclusion: string | null;
html_url: string;
created_at: string;
run_started_at: string;
};
async function ghFetch<T = unknown>(
pathname: string,
init: RequestInit = {}
): Promise<T> {
if (!GH_TOKEN) throw new Error("GH_TOKEN tidak di-set.");
if (!REPO) throw new Error("GH_URL tidak di-set.");
const res = await fetch(`${GH_API}${pathname}`, {
...init,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${GH_TOKEN}`,
"X-GitHub-Api-Version": "2022-11-28",
...(init.body ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`);
}
// 204 No Content (e.g. workflow dispatch)
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
async function triggerWorkflow(
workflow: string,
ref: string,
inputs: Record<string, string>
): Promise<void> {
await ghFetch(`/repos/${REPO}/actions/workflows/${workflow}/dispatches`, {
method: "POST",
body: JSON.stringify({ ref, inputs }),
});
}
async function getLatestRun(workflow: string): Promise<WorkflowRun> {
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(
`/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=1`
);
if (!data.workflow_runs?.length) {
throw new Error(`Tidak ada run untuk workflow ${workflow}.`);
}
return data.workflow_runs[0];
}
async function listRuns(
workflow: string | "all",
limit: number
): Promise<WorkflowRun[]> {
const url =
workflow === "all"
? `/repos/${REPO}/actions/runs?per_page=${limit}`
: `/repos/${REPO}/actions/workflows/${workflow}/runs?per_page=${limit}`;
const data = await ghFetch<{ workflow_runs: WorkflowRun[] }>(url);
return data.workflow_runs ?? [];
}
async function waitForRun(
runId: number,
timeoutMs = 30 * 60 * 1000
): Promise<WorkflowRun> {
const interval = 10_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await ghFetch<WorkflowRun>(
`/repos/${REPO}/actions/runs/${runId}`
);
if (run.status === "completed") {
if (run.conclusion !== "success") {
throw new Error(
`Run ${runId} selesai dengan conclusion: ${run.conclusion}`
);
}
return run;
}
await new Promise((r) => setTimeout(r, interval));
}
throw new Error(`Timeout menunggu run ${runId}.`);
}
// --- version helpers ---
function bumpVersion(version: string, type: "patch" | "minor" | "major"): string {
const [maj, min, pat] = version.split(".").map(Number);
if (type === "major") return `${maj + 1}.0.0`;
if (type === "minor") return `${maj}.${min + 1}.0`;
return `${maj}.${min}.${pat + 1}`;
}
function readPkgVersion(): string {
const pkg = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf-8"));
return pkg.version as string;
}
function applyVersionBump(newVersion: string): void {
const pkgPath = path.join(PROJECT_ROOT, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
pkg.version = newVersion;
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
// --- deployed version check ---
async function waitForDeployedVersion(expected: string, timeoutMs = 5 * 60 * 1000): Promise<string> {
if (!BASE_URL) return "BASE_URL tidak di-set, skip cek versi stg.";
const url = `${BASE_URL}${VERSION_PATH}`;
const interval = 15_000;
const maxAttempts = Math.ceil(timeoutMs / interval);
let last = "";
for (let i = 1; i <= maxAttempts; i++) {
await new Promise((r) => setTimeout(r, interval));
try {
const res = await fetch(url);
const data = (await res.json()) as { version?: string };
last = data.version ?? "?";
if (last === expected) {
return `Versi terverifikasi di stg: ${last}`;
}
} catch {
last = "error fetch";
}
}
return `Timeout: versi stg masih ${last}, expected ${expected}`;
}
// --- MCP server ---
const server = new Server(
{ name: "deploy-stg", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "deploy",
description:
"Full deploy ke stg: bump version, commit, push ke build remote, publish Docker image, tunggu selesai, repull Portainer, verifikasi versi.",
inputSchema: {
type: "object",
properties: {
stack_name: {
type: "string",
description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.",
},
bump: {
type: "string",
enum: ["patch", "minor", "major"],
description: "Jenis bump versi (default: patch)",
default: "patch",
},
},
required: [],
},
},
{
name: "publish",
description:
"Trigger workflow publish.yml: build & push Docker image ke GHCR (selalu stg, tag dari package.json). Kembalikan URL run.",
inputSchema: { type: "object", properties: {}, required: [] },
},
{
name: "repull",
description:
"Trigger workflow re-pull.yml: redeploy stack di Portainer stg dengan pull image terbaru. Kembalikan URL run.",
inputSchema: {
type: "object",
properties: {
stack_name: {
type: "string",
description: "Nama stack Portainer. Jika tidak diisi, pakai env STACK_NAME.",
},
},
required: [],
},
},
{
name: "run_status",
description:
"Cek status GitHub Actions run terbaru untuk workflow tertentu, atau semua workflow.",
inputSchema: {
type: "object",
properties: {
workflow: {
type: "string",
enum: ["publish.yml", "re-pull.yml", "all"],
description: "Nama workflow file atau 'all' untuk semua (default: all)",
default: "all",
},
limit: {
type: "number",
description: "Jumlah run yang ditampilkan (default 5)",
default: 5,
},
},
required: [],
},
},
{
name: "check_version",
description:
"Bandingkan versi lokal (package.json) dengan versi yang berjalan di stg (/api/version-app).",
inputSchema: { type: "object", properties: {}, required: [] },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
// ── deploy ─────────────────────────────────────────────────────────────
if (name === "deploy") {
const { stack_name: _sn, bump = "patch" } = (args ?? {}) as {
stack_name?: string;
bump?: "patch" | "minor" | "major";
};
const stack_name = _sn || DEFAULT_STACK_NAME;
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
// 0. Cek migrasi — buat otomatis jika schema ada perubahan
let migrationCreated = false;
try {
execFileSync(
"./node_modules/.bin/prisma",
["migrate", "diff", "--from-migrations", "prisma/migrations", "--to-schema-datamodel", "prisma/schema.prisma", "--exit-code"],
{ encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" }
);
} catch {
// Ada schema diff — buat migration otomatis
execFileSync(
"./node_modules/.bin/prisma",
["migrate", "dev", "--create-only", "--name", "auto"],
{ encoding: "utf-8", cwd: PROJECT_ROOT, stdio: "pipe" }
);
migrationCreated = true;
}
const oldVersion = readPkgVersion();
const newVersion = bumpVersion(oldVersion, bump);
// 1. Bump version in package.json
applyVersionBump(newVersion);
// 2. Commit (version bump + migration jika ada)
GIT(["add", "package.json", "prisma/migrations"]);
GIT(["commit", "-m", migrationCreated
? `bump: version ${newVersion} + migration`
: `bump: version ${newVersion}`
]);
// 3. Push to build remote (GitHub)
const currentBranch = GIT(["rev-parse", "--abbrev-ref", "HEAD"]);
GIT(["push", "build", `${currentBranch}:stg`, "--force"]);
// 4. Trigger publish workflow
await triggerWorkflow("publish.yml", STACK_ENV, {
stack_env: STACK_ENV,
tag: newVersion,
});
await new Promise((r) => setTimeout(r, 4000));
const publishRun = await getLatestRun("publish.yml");
// 5. Wait for publish to finish
await waitForRun(publishRun.id);
// 6. Trigger repull
await triggerWorkflow("re-pull.yml", STACK_ENV, {
stack_name,
stack_env: STACK_ENV,
});
await new Promise((r) => setTimeout(r, 4000));
const repullRun = await getLatestRun("re-pull.yml");
// 7. Wait for repull, then verify version
await new Promise((r) => setTimeout(r, 30_000));
const versionCheck = await waitForDeployedVersion(newVersion);
const localVer = readPkgVersion();
return {
content: [
{
type: "text",
text: [
`Deploy selesai: ${stack_name}-${STACK_ENV} @ ${newVersion} (dari ${oldVersion})`,
`Publish run : ${publishRun.html_url}`,
`Repull run : ${repullRun.html_url}`,
``,
`Versi lokal : ${localVer}`,
versionCheck,
].join("\n"),
},
],
};
}
// ── publish ────────────────────────────────────────────────────────────
if (name === "publish") {
const tag = readPkgVersion();
await triggerWorkflow("publish.yml", STACK_ENV, {
stack_env: STACK_ENV,
tag,
});
await new Promise((r) => setTimeout(r, 3000));
const run = await getLatestRun("publish.yml");
return {
content: [{ type: "text", text: `Publish triggered: ${STACK_ENV}-${tag}\nRun: ${run.html_url}` }],
};
}
// ── repull ─────────────────────────────────────────────────────────────
if (name === "repull") {
const { stack_name: _sn } = (args ?? {}) as { stack_name?: string };
const stack_name = _sn || DEFAULT_STACK_NAME;
if (!stack_name) throw new Error("stack_name tidak diisi dan env STACK_NAME kosong.");
await triggerWorkflow("re-pull.yml", STACK_ENV, {
stack_name,
stack_env: STACK_ENV,
});
await new Promise((r) => setTimeout(r, 3000));
const run = await getLatestRun("re-pull.yml");
return {
content: [{ type: "text", text: `Repull triggered: ${stack_name}-${STACK_ENV}\nRun: ${run.html_url}` }],
};
}
// ── run_status ─────────────────────────────────────────────────────────
if (name === "run_status") {
const { workflow = "all", limit = 5 } = (args ?? {}) as {
workflow?: string;
limit?: number;
};
const runs = await listRuns(workflow, limit);
const output = runs
.map(
(r) =>
`[${r.status}/${r.conclusion ?? "-"}] ${r.name}${r.run_started_at}\n ${r.html_url}`
)
.join("\n");
return {
content: [{ type: "text", text: output || "Tidak ada run ditemukan." }],
};
}
// ── check_version ──────────────────────────────────────────────────────
if (name === "check_version") {
const localVersion = readPkgVersion();
let stgVersion = "tidak dapat dijangkau";
if (BASE_URL) {
try {
const res = await fetch(`${BASE_URL}${VERSION_PATH}`);
const data = (await res.json()) as { version?: string };
stgVersion = data.version ?? "?";
} catch (e) {
stgVersion = `error: ${(e as Error).message}`;
}
} else {
stgVersion = "BASE_URL tidak di-set";
}
const match = localVersion === stgVersion ? "✓ sama" : "✗ beda";
return {
content: [
{
type: "text",
text: [
`Lokal (package.json) : ${localVersion}`,
`Stg (${VERSION_PATH}): ${stgVersion}`,
`Status : ${match}`,
].join("\n"),
},
],
};
}
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text", text: `Error: ${msg}` }], isError: true };
}
});
const transport = new StdioServerTransport();
await server.connect(transport);