169 lines
6.3 KiB
Go
169 lines
6.3 KiB
Go
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)
|
||
}
|