Initial commit: forge-tools-proxmox — MCP-сервер для Proxmox VE

This commit is contained in:
Maksim Totmin
2026-10-01 10:41:43 +07:00
commit a7addaead9
30 changed files with 4226 additions and 0 deletions
+154
View File
@@ -0,0 +1,154 @@
package pve
import (
"context"
"errors"
"fmt"
"net/url"
"sync"
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
)
// Manager — одиночный, ПОТОКО-БЕЗОПАСНЫЙ диспетчер процесса (столп 2 §2):
// агенты запускаются в отдельных goroutine и конкурентно дёргают один
// Manager. В pooled-режиме (isolation=pooled) ядро на каждый вызов
// инжектит серверный аргумент _tenant_config = <agentDir>/forge-tools/
// proxmox.json — поэтому менеджер кэширует per-путь загрузчики конфига
// (configreload, live-reload по контент-хэшу) и per-путь Tenant'ы (свои
// клиенты/секреты). Тенанты НЕ делят клиентов между агентами.
type Manager struct {
mu sync.Mutex
// configPath — статический -config (легаси-одиночный режим). Пусто =
// pooled: конфиг приходит на каждый вызов через _tenant_config.
configPath string
// loaders кэширует configreload.Loader[*Config] по пути конфига.
loaders map[string]*configreload.Loader[*Config]
// tenants кэширует Tenant (свои клиенты) по ключу-пути конфига.
tenants map[string]*Tenant
}
// NewManager создаёт менеджер. configPath — опциональный постоянный конфиг
// (-config); пусто = pooled (тенант из _tenant_config на каждый вызов).
func NewManager(configPath string) *Manager {
return &Manager{
configPath: configPath,
loaders: make(map[string]*configreload.Loader[*Config]),
tenants: make(map[string]*Tenant),
}
}
// Tenant возвращает per-agent тенант. path — _tenant_config (пусто при
// -config-режиме). Fail-closed: нет конфига — ошибка (модуль отказывает,
// а не работает «с общими» кредами).
func (m *Manager) Tenant(ctx context.Context, path string) (*Tenant, error) {
cfgPath := m.resolvePath(path)
if cfgPath == "" {
return nil, errors.New("proxmox: no config (set -config or provide _tenant_config)")
}
loader := m.loaderFor(cfgPath)
cfg, err := loader.Get()
if err != nil {
// ErrNotFound / parse-error без last-good — fail-closed, а не фолбэк.
if cfg == nil || !cfg.HasHosts() {
return nil, fmt.Errorf("proxmox: tenant config %s: %w", cfgPath, err)
}
// Есть last-good — работаем со старым (правка была битой), но
// сигналим, чтобы не молчать.
// (здесь последний рабочий конфиг уже возвращён в cfg)
}
m.mu.Lock()
defer m.mu.Unlock()
if t, ok := m.tenants[cfgPath]; ok {
return t, nil
}
t := &Tenant{cfg: cfg, clients: make(map[string]*Client)}
m.tenants[cfgPath] = t
return t, nil
}
// resolvePath выбирает путь конфига: _tenant_config в приоритете, иначе
// статический -config.
func (m *Manager) resolvePath(tenantPath string) string {
if tenantPath != "" {
return tenantPath
}
return m.configPath
}
// loaderFor возвращает (и кэширует) загрузчик конфига по пути.
func (m *Manager) loaderFor(path string) *configreload.Loader[*Config] {
m.mu.Lock()
defer m.mu.Unlock()
if l, ok := m.loaders[path]; ok {
return l
}
l := configreload.New(path, ParseConfig)
m.loaders[path] = l
return l
}
// Close закрывает все тенанты (и их клиенты).
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for _, t := range m.tenants {
t.closeLocked()
}
m.tenants = nil
}
// Tenant — per-agent конфигурация + свои HTTP-клиенты к хостам. Секреты и
// разрешения (allowlist) — строго в рамках одного тенанта.
type Tenant struct {
cfg *Config
mu sync.Mutex
clients map[string]*Client // alias -> client
}
// Config возвращает политику тенанта.
func (t *Tenant) Config() *Config { return t.cfg }
// Client возвращает HTTP-клиент для хоста по алиасу (или default).
// Строится лениво и кэшируется; потокобезопасно.
func (t *Tenant) Client(ctx context.Context, alias string) (*Client, error) {
host, ok := t.cfg.Host(alias)
if !ok {
return nil, fmt.Errorf("proxmox: host %q not configured", alias)
}
t.mu.Lock()
defer t.mu.Unlock()
if c, ok := t.clients[host.Alias]; ok {
return c, nil
}
c, err := NewClient(host)
if err != nil {
return nil, err
}
t.clients[host.Alias] = c
return c, nil
}
// Close закрывает клиенты тенанта. Не используй вне RWMutex Manager.
func (t *Tenant) Close() {
t.mu.Lock()
defer t.mu.Unlock()
t.closeLocked()
}
func (t *Tenant) closeLocked() {
// net/http.Client не имеет Close; здесь точка для будущего пула/окружения.
t.clients = nil
}
// joinURL собирает корректный путь API (PathEscape против path-traversal).
func joinURL(path string, ids ...string) string {
p := path
for _, id := range ids {
p += "/" + url.PathEscape(id)
}
return p
}