Files

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
}