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
+137
View File
@@ -0,0 +1,137 @@
package lexicomai
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Мутирующие доменные операции (M4–M5): изменение конфигурации, применение
// лицензии и управление службой. Все они идут через mutableTarget — то есть
// гейт read_only/allow_mutate проверяется до любого действия (fail-closed),
// а одобрение человека держит ядро/agent.yaml.
// ConfigSet обновляет ключи config.json продукта и перезапускает службу.
// updates — map ключей (точечный путь, например "logs.level") → значение.
// Возвращает итог (новые/изменённые ключи + результат рестарта).
func (m *Manager) ConfigSet(ctx context.Context, alias, product string, updates map[string]any) (string, error) {
_, target, err := m.mutableTarget(alias)
if err != nil {
return "", err
}
path := "/opt/" + product + "/config.json"
raw, err := m.runSudo(ctx, target, "cat "+shellQuote(path))
if err != nil {
return "", fmt.Errorf("read config: %w", err)
}
var cfg map[string]any
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return "", fmt.Errorf("parse config.json (should be clean JSON): %w", err)
}
changed := make([]string, 0, len(updates))
for key, val := range updates {
setNested(cfg, key, typedValue(val))
changed = append(changed, key)
}
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
if err := m.writeFile(ctx, target, path, out); err != nil {
return "", err
}
restart := ""
if out, err := m.ServiceControl(ctx, alias, product, "restart"); err == nil {
restart = out
} else {
restart = "restart failed: " + err.Error()
}
return fmt.Sprintf("updated keys: %s\n%s", strings.Join(changed, ", "), restart), nil
}
// setNested кладёт значение по точечному пути (создавая промежуточные объекты).
func setNested(m map[string]any, path string, val any) {
parts := strings.Split(path, ".")
cur := m
for i := 0; i < len(parts)-1; i++ {
next, ok := cur[parts[i]].(map[string]any)
if !ok {
next = map[string]any{}
cur[parts[i]] = next
}
cur = next
}
cur[parts[len(parts)-1]] = val
}
// typedValue приводит строковое значение из аргумента к «естественному»
// JSON-типу (true/false, число, объект/массив), иначе оставляет строкой.
// Это сохраняет типы конфига: "5242880" остаётся числом, "true" — булевым,
// а произвольный текст — строкой.
func typedValue(val any) any {
s, ok := val.(string)
if !ok || strings.TrimSpace(s) == "" {
return val
}
var v any
if err := json.Unmarshal([]byte(s), &v); err == nil {
return v
}
return s
}
// writeFile атомарно записывает данные в файл на машине (tmp + mv).
func (m *Manager) writeFile(ctx context.Context, target *Target, path string, data []byte) error {
tmp := path + ".tmp"
cmd := "sudo -n tee " + shellQuote(tmp) + " >/dev/null && sudo -n mv -f " + shellQuote(tmp) + " " + shellQuote(path)
if _, err := m.ExecWithInput(ctx, target, cmd, strings.NewReader(string(data))); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// LicenseApply записывает license.json и проверяет его через /license/check.
func (m *Manager) LicenseApply(ctx context.Context, alias, product, rawLicense string) (string, error) {
_, target, err := m.mutableTarget(alias)
if err != nil {
return "", err
}
l, err := ParseLicense([]byte(rawLicense))
if err != nil {
return "", err
}
path := "/opt/" + product + "/license.json"
if err := m.writeFile(ctx, target, path, []byte(rawLicense)); err != nil {
return "", err
}
ver, err := m.LicenseVerify(ctx, target, product)
if err != nil {
return "license written; verify: " + err.Error(), nil
}
return "license written for " + l.Service + "; " + strings.TrimPrefix(ver, "license for "+product+": "), nil
}
// ServiceControl запускает/останавливает/перезапускает службу продукта.
// action — start|stop|restart.
func (m *Manager) ServiceControl(ctx context.Context, alias, product, action string) (string, error) {
_, target, err := m.mutableTarget(alias)
if err != nil {
return "", err
}
switch action {
case "start", "stop", "restart":
default:
return "", fmt.Errorf("unknown action %q (expected start|stop|restart)", action)
}
unit := ServiceUnit(product)
out, err := m.runSudo(ctx, target, "systemctl "+action+" "+shellQuote(unit)+" && echo ok || echo failed")
if err != nil {
return "", err
}
return strings.TrimSpace(out), nil
}