391 lines
13 KiB
Go
391 lines
13 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
|
|
"forge-tools-proxmox/internal/pve"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// vm.go — QEMU-ВМ: чтение (list/describe/config/nextid) и lifecycle
|
|
// (start/stop/reboot/shutdown/suspend/resume) + clone/delete/convert-template.
|
|
// Мутации за гейтом (read_only + allowlist) и registerPatternTool (probe →
|
|
// require_approval). Удаление/конвертация — дополнительно confirm.
|
|
//
|
|
// ВАЖНО (анти-дубликат): запуск ВМ НЕ через guest/консоль — это зона ssh__run.
|
|
// Здесь только управление гостевой сущностью гипервизора.
|
|
|
|
// vmType — тип гостя в PVE API ("qemu"|"lxc"). Константа для единообразия.
|
|
const vmTypeQEMU = "qemu"
|
|
|
|
func registerVMTools(s *mcp.Server) {
|
|
// --- read ---
|
|
s.AddTool(&mcp.Tool{
|
|
Name: "vms_list",
|
|
Description: "List QEMU VMs across the cluster (status, resources). Read-only.",
|
|
InputSchema: schema(map[string]any{
|
|
"host": strProps("Cluster alias (default: primary)", false),
|
|
}, nil),
|
|
}, vmsListHandler)
|
|
|
|
s.AddTool(&mcp.Tool{
|
|
Name: "vm_describe",
|
|
Description: "Describe one VM: current status + config (cores, memory, disks, network). Read-only.",
|
|
InputSchema: schema(map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("VM ID", true),
|
|
"host": strProps("Cluster alias (default: primary)", false),
|
|
}, []string{"node", "vmid"}),
|
|
}, vmDescribeHandler)
|
|
|
|
s.AddTool(&mcp.Tool{
|
|
Name: "vm_config",
|
|
Description: "Get the raw QEMU config of a VM (qm config). Read-only.",
|
|
InputSchema: schema(map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("VM ID", true),
|
|
"host": strProps("Cluster alias (default: primary)", false),
|
|
}, []string{"node", "vmid"}),
|
|
}, vmConfigHandler)
|
|
|
|
s.AddTool(&mcp.Tool{
|
|
Name: "vm_next_id",
|
|
Description: "Get the next free VMID in the cluster. Read-only.",
|
|
InputSchema: schema(map[string]any{
|
|
"host": strProps("Cluster alias (default: primary)", false),
|
|
}, nil),
|
|
}, vmNextIDHandler)
|
|
|
|
// --- lifecycle (мутации ⇒ write gate + probe/approval) ---
|
|
s.AddTool(&mcp.Tool{
|
|
Name: "vm_start",
|
|
Description: "Start a VM. Requires write permission (allowlist + approve).",
|
|
InputSchema: schema(map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("VM ID", true),
|
|
}, []string{"node", "vmid"}),
|
|
}, vmStartHandler)
|
|
|
|
for _, a := range []struct {
|
|
name string
|
|
description string
|
|
confirm bool
|
|
handler mcp.ToolHandler
|
|
}{
|
|
{"vm_stop", "Force-stop a VM. Requires confirm + write permission.", true, vmStopHandler},
|
|
{"vm_reboot", "Reboot a VM. Requires confirm + write permission.", true, vmRebootHandler},
|
|
{"vm_shutdown", "Gracefully shut down a VM (guest agent / ACPI). Requires confirm + write permission.", true, vmShutdownHandler},
|
|
{"vm_suspend", "Suspend (pause) a VM. Requires write permission.", false, vmSuspendHandler},
|
|
{"vm_resume", "Resume a VM. Requires write permission.", false, vmResumeHandler},
|
|
} {
|
|
props := map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("VM ID", true),
|
|
}
|
|
required := []string{"node", "vmid"}
|
|
if a.confirm {
|
|
props["confirm"] = strProps("Set to \"true\" to confirm this destructive/lifecycle action", true, "true")
|
|
required = append(required, "confirm")
|
|
}
|
|
registerPatternTool(s, &mcp.Tool{
|
|
Name: a.name,
|
|
Description: a.description,
|
|
InputSchema: schema(props, required),
|
|
}, vmPatterns, a.handler)
|
|
}
|
|
|
|
// clone / delete / template
|
|
registerPatternTool(s, &mcp.Tool{
|
|
Name: "vm_clone",
|
|
Description: "Clone a VM (template) into a new VMID. If a template, clones it. Requires write permission.",
|
|
InputSchema: schema(map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("Source VM/template ID", true),
|
|
"newid": intProps("New VMID (0 = next free)", false),
|
|
"name": strProps("Name for the clone", false),
|
|
"full": boolProps("Full clone (independent copy metadata). Default: depends on template", false),
|
|
"confirm": strProps("Set to \"true\" to confirm creating a new guest", true, "true"),
|
|
}, []string{"node", "vmid", "confirm"}),
|
|
}, vmPatterns, vmCloneHandler)
|
|
|
|
registerPatternTool(s, &mcp.Tool{
|
|
Name: "vm_delete",
|
|
Description: "Permanently delete a VM. Requires confirm + write permission.",
|
|
InputSchema: schema(map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("VM ID", true),
|
|
"purge": boolProps("Also remove from backup jobs/HA/DR (default: true)", false),
|
|
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
|
|
"force": boolProps("Force even if protected", false),
|
|
}, []string{"node", "vmid", "confirm"}),
|
|
}, vmPatterns, vmDeleteHandler)
|
|
|
|
registerPatternTool(s, &mcp.Tool{
|
|
Name: "vm_convert_template",
|
|
Description: "Convert a VM into a template. Requires confirm + write permission.",
|
|
InputSchema: schema(map[string]any{
|
|
"node": strProps("Node name", true),
|
|
"vmid": strProps("VM ID", true),
|
|
"confirm": strProps("Set to \"true\" to confirm conversion", true, "true"),
|
|
}, []string{"node", "vmid", "confirm"}),
|
|
}, vmPatterns, vmConvertTemplateHandler)
|
|
}
|
|
|
|
// --- read handlers ---
|
|
|
|
func vmsListHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
host, err := resolveHost(t, requestArgs(req))
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
cctx, cancel := timeout(ctx, t)
|
|
defer cancel()
|
|
data, err := t.GuestResources(cctx, host, vmTypeQEMU)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(pretty(data)), nil
|
|
}
|
|
|
|
func vmDescribeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
args := requestArgs(req)
|
|
node, vmid, host, r := resolveVMArgs(t, args)
|
|
if r != nil {
|
|
return r, nil
|
|
}
|
|
cctx, cancel := timeout(ctx, t)
|
|
defer cancel()
|
|
status, err := t.VMStatus(cctx, host, node, vmid)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
cfg, err := t.VMConfig(cctx, host, node, vmid)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult("=== Status ===\n" + pretty(status) + "\n\n=== Config ===\n" + pretty(cfg)), nil
|
|
}
|
|
|
|
func vmConfigHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
args := requestArgs(req)
|
|
node, vmid, host, r := resolveVMArgs(t, args)
|
|
if r != nil {
|
|
return r, nil
|
|
}
|
|
cctx, cancel := timeout(ctx, t)
|
|
defer cancel()
|
|
data, err := t.VMConfig(cctx, host, node, vmid)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(pretty(data)), nil
|
|
}
|
|
|
|
func vmNextIDHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
host, err := resolveHost(t, requestArgs(req))
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
cctx, cancel := timeout(ctx, t)
|
|
defer cancel()
|
|
data, err := t.NextID(cctx, host)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(pretty(data)), nil
|
|
}
|
|
|
|
// --- lifecycle handlers ---
|
|
|
|
func vmStartHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return vmActionHandler(ctx, req, "start", false)
|
|
}
|
|
func vmStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return vmActionHandler(ctx, req, "stop", true)
|
|
}
|
|
func vmRebootHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return vmActionHandler(ctx, req, "reboot", true)
|
|
}
|
|
func vmShutdownHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return vmActionHandler(ctx, req, "shutdown", true)
|
|
}
|
|
func vmSuspendHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return vmActionHandler(ctx, req, "suspend", false)
|
|
}
|
|
func vmResumeHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
return vmActionHandler(ctx, req, "resume", false)
|
|
}
|
|
|
|
// vmActionHandler — общий обработчик lifecycle: gateVMWrite + (confirm) +
|
|
// POST /status/<action> → UPID.
|
|
func vmActionHandler(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
args := requestArgs(req)
|
|
node, vmid, host, r := resolveVMArgs(t, args)
|
|
if r != nil {
|
|
return r, nil
|
|
}
|
|
if needConfirm {
|
|
if err := confirm(args, "vm_"+action); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
}
|
|
if err := gateVMWrite(t, host, vmid); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
upid, err := t.VMAction(ctx, host, node, vmid, action, nil)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(upidMsg("vm_"+action, fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
|
}
|
|
|
|
func vmCloneHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
args := requestArgs(req)
|
|
node, src, host, r := resolveVMArgs(t, args)
|
|
if r != nil {
|
|
return r, nil
|
|
}
|
|
if err := confirm(args, "vm_clone"); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
// newid: 0 = next free; при клоне всегда гейт по allowlist на источник.
|
|
if err := gateVMWrite(t, host, src); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
newID := getInt(args, "newid", 0)
|
|
if newID == 0 {
|
|
nid, err := t.NextID(ctx, host)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
newID = intFromData(nid)
|
|
}
|
|
form := url.Values{"newid": {strconv.Itoa(newID)}}
|
|
if name := getString(args, "name", ""); name != "" {
|
|
form.Set("name", name)
|
|
}
|
|
// full: независимый клон; для template обычно 1, для машин задаёт агент.
|
|
if getBool(args, "full", false) {
|
|
form.Set("full", "1")
|
|
}
|
|
upid, err := t.VMClone(ctx, host, node, src, newID, form)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(upidMsg("vm_clone", fmt.Sprintf("%d -> %d", src, newID), upid)), nil
|
|
}
|
|
|
|
func vmDeleteHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
args := requestArgs(req)
|
|
node, vmid, host, r := resolveVMArgs(t, args)
|
|
if r != nil {
|
|
return r, nil
|
|
}
|
|
if err := confirm(args, "vm_delete"); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
if err := gateVMWrite(t, host, vmid); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
upid, err := t.VMDelete(ctx, host, node, vmid)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(upidMsg("vm_delete", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
|
}
|
|
|
|
func vmConvertTemplateHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
t, err := tenantFor(ctx, req)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
args := requestArgs(req)
|
|
node, vmid, host, r := resolveVMArgs(t, args)
|
|
if r != nil {
|
|
return r, nil
|
|
}
|
|
if err := confirm(args, "vm_convert_template"); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
if err := gateVMWrite(t, host, vmid); err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
upid, err := t.VMConvertTemplate(ctx, host, node, vmid)
|
|
if err != nil {
|
|
return errorResult(err.Error()), nil
|
|
}
|
|
return textResult(upidMsg("vm_convert_template", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
|
}
|
|
|
|
// --- shared helpers ---
|
|
|
|
// resolveVMArgs извлекает и валидирует node/vmid/host; возвращает errorResult
|
|
// как *mcp.CallToolResult при ошибке (r != nil). Host резолвится с учётом
|
|
// мульти-гипервизора (при >1 хосте обязателен — иначе коллизия VMID).
|
|
func resolveVMArgs(t *pve.Tenant, args map[string]any) (node string, vmid int, host string, r *mcp.CallToolResult) {
|
|
var err error
|
|
if node, err = requireNode(args); err != nil {
|
|
return "", 0, "", errorResult(err.Error())
|
|
}
|
|
if vmid, err = requireVMID(args); err != nil {
|
|
return "", 0, "", errorResult(err.Error())
|
|
}
|
|
if host, err = resolveHost(t, args); err != nil {
|
|
return "", 0, "", errorResult(err.Error())
|
|
}
|
|
return node, vmid, host, nil
|
|
}
|
|
|
|
// intFromData вытаскивает число из json-ответа (nextid / id): {"data": "101"}.
|
|
func intFromData(data json.RawMessage) int {
|
|
var obj struct {
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return 0
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(obj.Data, &s); err == nil {
|
|
n, _ := strconv.Atoi(s)
|
|
return n
|
|
}
|
|
var f float64
|
|
if err := json.Unmarshal(obj.Data, &f); err == nil {
|
|
return int(f)
|
|
}
|
|
return 0
|
|
}
|