355 lines
12 KiB
Go
355 lines
12 KiB
Go
package tools
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/url"
|
||
"strconv"
|
||
|
||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||
)
|
||
|
||
// lxc.go — LXC-контейнеры: чтение (list/describe/config) + lifecycle
|
||
// (start/stop/reboot/shutdown), clone, delete, config_update, resize.
|
||
// Всё по аналогии с QEMU, но через /lxc. Мутации = gate + confirm + probe.
|
||
|
||
const vmTypeLXC = "lxc"
|
||
|
||
func registerLXCTools(s *mcp.Server) {
|
||
// --- read ---
|
||
s.AddTool(&mcp.Tool{
|
||
Name: "containers_list",
|
||
Description: "List LXC containers across the cluster. Read-only.",
|
||
InputSchema: schema(map[string]any{
|
||
"host": strProps("Cluster alias (default: primary)", false),
|
||
}, nil),
|
||
}, containersListHandler)
|
||
|
||
s.AddTool(&mcp.Tool{
|
||
Name: "container_describe",
|
||
Description: "Describe one LXC container: status + config. Read-only.",
|
||
InputSchema: schema(map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("CT ID", true),
|
||
"host": strProps("Cluster alias (default: primary)", false),
|
||
}, []string{"node", "vmid"}),
|
||
}, containerDescribeHandler)
|
||
|
||
s.AddTool(&mcp.Tool{
|
||
Name: "container_config",
|
||
Description: "Get the raw LXC config of a container. Read-only.",
|
||
InputSchema: schema(map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("CT ID", true),
|
||
"host": strProps("Cluster alias (default: primary)", false),
|
||
}, []string{"node", "vmid"}),
|
||
}, containerConfigHandler)
|
||
|
||
// --- lifecycle ---
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_start",
|
||
Description: "Start an LXC container. Requires write permission.",
|
||
InputSchema: containerActionSchema(false),
|
||
}, vmPatterns, containerStartHandler)
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_stop",
|
||
Description: "Stop an LXC container. Requires confirm + write permission.",
|
||
InputSchema: containerActionSchema(true),
|
||
}, vmPatterns, containerStopHandler)
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_reboot",
|
||
Description: "Reboot an LXC container. Requires confirm + write permission.",
|
||
InputSchema: containerActionSchema(true),
|
||
}, vmPatterns, containerRebootHandler)
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_shutdown",
|
||
Description: "Gracefully shut down an LXC container. Requires confirm + write permission.",
|
||
InputSchema: containerActionSchema(true),
|
||
}, vmPatterns, containerShutdownHandler)
|
||
|
||
// --- clone / delete / config / resize ---
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_clone",
|
||
Description: "Clone an LXC container/template into a new CT ID. Requires confirm + write permission.",
|
||
InputSchema: schema(map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("Source CT ID", true),
|
||
"newid": intProps("New CT ID (0 = next free)", false),
|
||
"name": strProps("Name for the clone", false),
|
||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||
}, []string{"node", "vmid", "confirm"}),
|
||
}, vmPatterns, containerCloneHandler)
|
||
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_delete",
|
||
Description: "Permanently delete an LXC container. Requires confirm + write permission.",
|
||
InputSchema: schema(map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("CT ID", true),
|
||
"confirm": strProps("Set to \"true\" to confirm permanent deletion", true, "true"),
|
||
}, []string{"node", "vmid", "confirm"}),
|
||
}, vmPatterns, containerDeleteHandler)
|
||
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_config_update",
|
||
Description: "Update safe LXC config fields (hostname, memory, swap, cores, unprivileged). Denied keys rejected. Requires confirm + write permission.",
|
||
InputSchema: schema(map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("CT ID", true),
|
||
"updates": objectProps("Object of key->value config fields", true),
|
||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||
}, []string{"node", "vmid", "updates", "confirm"}),
|
||
}, vmPatterns, containerConfigUpdateHandler)
|
||
|
||
registerPatternTool(s, &mcp.Tool{
|
||
Name: "container_resize",
|
||
Description: "Resize a rootfs/mountpoint of an LXC container. Requires confirm + write permission.",
|
||
InputSchema: schema(map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("CT ID", true),
|
||
"disk": strProps("Disk to resize (e.g. rootfs)", true),
|
||
"size": strProps("Size change, e.g. +5G", true),
|
||
"confirm": strProps("Set to \"true\" to confirm", true, "true"),
|
||
}, []string{"node", "vmid", "disk", "size", "confirm"}),
|
||
}, vmPatterns, containerResizeHandler)
|
||
}
|
||
|
||
// containerActionSchema — схема для lifecycle LXC (с/без confirm).
|
||
func containerActionSchema(withConfirm bool) map[string]any {
|
||
props := map[string]any{
|
||
"node": strProps("Node name", true),
|
||
"vmid": strProps("CT ID", true),
|
||
}
|
||
req := []string{"node", "vmid"}
|
||
if withConfirm {
|
||
props["confirm"] = strProps("Set to \"true\" to confirm", true, "true")
|
||
req = append(req, "confirm")
|
||
}
|
||
return schema(props, req)
|
||
}
|
||
|
||
// --- read ---
|
||
|
||
func containersListHandler(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, vmTypeLXC)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(pretty(data)), nil
|
||
}
|
||
|
||
func containerDescribeHandler(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.ContainerStatus(cctx, host, node, vmid)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
cfg, err := t.ContainerConfig(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 containerConfigHandler(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.ContainerConfig(cctx, host, node, vmid)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(pretty(data)), nil
|
||
}
|
||
|
||
// --- lifecycle ---
|
||
|
||
func containerStartHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
return containerActionHandler(ctx, req, "start", false)
|
||
}
|
||
func containerStopHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
return containerActionHandler(ctx, req, "stop", true)
|
||
}
|
||
func containerRebootHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
return containerActionHandler(ctx, req, "reboot", true)
|
||
}
|
||
func containerShutdownHandler(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
return containerActionHandler(ctx, req, "shutdown", true)
|
||
}
|
||
|
||
func containerActionHandler(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, "container_"+action); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
}
|
||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
upid, err := t.ContainerAction(ctx, host, node, vmid, action)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(upidMsg("container_"+action, fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||
}
|
||
|
||
func containerCloneHandler(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, "container_clone"); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
if err := gateVMWrite(t, host, src); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
newID := getInt(args, "newid", 0)
|
||
if newID == 0 {
|
||
data, err := t.NextID(ctx, host)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
newID = intFromData(data)
|
||
}
|
||
form := url.Values{"newid": {strconv.Itoa(newID)}}
|
||
if name := getString(args, "name", ""); name != "" {
|
||
form.Set("name", name)
|
||
}
|
||
upid, err := t.ContainerClone(ctx, host, node, src, newID, form)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(upidMsg("container_clone", fmt.Sprintf("%d -> %d", src, newID), upid)), nil
|
||
}
|
||
|
||
func containerDeleteHandler(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, "container_delete"); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
upid, err := t.ContainerDelete(ctx, host, node, vmid)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(upidMsg("container_delete", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||
}
|
||
|
||
func containerConfigUpdateHandler(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, "container_config_update"); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
updates := getObject(args, "updates")
|
||
if len(updates) == 0 {
|
||
return errorResult("'updates' must be a non-empty object"), nil
|
||
}
|
||
form := make(url.Values, len(updates))
|
||
for k, v := range updates {
|
||
if t.Config().DenyConfigKey(k) {
|
||
return errorResult(fmt.Sprintf("field %q is denied by policy", k)), nil
|
||
}
|
||
form.Set(k, fmt.Sprint(v))
|
||
}
|
||
upid, err := t.ConfigPost(ctx, host, node, vmTypeLXC, vmid, form)
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(upidMsg("container_config_update", fmt.Sprintf("%s/%d", node, vmid), upid)), nil
|
||
}
|
||
|
||
func containerResizeHandler(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, "container_resize"); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
if err := gateVMWrite(t, host, vmid); err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
disk, err := requireName(args, "disk")
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
size := getString(args, "size", "")
|
||
if size == "" {
|
||
return errorResult("'size' is required (e.g. +5G)"), nil
|
||
}
|
||
upid, err := t.ResizeDisk(ctx, host, node, vmTypeLXC, vmid, url.Values{"disk": {disk}, "size": {size}})
|
||
if err != nil {
|
||
return errorResult(err.Error()), nil
|
||
}
|
||
return textResult(upidMsg("container_resize", fmt.Sprintf("%s/%d %s", node, vmid, disk), upid)), nil
|
||
}
|