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) }