Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"forge-tools-proxmox/internal/pve"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// helpers.go — общие помощники инструментов: доступ к тенанту, валидация
|
||||
// идентификаторов (guard), гейты записи (read_only + allowlist + confirm),
|
||||
// таймауты и форматирование JSON. Никакой бизнес-логики — только трансляция
|
||||
// запрос/ответ (см. ARCHITECTURE.md §4 «золотое правило разделения»).
|
||||
|
||||
var mgr *pve.Manager
|
||||
|
||||
// SetManager связывает одиночный Manager процесса (forge: 1 подпроцесс).
|
||||
func SetManager(m *pve.Manager) { mgr = m }
|
||||
|
||||
// manager возвращает одиночный Manager.
|
||||
func manager() *pve.Manager { return mgr }
|
||||
|
||||
// tenantFor резолвит per-agent тенант из серверного аргумента _tenant_config
|
||||
// (инжектится ядром в pooled-режиме). Пусто — статический -config. Ошибка
|
||||
// загрузки конфига — доменная (errorResult), а не падение процесса.
|
||||
func tenantFor(ctx context.Context, req *mcp.CallToolRequest) (*pve.Tenant, error) {
|
||||
m := manager()
|
||||
if m == nil {
|
||||
return nil, errors.New("proxmox: no manager initialized")
|
||||
}
|
||||
path := getString(requestArgs(req), "_tenant_config", "")
|
||||
return m.Tenant(ctx, path)
|
||||
}
|
||||
|
||||
// timeout контекст по конфигу тенанта (bounded §9.6).
|
||||
func timeout(ctx context.Context, t *pve.Tenant) (context.Context, context.CancelFunc) {
|
||||
sec := t.Config().TimeoutSec
|
||||
if sec <= 0 {
|
||||
sec = pve.DefaultTimeoutSec
|
||||
}
|
||||
return context.WithTimeout(ctx, time.Duration(sec)*time.Second)
|
||||
}
|
||||
|
||||
// requireNode возвращает node с guard-валидацией идентификатора.
|
||||
func requireNode(args map[string]any) (string, error) {
|
||||
node, err := requireString(args, "node")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pve.ValidateIdentifier(node); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// requireVMID возвращает vmid (int или token-string) с валидацией.
|
||||
func requireVMID(args map[string]any) (int, error) {
|
||||
raw := getString(args, "vmid", "")
|
||||
if raw == "" {
|
||||
return 0, errors.New("missing required argument 'vmid'")
|
||||
}
|
||||
if err := pve.ValidateIdentifier(raw); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return 0, fmt.Errorf("argument 'vmid' must be a positive integer, got %q", raw)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// requireName возвращает имя (snapshot/storage) с guard-валидацией.
|
||||
func requireName(args map[string]any, key string) (string, error) {
|
||||
name, err := requireString(args, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pve.ValidateIdentifier(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// requireUPID возвращает UPID с отдельным guard (допускает ':' '@' '!').
|
||||
func requireUPID(args map[string]any) (string, error) {
|
||||
upid, err := requireString(args, "upid")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pve.ValidateUPID(upid); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return upid, nil
|
||||
}
|
||||
|
||||
// resolveHost выбирает целевой гипервизор (алиас). Правила:
|
||||
// - host задан → валидируем, что он настроен (иначе fail-closed);
|
||||
// - не задан и хостов больше одного → отказ (неоднозначность: VMID/ноды на
|
||||
// разных гипервизорах могут пересекаться, поэтому цель обязана быть явной);
|
||||
// - не задан и хост один → пустая строка (= default, резолвится в Client).
|
||||
func resolveHost(t *pve.Tenant, args map[string]any) (string, error) {
|
||||
h := getString(args, "host", "")
|
||||
if h != "" {
|
||||
if !t.Config().HostExists(h) {
|
||||
return "", fmt.Errorf("host %q not configured", h)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
if t.Config().MultiHost() {
|
||||
return "", errors.New("specify 'host' — multiple hypervisors configured")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// confirm проверяет явный флаг "confirm": "true" для деструктивных операций.
|
||||
// Без него — отказ ДО обращения к API (безопасность §9).
|
||||
func confirm(args map[string]any, what string) error {
|
||||
if getString(args, "confirm", "") != "true" {
|
||||
return fmt.Errorf("%s requires confirm=\"true\" argument (destructive)", what)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gateVMWrite применяет политику записи к VM/CT на конкретный гипервизор:
|
||||
// fail-closed по read_only и per-host allowlist.vmids (или глобальному
|
||||
// фолбэку для одиночного гипервизора). Это второй слой поверх
|
||||
// least-privilege токена (§9.5), и он исключает коллизию VMID между хостами.
|
||||
func gateVMWrite(t *pve.Tenant, host string, vmid int) error {
|
||||
if !t.Config().WriteAllowed(host, vmid) {
|
||||
return fmt.Errorf("proxmox: write to vmid %d on host %q not allowed (read_only or allowlist.vmids)", vmid, host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gateNodeWrite — то же для мутаций уровня ноды на конкретный гипервизор.
|
||||
func gateNodeWrite(t *pve.Tenant, host, node string) error {
|
||||
if !t.Config().NodeWriteAllowed(host, node) {
|
||||
return fmt.Errorf("proxmox: write to node %q on host %q not allowed (read_only or allowlist.nodes)", node, host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pretty форматирует raw JSON для отдачи модели (читабельно).
|
||||
func pretty(raw json.RawMessage) string {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "(no data)"
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// upidMsg собирает человекочитаемое сообщение мутации (с UPID, если есть).
|
||||
func upidMsg(action, target string, upid string) string {
|
||||
if upid != "" {
|
||||
return fmt.Sprintf("%s %s queued (UPID: %s)\nUse task_status (wait=true) to confirm completion.", action, target, upid)
|
||||
}
|
||||
return fmt.Sprintf("%s %s done", action, target)
|
||||
}
|
||||
Reference in New Issue
Block a user