first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user