first commit

This commit is contained in:
Maksim Totmin
2026-10-01 09:50:59 +07:00
commit 4beb4b55d3
29 changed files with 3034 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
package lexicomai
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// Manager — одиночный диспетчер состояния процесса: источник конфига
// (live-reload) и блокировки по машинам. Потокобезопасен: агенты вызывают
// инструменты конкурентно.
//
// SSH-клиенты не кэшируются (каждый вызов — отдельный процесс, см. ssh.go),
// поэтому неизменяемого состояния, кроме конфига и блокировок, нет.
type Manager struct {
loader *Loader
// mu защищает locks. RWMutex не нужен, т.к. locks меняется редко и коротко,
// а сами блокировки захватываются отдельно.
mu sync.Mutex
locks map[string]*sync.Mutex
http *http.Client
sessions *installSessions
}
// NewManager создаёт диспетчер поверх загрузчика конфига.
func NewManager(loader *Loader) *Manager {
return &Manager{
loader: loader,
locks: make(map[string]*sync.Mutex),
sessions: newInstallSessions(),
http: &http.Client{
// Каждый запрос дополнительно ограничивается context (см. probeHTTP).
Timeout: 10 * time.Second,
},
}
}
// Close освобождает ресурсы. Вызывается из main через defer.
func (m *Manager) Close() {
m.http.CloseIdleConnections()
}
// resolve возвращает актуальный конфиг (live-reload применяется на лету).
func (m *Manager) resolve() (*Config, error) {
return m.loader.Get()
}
// timeout возвращает таймаут одной SSH-операции из актуального конфига.
func (m *Manager) timeout() time.Duration {
cfg, err := m.resolve()
if err != nil {
return DefaultTimeoutSeconds * time.Second
}
return cfg.Timeout()
}
// probeHTTP выполняет read-only HTTP GET к продукту на машине (host:port) и
// возвращает статус и (обрезанное) тело. Используется для /openapi.json
// (живость/версия) и /license/check (валидность лицензии).
func (m *Manager) probeHTTP(ctx context.Context, target *Target, port int, path string) (int, string, error) {
if port <= 0 {
return 0, "", fmt.Errorf("port not known for %s", target.Alias)
}
url := fmt.Sprintf("http://%s:%d%s", target.SSH.Host, port, path)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, "", err
}
req.Header.Set("Accept", "application/json")
resp, err := m.http.Do(req)
if err != nil {
return 0, "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return resp.StatusCode, string(body), nil
}
// lock возвращает эксклюзивную блокировку машины (сериализация мутаций/
// установок на один таргет). Конкурентные agents не должны гонять установки
// на одну машину параллельно.
func (m *Manager) lock(alias string) *sync.Mutex {
m.mu.Lock()
defer m.mu.Unlock()
l, ok := m.locks[alias]
if !ok {
l = &sync.Mutex{}
m.locks[alias] = l
}
return l
}
// loadConfigFor alias возвращает конфиг и таргет; alias="" → значение по умолчанию.
func (m *Manager) loadConfigFor(alias string) (*Config, *Target, error) {
cfg, err := m.resolve()
if err != nil {
return nil, nil, err
}
t, err := cfg.Target(alias)
if err != nil {
return nil, nil, err
}
return cfg, t, nil
}
// CurrentConfig возвращает актуальный конфиг (для проверки политики в tools).
func (m *Manager) CurrentConfig() (*Config, error) {
return m.resolve()
}
// Target возвращает машину по alias (пустой alias — «по умолчанию»).
func (m *Manager) Target(alias string) (*Target, error) {
_, t, err := m.loadConfigFor(alias)
return t, err
}
// Session возвращает инстал-сессию по id (для service_install_status).
func (m *Manager) Session(id string) (*InstallSession, bool) {
return m.sessions.get(id)
}
// ensureMutationAllowed — доменный гейт мутаций: они возможны только при
// read_only=false глобально И allow_mutate=true на конкретной машине
// (fail-closed: любое из условий нарушено — отказ). Независимый слой поверх
// approval человека (approval держит ядро/agent.yaml).
func (m *Manager) ensureMutationAllowed(cfg *Config, target *Target) error {
if cfg.IsReadOnly() {
return fmt.Errorf("module is read-only (read_only=true) — mutations disabled")
}
if !target.AllowMutate {
return fmt.Errorf("target %q has allow_mutate=false — mutations disabled", target.Alias)
}
return nil
}
// mutableTarget резолвит машину и проверяет разрешение на мутацию
// (используется доменными методами записи, чтобы гейт был защищён на месте).
func (m *Manager) mutableTarget(alias string) (*Config, *Target, error) {
cfg, t, err := m.loadConfigFor(alias)
if err != nil {
return nil, nil, err
}
if err := m.ensureMutationAllowed(cfg, t); err != nil {
return nil, nil, err
}
return cfg, t, nil
}