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 }