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 = /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 }