Files
forge-tools-proxmox/internal/tools/helpers.go
T

169 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}