Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// api_configure.go — изменение конфигурации сервиса (compose/env) и её версий.
|
||||
|
||||
// SetCompose устанавливает текущий docker-compose и отправляет его агенту.
|
||||
func (a *API) SetCompose(ctx context.Context, appID int, content string) (*ConfigAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &ConfigAck{}
|
||||
body := map[string]string{"content": content}
|
||||
if err := a.Sess.postJSON(ctx, "/api/compose/"+strconv.Itoa(appID), body, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetEnv устанавливает текущий env-файл и отправляет его агенту.
|
||||
func (a *API) SetEnv(ctx context.Context, appID int, filename, content string) (*ConfigAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &ConfigAck{}
|
||||
body := map[string]string{"content": content}
|
||||
path := "/api/env/" + strconv.Itoa(appID) + "/" + url.PathEscape(filename)
|
||||
if err := a.Sess.postJSON(ctx, path, body, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RestoreCompose восстанавливает версию compose (создаёт новую текущую версию).
|
||||
func (a *API) RestoreCompose(ctx context.Context, versionID int) (*StatusAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &StatusAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/restore_compose/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RestoreEnvVersion восстанавливает версию env-файла.
|
||||
func (a *API) RestoreEnvVersion(ctx context.Context, versionID int) (*StatusAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &StatusAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/restore_env_version/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateConfig повторно отправляет агенту текущую конфигурацию из БД.
|
||||
func (a *API) UpdateConfig(ctx context.Context, appID int) (*TaskAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &TaskAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/update_config/"+strconv.Itoa(appID), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package configserver
|
||||
|
||||
import "context"
|
||||
|
||||
// api_maintain.go — обслуживание: очистка старых версий.
|
||||
|
||||
// CleanupOldVersions удаляет версии compose/env и логи миграций старше days дней
|
||||
// (текущие версии не трогаются).
|
||||
func (a *API) CleanupOldVersions(ctx context.Context, days int) (*CleanupAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
body := map[string]int{"days": days}
|
||||
out := &CleanupAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/cleanup_old_versions", body, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// api_observe.go — read-only операции (наблюдение и диагностика).
|
||||
|
||||
// WhoAmI — ответ GET /api/whoami.
|
||||
type WhoAmI struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Capabilities — результат capability-probe: версия API и доступные группы
|
||||
// JSON-эндпоинтов v1.1.0. Нужен, чтобы инструменты не «тихо» ломались на
|
||||
// старых стендах, а возвращали внятное сообщение.
|
||||
type Capabilities struct {
|
||||
Version string `json:"version"`
|
||||
Features map[string]bool `json:"features"`
|
||||
}
|
||||
|
||||
// Health проверяет живость сервера (GET /health, без авторизации).
|
||||
func (a *API) Health(ctx context.Context) (int, string, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
resp, err := a.Sess.getPublic(ctx, "/health")
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return resp.status, strings.TrimSpace(string(resp.body)), nil
|
||||
}
|
||||
|
||||
// WhoAmI проверяет текущую авторизацию.
|
||||
func (a *API) WhoAmI(ctx context.Context) (*WhoAmI, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &WhoAmI{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/whoami", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Probe определяет версию API. Ключевой признак v1.1.0 — публичный JSON
|
||||
// `/api/whoami` (в <=1.0.20 его нет, middleware отвечает 401).
|
||||
func (a *API) Probe(ctx context.Context) (*Capabilities, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
resp, err := a.Sess.getPublic(ctx, "/api/whoami")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caps := &Capabilities{Version: "legacy (<1.1.0)", Features: map[string]bool{}}
|
||||
if resp.status == 200 && strings.Contains(resp.ctype, "json") {
|
||||
caps.Version = "1.1.0"
|
||||
for _, f := range []string{
|
||||
"api_login", "api_whoami", "application_detail",
|
||||
"set_compose", "set_env", "tasks_filter", "task_get", "update_front",
|
||||
} {
|
||||
caps.Features[f] = true
|
||||
}
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
// ListApplications возвращает список сервисов.
|
||||
func (a *API) ListApplications(ctx context.Context, includePresence bool) ([]Application, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
q := url.Values{}
|
||||
if includePresence {
|
||||
q.Set("include_presence", "true")
|
||||
}
|
||||
var out []Application
|
||||
if err := a.Sess.getJSON(ctx, "/api/applications", q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetApplication возвращает детализацию сервиса.
|
||||
func (a *API) GetApplication(ctx context.Context, id int) (*ApplicationDetail, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &ApplicationDetail{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/application/"+strconv.Itoa(id), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetConfig возвращает текущие compose/env сервиса (эндпоинт агента, публичный).
|
||||
func (a *API) GetConfig(ctx context.Context, id int) (*RawConfig, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &RawConfig{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/get_config/"+strconv.Itoa(id), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnvFiles возвращает имена текущих env-файлов сервиса.
|
||||
func (a *API) EnvFiles(ctx context.Context, id int) ([]string, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
var out []string
|
||||
if err := a.Sess.getJSON(ctx, "/api/env_files/"+strconv.Itoa(id), nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ComposeVersion возвращает конкретную версию compose.
|
||||
func (a *API) ComposeVersion(ctx context.Context, versionID int) (*ComposeVersion, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &ComposeVersion{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/compose_version/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnvVersion возвращает конкретную версию env-файла.
|
||||
func (a *API) EnvVersion(ctx context.Context, versionID int) (*EnvVersion, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &EnvVersion{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/env_version/"+strconv.Itoa(versionID), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EnvVersions возвращает все версии конкретного env-файла.
|
||||
func (a *API) EnvVersions(ctx context.Context, appID int, filename string) ([]EnvVersion, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
path := "/api/env_versions/" + strconv.Itoa(appID) + "/" + url.PathEscape(filename)
|
||||
var out []EnvVersion
|
||||
if err := a.Sess.getJSON(ctx, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TaskFilter — фильтры списка задач.
|
||||
type TaskFilter struct {
|
||||
HostIP string
|
||||
Status string
|
||||
TaskType string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListTasks возвращает задачи с фильтрами (GET /api/tasks).
|
||||
func (a *API) ListTasks(ctx context.Context, f TaskFilter) ([]Task, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
q := url.Values{}
|
||||
if f.HostIP != "" {
|
||||
q.Set("host_ip", f.HostIP)
|
||||
}
|
||||
if f.Status != "" {
|
||||
q.Set("status", f.Status)
|
||||
}
|
||||
if f.TaskType != "" {
|
||||
q.Set("task_type", f.TaskType)
|
||||
}
|
||||
if f.Limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(f.Limit))
|
||||
}
|
||||
var out []Task
|
||||
if err := a.Sess.getJSON(ctx, "/api/tasks", q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTask возвращает задачу по task_id.
|
||||
func (a *API) GetTask(ctx context.Context, taskID string) (*Task, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &Task{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/task/"+url.PathEscape(taskID), nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WaitTask опрашивает задачу до завершения (completed/failed) или до истечения
|
||||
// task_poll_max_sec. Возвращает последнее известное состояние задачи; признак
|
||||
// незавершённости — Status == "pending"/"in_progress". Loop агента
|
||||
// последовательный, поэтому ожидание строго ограничено конфигом.
|
||||
func (a *API) WaitTask(ctx context.Context, taskID string) (*Task, error) {
|
||||
deadline := time.Now().Add(time.Duration(a.Srv.TaskPollMaxSec) * time.Second)
|
||||
interval := time.Duration(a.Srv.TaskPollIntervalSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
for {
|
||||
task, err := a.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.Status == "completed" || task.Status == "failed" {
|
||||
return task, nil
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return task, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return task, nil
|
||||
case <-time.After(interval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeploymentFiles возвращает schema/manifest/prebuild_vars/ip_overrides.
|
||||
func (a *API) DeploymentFiles(ctx context.Context) (*DeploymentFiles, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &DeploymentFiles{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/deployment/files", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IPMatch возвращает диагностику сопоставления IP схемы и агентов.
|
||||
func (a *API) IPMatch(ctx context.Context) (*IPMatch, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &IPMatch{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/deployment/ip_match", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ReleaseJob возвращает статус фоновой загрузки релиза.
|
||||
func (a *API) ReleaseJob(ctx context.Context) (*ReleaseJob, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &ReleaseJob{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/deployment/release_job", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeploymentTasks возвращает последние release-задачи (sync/start).
|
||||
func (a *API) DeploymentTasks(ctx context.Context) ([]Task, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
var out []Task
|
||||
if err := a.Sess.getJSON(ctx, "/api/deployment/tasks", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stats возвращает счётчики версий.
|
||||
func (a *API) Stats(ctx context.Context) (*Stats, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &Stats{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/stats", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ServiceMap возвращает карту серверов и сервисов.
|
||||
func (a *API) ServiceMap(ctx context.Context) (*ServiceMap, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &ServiceMap{}
|
||||
if err := a.Sess.getJSON(ctx, "/api/service_map", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// api_operate.go — жизненный цикл сервиса и загрузка фронта.
|
||||
|
||||
// Deploy поднимает сервис (docker-compose up -d).
|
||||
func (a *API) Deploy(ctx context.Context, appID int) (*TaskAck, error) {
|
||||
return a.lifecycle(ctx, "deploy", appID)
|
||||
}
|
||||
|
||||
// Restart перезапускает сервис (down + up -d).
|
||||
func (a *API) Restart(ctx context.Context, appID int) (*TaskAck, error) {
|
||||
return a.lifecycle(ctx, "restart", appID)
|
||||
}
|
||||
|
||||
// Down останавливает сервис (docker-compose down).
|
||||
func (a *API) Down(ctx context.Context, appID int) (*TaskAck, error) {
|
||||
return a.lifecycle(ctx, "down", appID)
|
||||
}
|
||||
|
||||
// Migrate запускает миграцию (docker-compose run migration).
|
||||
func (a *API) Migrate(ctx context.Context, appID int) (*TaskAck, error) {
|
||||
return a.lifecycle(ctx, "migrate", appID)
|
||||
}
|
||||
|
||||
// lifecycle — общий вызов POST /api/<action>/<app_id>, возвращающий task_id.
|
||||
func (a *API) lifecycle(ctx context.Context, action string, appID int) (*TaskAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &TaskAck{}
|
||||
path := "/api/" + action + "/" + strconv.Itoa(appID)
|
||||
if err := a.Sess.postJSON(ctx, path, nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateFront загружает локальную сборку фронта и создаёт задачу её доставки
|
||||
// агенту. Каталог обязан лежать внутри front_roots (jail, fail-closed).
|
||||
func (a *API) UpdateFront(ctx context.Context, appID int, buildDir string) (*FrontAck, error) {
|
||||
files, err := a.collectFrontFiles(buildDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := a.withUploadTimeout(ctx)
|
||||
defer cancel()
|
||||
raw, err := a.Sess.postMultipart(ctx, "/api/update_front/"+strconv.Itoa(appID), files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := &FrontAck{}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return nil, fmt.Errorf("разбор ответа update_front: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectFrontFiles собирает файлы сборки, проверяя containment и лимиты.
|
||||
func (a *API) collectFrontFiles(buildDir string) ([]UploadFile, error) {
|
||||
if len(a.Srv.FrontRoots) == 0 {
|
||||
return nil, ErrNoFrontRoots
|
||||
}
|
||||
root, err := resolveInsideRoots(buildDir, a.Srv.FrontRoots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
files []UploadFile
|
||||
total int64
|
||||
)
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// Симлинки и спецфайлы пропускаем: загружаем только обычные файлы.
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total += info.Size()
|
||||
if total > a.Srv.MaxUploadBytes {
|
||||
return validationErrorf("размер сборки превышает лимит %d байт", a.Srv.MaxUploadBytes)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, UploadFile{Name: filepath.ToSlash(rel), Data: data})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение сборки %s: %w", buildDir, err)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, validationErrorf("в каталоге %s нет файлов", buildDir)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// resolveInsideRoots приводит путь к абсолютному и проверяет, что после
|
||||
// разрешения симлинков он лежит внутри одного из разрешённых корней.
|
||||
func resolveInsideRoots(target string, roots []string) (string, error) {
|
||||
abs, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("некорректный путь %q: %w", target, err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
return "", validationErrorf("путь %q недоступен: %v", target, err)
|
||||
}
|
||||
resolved = filepath.Clean(resolved)
|
||||
|
||||
for _, r := range roots {
|
||||
rootAbs, err := filepath.Abs(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Симлинки в самом корне не должны «съесть» проверку — разрешаем, если можем.
|
||||
if rootResolved, err := filepath.EvalSymlinks(rootAbs); err == nil {
|
||||
rootAbs = rootResolved
|
||||
}
|
||||
rootAbs = filepath.Clean(rootAbs)
|
||||
if resolved == rootAbs || strings.HasPrefix(resolved, rootAbs+string(os.PathSeparator)) {
|
||||
return resolved, nil
|
||||
}
|
||||
}
|
||||
return "", validationErrorf("путь %q вне разрешённых front_roots", target)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveInsideRoots(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sub := filepath.Join(root, "build")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "index.html"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := resolveInsideRoots(sub, []string{root}); err != nil {
|
||||
t.Fatalf("каталог внутри root должен быть разрешён: %v", err)
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
if _, err := resolveInsideRoots(outside, []string{root}); err == nil {
|
||||
t.Fatal("каталог вне root должен быть отклонён")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveInsideRootsSymlinkEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "escape")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("симлинки недоступны: %v", err)
|
||||
}
|
||||
if _, err := resolveInsideRoots(link, []string{root}); err == nil {
|
||||
t.Fatal("symlink-побег из root должен быть отклонён")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFrontFilesNoRoots(t *testing.T) {
|
||||
api := &API{Srv: Server{}}
|
||||
_, err := api.collectFrontFiles("/tmp")
|
||||
if !errors.Is(err, ErrNoFrontRoots) {
|
||||
t.Fatalf("ожидалась ErrNoFrontRoots, получено: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectFrontFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
build := filepath.Join(root, "dist")
|
||||
if err := os.MkdirAll(filepath.Join(build, "assets"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(build, "index.html"), []byte("<html/>"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(build, "assets", "app.js"), []byte("js"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
api := &API{Srv: Server{FrontRoots: []string{root}, MaxUploadBytes: DefaultMaxUploadBytes}}
|
||||
files, err := api.collectFrontFiles(build)
|
||||
if err != nil {
|
||||
t.Fatalf("collectFrontFiles: %v", err)
|
||||
}
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("файлов = %d, ожидалось 2", len(files))
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, f := range files {
|
||||
names[f.Name] = true
|
||||
}
|
||||
if !names["index.html"] || !names["assets/app.js"] {
|
||||
t.Fatalf("неожиданные относительные пути: %+v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// api_release.go — страница «Развёртывание»: файлы схемы, seed/fill, релиз.
|
||||
|
||||
// DeploymentUpdate — частичное обновление файлов развёртывания. nil-поля не
|
||||
// отправляются (сервер не перезаписывает их пустыми значениями).
|
||||
type DeploymentUpdate struct {
|
||||
Schema *string
|
||||
Manifest *string
|
||||
PrebuildVars *string
|
||||
IPOverrides *string
|
||||
}
|
||||
|
||||
// SaveDeploymentFiles сохраняет schema/manifest/prebuild_vars/ip_overrides.
|
||||
func (a *API) SaveDeploymentFiles(ctx context.Context, upd DeploymentUpdate) (*StatusAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
|
||||
body := map[string]string{}
|
||||
if upd.Schema != nil {
|
||||
body["schema"] = *upd.Schema
|
||||
}
|
||||
if upd.Manifest != nil {
|
||||
body["manifest"] = *upd.Manifest
|
||||
}
|
||||
if upd.PrebuildVars != nil {
|
||||
body["prebuild_vars"] = *upd.PrebuildVars
|
||||
}
|
||||
if upd.IPOverrides != nil {
|
||||
body["ip_overrides"] = *upd.IPOverrides
|
||||
}
|
||||
|
||||
out := &StatusAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/deployment/files", body, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SeedHosts создаёт хосты из schema.json.
|
||||
func (a *API) SeedHosts(ctx context.Context) (*SeedHostsAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &SeedHostsAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/deployment/seed_hosts", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FillVars заполняет prebuild_vars из схемы (ответ — произвольный JSON).
|
||||
func (a *API) FillVars(ctx context.Context) (json.RawMessage, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
var out json.RawMessage
|
||||
if err := a.Sess.postJSON(ctx, "/api/deployment/fill_vars", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DownloadRelease запускает загрузку релиза. При load_images=true сервер
|
||||
// отвечает сразу (фоновая задача), прогресс читается через ReleaseJob.
|
||||
func (a *API) DownloadRelease(ctx context.Context, token string, loadImages bool) (*DownloadReleaseAck, error) {
|
||||
if token == "" {
|
||||
token = a.Srv.GitlabToken
|
||||
}
|
||||
body := map[string]any{"load_images": loadImages}
|
||||
if token != "" {
|
||||
body["gitlab_token"] = token
|
||||
}
|
||||
|
||||
// Синхронная выгрузка (load_images=false) может быть долгой.
|
||||
ctx, cancel := a.withUploadTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &DownloadReleaseAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/deployment/download_release", body, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// StartServices создаёт задачи запуска сервисов для хостов из схемы.
|
||||
func (a *API) StartServices(ctx context.Context) (*StartServicesAck, error) {
|
||||
ctx, cancel := a.withTimeout(ctx)
|
||||
defer cancel()
|
||||
out := &StartServicesAck{}
|
||||
if err := a.Sess.postJSON(ctx, "/api/deployment/start_services", nil, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Package configserver — доменный слой MCP-модуля omnichannel-mcp.
|
||||
//
|
||||
// Здесь нет зависимостей от MCP: только разбор конфига, авторизованные
|
||||
// HTTP-сессии к API config_server и типизированные вызовы его эндпоинтов.
|
||||
// MCP-слой (internal/tools) — тонкая обёртка, которая парсит аргументы,
|
||||
// дёргает этот пакет и форматирует результат.
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||
)
|
||||
|
||||
// Значения по умолчанию для лимитов сервера. Все они задаются в config.json,
|
||||
// но модуль должен работать и с минимальным конфигом (только base_url).
|
||||
const (
|
||||
DefaultTimeoutSec = 30
|
||||
DefaultUploadTimeoutSec = 300
|
||||
DefaultTaskPollMaxSec = 120
|
||||
DefaultTaskPollIntervalSec = 2
|
||||
DefaultMaxOutputBytes = 100_000
|
||||
DefaultMaxUploadBytes int64 = 512 << 20 // 512 MiB
|
||||
DefaultMaxConcurrentMutations = 1
|
||||
)
|
||||
|
||||
// Server — одно подключение к config_server (стенд/контур).
|
||||
type Server struct {
|
||||
Alias string `json:"alias"`
|
||||
// BaseURL — адрес API, например http://10.101.60.3:5005.
|
||||
BaseURL string `json:"base_url"`
|
||||
|
||||
// Username/Password — учётная запись с правом записи (используется
|
||||
// изменяющими инструментами).
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
||||
// ReadonlyUsername/ReadonlyPassword — необязательная учётная запись только
|
||||
// для чтения. Если задана, read-инструменты используют её (least-privilege
|
||||
// на нашей стороне; см. ARCHITECTURE.md §9.5).
|
||||
ReadonlyUsername string `json:"readonly_username"`
|
||||
ReadonlyPassword string `json:"readonly_password"`
|
||||
|
||||
// GitlabToken — токен для /api/deployment/download_release. Секрет: держим
|
||||
// в env/локальном конфиге, никогда не логируем.
|
||||
GitlabToken string `json:"gitlab_token"`
|
||||
|
||||
// InsecureSkipVerify отключает проверку TLS-сертификата (для стендов с
|
||||
// самоподписанными сертификатами).
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||||
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
UploadTimeoutSec int `json:"upload_timeout_sec"`
|
||||
TaskPollMaxSec int `json:"task_poll_max_sec"`
|
||||
TaskPollIntervalSec int `json:"task_poll_interval_sec"`
|
||||
MaxOutputBytes int `json:"max_output_bytes"`
|
||||
MaxUploadBytes int64 `json:"max_upload_bytes"`
|
||||
MaxConcurrentMutations int `json:"max_concurrent_mutations"`
|
||||
|
||||
// FrontRoots — разрешённые каталоги локальных сборок фронта для
|
||||
// update_front (jail). Пусто — загрузка фронта запрещена (fail-closed).
|
||||
FrontRoots []string `json:"front_roots"`
|
||||
}
|
||||
|
||||
// Config — корневой конфиг модуля (config.json или per-agent omnichannel-mcp.json).
|
||||
type Config struct {
|
||||
Servers []Server `json:"servers"`
|
||||
Default string `json:"default"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
|
||||
// AllowHosts — необязательный allowlist хостов. Если задан, base_url любого
|
||||
// сервера обязан быть на одном из этих хостов (защита от опечаток/SSRF).
|
||||
// Пусто — доверяем хостам из самих серверов.
|
||||
AllowHosts []string `json:"allow_hosts"`
|
||||
}
|
||||
|
||||
// rawConfig — форма для строгого разбора: позволяет отличить «read_only не
|
||||
// задан» (тогда безопасный дефолт true) от явного false.
|
||||
type rawConfig struct {
|
||||
Servers []Server `json:"servers"`
|
||||
Default string `json:"default"`
|
||||
ReadOnly *bool `json:"read_only"`
|
||||
AllowHosts []string `json:"allow_hosts"`
|
||||
|
||||
// Single-server shorthand: если servers пуст, а base_url задан — считаем это
|
||||
// одним сервером с алиасом "default".
|
||||
BaseURL string `json:"base_url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var envVarRe = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
|
||||
|
||||
// ParseConfig разбирает конфиг: раскрывает ${VAR} из окружения, применяет
|
||||
// дефолты и валидирует. Fail-closed: незаполненная переменная, битый адрес или
|
||||
// отсутствие серверов — ошибка, а не «подозрительный дефолт».
|
||||
func ParseConfig(data []byte) (*Config, error) {
|
||||
if unresolved := unresolvedVars(data); len(unresolved) > 0 {
|
||||
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
|
||||
}
|
||||
return decodeConfig(toolkit.Expand(data))
|
||||
}
|
||||
|
||||
// decodeConfig разбирает уже раскрытый JSON (без ${VAR}).
|
||||
func decodeConfig(data []byte) (*Config, error) {
|
||||
var raw rawConfig
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("разбор config.json: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Servers: raw.Servers,
|
||||
Default: strings.TrimSpace(raw.Default),
|
||||
ReadOnly: true,
|
||||
AllowHosts: raw.AllowHosts,
|
||||
}
|
||||
if raw.ReadOnly != nil {
|
||||
cfg.ReadOnly = *raw.ReadOnly
|
||||
}
|
||||
if len(cfg.Servers) == 0 && strings.TrimSpace(raw.BaseURL) != "" {
|
||||
cfg.Servers = []Server{{Alias: "default", BaseURL: raw.BaseURL, Username: raw.Username, Password: raw.Password}}
|
||||
}
|
||||
if err := cfg.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LocalConfigSuffix — суффикс локального файла с секретами (gitignored).
|
||||
const LocalConfigSuffix = ".local"
|
||||
|
||||
// LoadFile загружает конфиг из основного файла, накладывая поверх соседний
|
||||
// <name>.local.json (реальные секреты; в репозитории — только шаблон).
|
||||
func LoadFile(path string) (*Config, error) {
|
||||
mainData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if unresolved := unresolvedVars(mainData); len(unresolved) > 0 {
|
||||
return nil, fmt.Errorf("не заданы переменные окружения: %s", strings.Join(unresolved, ", "))
|
||||
}
|
||||
|
||||
expanded := toolkit.Expand(mainData)
|
||||
if localPath := localConfigPath(path); localPath != "" {
|
||||
if localData, err := os.ReadFile(localPath); err == nil {
|
||||
if unresolved := unresolvedVars(localData); len(unresolved) > 0 {
|
||||
return nil, fmt.Errorf("не заданы переменные окружения (в %s): %s", localPath, strings.Join(unresolved, ", "))
|
||||
}
|
||||
if expanded, err = mergeConfigJSON(expanded, toolkit.Expand(localData)); err != nil {
|
||||
return nil, fmt.Errorf("слияние %s: %w", localPath, err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("чтение %s: %w", localPath, err)
|
||||
}
|
||||
}
|
||||
return decodeConfig(expanded)
|
||||
}
|
||||
|
||||
// localConfigPath возвращает путь <dir>/<name>.local.json для основного файла.
|
||||
func localConfigPath(path string) string {
|
||||
if strings.HasSuffix(path, ".json") {
|
||||
return strings.TrimSuffix(path, ".json") + LocalConfigSuffix + ".json"
|
||||
}
|
||||
return path + LocalConfigSuffix
|
||||
}
|
||||
|
||||
// mergeConfigJSON накладывает over поверх base: объекты сливаются рекурсивно,
|
||||
// массивы servers — по alias (локальный сервер дополняет/перезаписывает
|
||||
// одноимённый), прочие значения over перезаписывают base.
|
||||
func mergeConfigJSON(base, over []byte) ([]byte, error) {
|
||||
var baseMap, overMap map[string]any
|
||||
if err := json.Unmarshal(base, &baseMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(over, &overMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := mergeMaps(baseMap, overMap)
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
// mergeMaps рекурсивно сливает карты; список servers обрабатывается отдельно.
|
||||
func mergeMaps(base, over map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(base)+len(over))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range over {
|
||||
if k == "servers" {
|
||||
out[k] = mergeServers(out[k], v)
|
||||
continue
|
||||
}
|
||||
if baseChild, ok := out[k].(map[string]any); ok {
|
||||
if overChild, ok := v.(map[string]any); ok {
|
||||
out[k] = mergeMaps(baseChild, overChild)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeServers сливает массивы серверов по полю alias.
|
||||
func mergeServers(base, over any) []any {
|
||||
baseList, _ := base.([]any)
|
||||
overList, _ := over.([]any)
|
||||
|
||||
index := map[string]int{}
|
||||
var result []any
|
||||
for _, item := range baseList {
|
||||
m, _ := item.(map[string]any)
|
||||
alias, _ := m["alias"].(string)
|
||||
index[alias] = len(result)
|
||||
result = append(result, m)
|
||||
}
|
||||
for _, item := range overList {
|
||||
m, _ := item.(map[string]any)
|
||||
alias, _ := m["alias"].(string)
|
||||
if pos, ok := index[alias]; ok {
|
||||
if baseServer, ok := result[pos].(map[string]any); ok {
|
||||
result[pos] = mergeMaps(baseServer, m)
|
||||
continue
|
||||
}
|
||||
}
|
||||
index[alias] = len(result)
|
||||
result = append(result, m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// unresolvedVars возвращает имена переменных ${VAR}, которых нет в окружении.
|
||||
func unresolvedVars(data []byte) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, m := range envVarRe.FindAllSubmatch(data, -1) {
|
||||
name := string(m[1])
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
if os.Getenv(name) == "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// normalize применяет дефолты и валидирует конфиг.
|
||||
func (c *Config) normalize() error {
|
||||
if len(c.Servers) == 0 {
|
||||
return fmt.Errorf("не задано ни одного сервера (servers или base_url)")
|
||||
}
|
||||
|
||||
aliases := map[string]bool{}
|
||||
for i := range c.Servers {
|
||||
s := &c.Servers[i]
|
||||
if strings.TrimSpace(s.Alias) == "" {
|
||||
return fmt.Errorf("servers[%d]: не задан alias", i)
|
||||
}
|
||||
if aliases[s.Alias] {
|
||||
return fmt.Errorf("дублирующийся alias %q", s.Alias)
|
||||
}
|
||||
aliases[s.Alias] = true
|
||||
if err := c.validateServer(s); err != nil {
|
||||
return fmt.Errorf("сервер %q: %w", s.Alias, err)
|
||||
}
|
||||
}
|
||||
|
||||
// default: явный или первый сервер.
|
||||
if c.Default == "" {
|
||||
c.Default = c.Servers[0].Alias
|
||||
}
|
||||
if !aliases[c.Default] {
|
||||
return fmt.Errorf("default %q не найден среди серверов (%s)", c.Default, strings.Join(c.ServerNames(), ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateServer проверяет адрес, allowlist и заполняет лимиты дефолтами.
|
||||
func (c *Config) validateServer(s *Server) error {
|
||||
u, err := url.Parse(strings.TrimSpace(s.BaseURL))
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("некорректный base_url %q", s.BaseURL)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("base_url должен быть http/https, получено %q", u.Scheme)
|
||||
}
|
||||
if len(c.AllowHosts) > 0 && !containsFold(c.AllowHosts, u.Hostname()) {
|
||||
return fmt.Errorf("хост %q не в allow_hosts (%s)", u.Hostname(), strings.Join(c.AllowHosts, ", "))
|
||||
}
|
||||
|
||||
s.BaseURL = strings.TrimRight(s.BaseURL, "/")
|
||||
s.TimeoutSec = orDefault(s.TimeoutSec, DefaultTimeoutSec)
|
||||
s.UploadTimeoutSec = orDefault(s.UploadTimeoutSec, DefaultUploadTimeoutSec)
|
||||
s.TaskPollMaxSec = orDefault(s.TaskPollMaxSec, DefaultTaskPollMaxSec)
|
||||
s.TaskPollIntervalSec = orDefault(s.TaskPollIntervalSec, DefaultTaskPollIntervalSec)
|
||||
s.MaxOutputBytes = orDefault(s.MaxOutputBytes, DefaultMaxOutputBytes)
|
||||
s.MaxConcurrentMutations = orDefault(s.MaxConcurrentMutations, DefaultMaxConcurrentMutations)
|
||||
if s.MaxUploadBytes <= 0 {
|
||||
s.MaxUploadBytes = DefaultMaxUploadBytes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Server возвращает сервер по алиасу (пустой — default).
|
||||
func (c *Config) Server(alias string) (*Server, error) {
|
||||
if alias == "" {
|
||||
alias = c.Default
|
||||
}
|
||||
for i := range c.Servers {
|
||||
if c.Servers[i].Alias == alias {
|
||||
return &c.Servers[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("неизвестный сервер %q; настроены: %s (default: %s)",
|
||||
alias, strings.Join(c.ServerNames(), ", "), c.Default)
|
||||
}
|
||||
|
||||
// ServerNames возвращает список алиасов (для сообщений об ошибках).
|
||||
func (c *Config) ServerNames() []string {
|
||||
out := make([]string, 0, len(c.Servers))
|
||||
for _, s := range c.Servers {
|
||||
out = append(out, s.Alias)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Redacted возвращает копию конфига с замаскированными секретами — для
|
||||
// `--check-config` и диагностики. Пароли/токены не покидают процесс.
|
||||
func (c *Config) Redacted() map[string]any {
|
||||
servers := make([]map[string]any, 0, len(c.Servers))
|
||||
for _, s := range c.Servers {
|
||||
servers = append(servers, map[string]any{
|
||||
"alias": s.Alias,
|
||||
"base_url": s.BaseURL,
|
||||
"username": s.Username,
|
||||
"password": mask(s.Password),
|
||||
"readonly_username": s.ReadonlyUsername,
|
||||
"readonly_password": mask(s.ReadonlyPassword),
|
||||
"gitlab_token": mask(s.GitlabToken),
|
||||
"insecure_skip_verify": s.InsecureSkipVerify,
|
||||
"timeout_sec": s.TimeoutSec,
|
||||
"upload_timeout_sec": s.UploadTimeoutSec,
|
||||
"task_poll_max_sec": s.TaskPollMaxSec,
|
||||
"task_poll_interval_sec": s.TaskPollIntervalSec,
|
||||
"max_output_bytes": s.MaxOutputBytes,
|
||||
"max_upload_bytes": s.MaxUploadBytes,
|
||||
"max_concurrent_mutations": s.MaxConcurrentMutations,
|
||||
"front_roots": s.FrontRoots,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"servers": servers,
|
||||
"default": c.Default,
|
||||
"read_only": c.ReadOnly,
|
||||
"allow_hosts": c.AllowHosts,
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(v, def int) int {
|
||||
if v <= 0 {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func mask(secret string) string {
|
||||
if secret == "" {
|
||||
return ""
|
||||
}
|
||||
return "***"
|
||||
}
|
||||
|
||||
func containsFold(list []string, value string) bool {
|
||||
for _, item := range list {
|
||||
if strings.EqualFold(item, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseConfigDefaults(t *testing.T) {
|
||||
cfg, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005","username":"u","password":"p"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig: %v", err)
|
||||
}
|
||||
if !cfg.ReadOnly {
|
||||
t.Fatalf("read_only должен по умолчанию быть true (fail-safe)")
|
||||
}
|
||||
if cfg.Default != "a" {
|
||||
t.Fatalf("default = %q, ожидалось a", cfg.Default)
|
||||
}
|
||||
srv, err := cfg.Server("")
|
||||
if err != nil {
|
||||
t.Fatalf("Server: %v", err)
|
||||
}
|
||||
if srv.TimeoutSec != DefaultTimeoutSec || srv.MaxOutputBytes != DefaultMaxOutputBytes {
|
||||
t.Fatalf("дефолты лимитов не применены: %+v", srv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleServerShorthand(t *testing.T) {
|
||||
cfg, err := ParseConfig([]byte(`{"base_url":"http://h:5005","username":"u","password":"p"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseConfig: %v", err)
|
||||
}
|
||||
if len(cfg.Servers) != 1 || cfg.Servers[0].Alias != "default" {
|
||||
t.Fatalf("shorthand не сработал: %+v", cfg.Servers)
|
||||
}
|
||||
if cfg.Default != "default" {
|
||||
t.Fatalf("default = %q", cfg.Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnresolvedVarFails(t *testing.T) {
|
||||
t.Setenv("OMNI_PRESENT", "x")
|
||||
_ = os.Unsetenv("OMNI_MISSING")
|
||||
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"${OMNI_MISSING}","username":"u","password":"p"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка о незаданной переменной")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownFieldFails(t *testing.T) {
|
||||
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005"}],"typo_field":1}`))
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка строгого разбора неизвестного поля")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowlist(t *testing.T) {
|
||||
_, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://evil:5005"}],"allow_hosts":["good"]}`))
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка: хост вне allow_hosts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerUnknownAlias(t *testing.T) {
|
||||
cfg, err := ParseConfig([]byte(`{"servers":[{"alias":"a","base_url":"http://h:5005"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cfg.Server("nope"); err == nil {
|
||||
t.Fatal("ожидалась ошибка неизвестного алиаса")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileMergesLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := filepath.Join(dir, "config.json")
|
||||
local := filepath.Join(dir, "config.local.json")
|
||||
|
||||
if err := os.WriteFile(main, []byte(`{
|
||||
"servers":[{"alias":"prod","base_url":"http://example:5005","username":"u","password":"p"}],
|
||||
"read_only": true
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(local, []byte(`{
|
||||
"servers":[{"alias":"prod","base_url":"http://127.0.0.1:5005","password":"secret"}],
|
||||
"read_only": false
|
||||
}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := LoadFile(main)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFile: %v", err)
|
||||
}
|
||||
if cfg.ReadOnly {
|
||||
t.Fatal("read_only из local не применён")
|
||||
}
|
||||
srv := cfg.Servers[0]
|
||||
if srv.BaseURL != "http://127.0.0.1:5005" {
|
||||
t.Fatalf("base_url не переопределён: %q", srv.BaseURL)
|
||||
}
|
||||
if srv.Username != "u" {
|
||||
t.Fatalf("username из main потерян: %q", srv.Username)
|
||||
}
|
||||
if srv.Password != "secret" {
|
||||
t.Fatalf("password из local не применён: %q", srv.Password)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Сентинельные ошибки домена. MCP-слой различает их, чтобы выбрать между
|
||||
// recoverable-ответом (`errorResult`) и инфраструктурной Go-ошибкой.
|
||||
var (
|
||||
// ErrNoConfig — не найден ни один конфиг (ни `-config`, ни OMNI_CONFIG_DIR).
|
||||
ErrNoConfig = errors.New("конфиг не найден: задайте -config или per-agent omnichannel-mcp.json")
|
||||
// ErrReadOnly — модуль в режиме только-чтение, мутация запрещена.
|
||||
ErrReadOnly = errors.New("модуль в режиме read_only: изменяющие инструменты отключены")
|
||||
// ErrConfirmRequired — разрушительная операция без confirm=\"true\".
|
||||
ErrConfirmRequired = errors.New("операция требует confirm=\"true\"")
|
||||
// ErrNoFrontRoots — не заданы front_roots, загрузка фронта запрещена.
|
||||
ErrNoFrontRoots = errors.New("загрузка фронта выключена: не заданы front_roots в конфиге")
|
||||
)
|
||||
|
||||
// APIError — доменная ошибка API config_server (4xx/5xx с телом {"error": ...}).
|
||||
// Такие ошибки показываются модели (recoverable), чтобы она могла исправиться.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message == "" {
|
||||
return fmt.Sprintf("config_server вернул HTTP %d", e.Status)
|
||||
}
|
||||
return fmt.Sprintf("config_server (HTTP %d): %s", e.Status, e.Message)
|
||||
}
|
||||
|
||||
// ValidationError — доменная ошибка модуля (невалидный путь, лимит, политика).
|
||||
// Тоже recoverable: модель может исправить аргументы.
|
||||
type ValidationError struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string { return e.Msg }
|
||||
|
||||
// validationErrorf создаёт ValidationError с форматированием.
|
||||
func validationErrorf(format string, args ...any) error {
|
||||
return &ValidationError{Msg: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.totmin.ru/en2zmax/forge-toolkit/configreload"
|
||||
)
|
||||
|
||||
// tenantFileName — имя файла конфига внутри каталога OMNI_CONFIG_DIR
|
||||
// (например, при постраничной/per-call передаче конфига).
|
||||
const tenantFileName = "omnichannel-mcp.json"
|
||||
|
||||
// Manager — одиночный потокобезопасный диспетчер процесса (ARCHITECTURE.md §2).
|
||||
// Кэширует per-путь загрузчики конфига (live-reload по контент-хэшу), сессии
|
||||
// (отдельно rw/ro) и семафоры на мутации. Конкурентный доступ агентов безопасен.
|
||||
type Manager struct {
|
||||
staticPath string
|
||||
|
||||
mu sync.Mutex
|
||||
loaders map[string]*configreload.Loader[*Config]
|
||||
sessions map[string]*Session
|
||||
sems map[string]chan struct{}
|
||||
}
|
||||
|
||||
// NewManager создаёт Manager. staticPath — путь из `-config` (основной режим);
|
||||
// пусто — путь ищется в OMNI_CONFIG_DIR.
|
||||
func NewManager(staticPath string) *Manager {
|
||||
return &Manager{
|
||||
staticPath: staticPath,
|
||||
loaders: map[string]*configreload.Loader[*Config]{},
|
||||
sessions: map[string]*Session{},
|
||||
sems: map[string]chan struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePath выбирает путь конфига по приоритету: явный путь вызова →
|
||||
// каталог из окружения (OMNI_CONFIG_DIR, затем legacy FORGE_TENANT_CONFIG) →
|
||||
// статический `-config`. Если переменная задаёт каталог, в нём берётся
|
||||
// tenantFileName.
|
||||
func (m *Manager) ResolvePath(tenantPath string) string {
|
||||
if tenantPath != "" {
|
||||
return tenantPath
|
||||
}
|
||||
for _, env := range []string{"OMNI_CONFIG_DIR", "FORGE_TENANT_CONFIG"} {
|
||||
if dir := os.Getenv(env); dir != "" {
|
||||
return filepath.Join(dir, tenantFileName)
|
||||
}
|
||||
}
|
||||
return m.staticPath
|
||||
}
|
||||
|
||||
// Config загружает конфиг (live-reload) и возвращает его вместе с путём.
|
||||
// Отсутствие файла — fail-closed (ErrNoConfig); битый файл с last-good —
|
||||
// работаем со старым значением и логируем.
|
||||
func (m *Manager) Config(tenantPath string) (*Config, string, error) {
|
||||
path := m.ResolvePath(tenantPath)
|
||||
if path == "" {
|
||||
return nil, "", ErrNoConfig
|
||||
}
|
||||
|
||||
loader := m.loaderFor(path)
|
||||
cfg, err := loader.Get()
|
||||
if err != nil {
|
||||
if errors.Is(err, configreload.ErrNotFound) {
|
||||
return nil, path, fmt.Errorf("%w (файл %s)", ErrNoConfig, path)
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, path, err
|
||||
}
|
||||
// last-good: продолжаем на прежнем конфиге, но сигналим в лог.
|
||||
slog.Warn("конфиг изменён к невалидному, работаем на последнем рабочем", "path", path, "error", err)
|
||||
}
|
||||
return cfg, path, nil
|
||||
}
|
||||
|
||||
// Open собирает фасад API для вызова: резолвит конфиг и сервер, проверяет
|
||||
// read_only, поднимает сессию и (для мутаций) берёт семафор конкурентности.
|
||||
// Close обязателен.
|
||||
func (m *Manager) Open(ctx context.Context, tenantPath, alias string, write bool) (*API, error) {
|
||||
cfg, path, err := m.Config(tenantPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv, err := cfg.Server(alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if write && cfg.ReadOnly {
|
||||
return nil, ErrReadOnly
|
||||
}
|
||||
|
||||
// Read-инструменты используют read-only креды, если они заданы.
|
||||
sess, err := m.session(path, srv, !write)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
api := &API{
|
||||
CfgPath: path,
|
||||
Srv: *srv,
|
||||
Config: cfg,
|
||||
Sess: sess,
|
||||
Write: write,
|
||||
}
|
||||
if write {
|
||||
sem := m.semaphore(path, srv.Alias, srv.MaxConcurrentMutations)
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
api.sem, api.held = sem, true
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// loaderFor возвращает (и кэширует) live-reload загрузчик по пути.
|
||||
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, func([]byte) (*Config, error) { return LoadFile(path) })
|
||||
m.loaders[path] = l
|
||||
return l
|
||||
}
|
||||
|
||||
// session возвращает (и кэширует) сессию по (путь, алиас, rw/ro).
|
||||
func (m *Manager) session(path string, srv *Server, readonly bool) (*Session, error) {
|
||||
key := path + "|" + srv.Alias + "|" + strconv.FormatBool(readonly)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.sessions[key]; ok {
|
||||
return s, nil
|
||||
}
|
||||
s, err := NewSession(srv, readonly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.sessions[key] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// semaphore возвращает (и кэширует) семафор конкурентных мутаций на сервер.
|
||||
func (m *Manager) semaphore(path, alias string, n int) chan struct{} {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
key := path + "|" + alias
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
ch, ok := m.sems[key]
|
||||
if !ok || cap(ch) != n {
|
||||
ch = make(chan struct{}, n)
|
||||
m.sems[key] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// Close закрывает все сессии (graceful shutdown).
|
||||
func (m *Manager) Close() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessions = nil
|
||||
m.sems = nil
|
||||
}
|
||||
|
||||
// API — фасад одного вызова: конфиг сервера + авторизованная сессия + лимиты.
|
||||
// Методы API (см. api_*.go) соответствуют эндпоинтам config_server v1.1.0.
|
||||
type API struct {
|
||||
CfgPath string
|
||||
Srv Server
|
||||
Config *Config
|
||||
Sess *Session
|
||||
Write bool
|
||||
|
||||
sem chan struct{}
|
||||
held bool
|
||||
}
|
||||
|
||||
// Close освобождает семафор мутаций (если он был взят). Идемпотентен.
|
||||
func (a *API) Close() {
|
||||
if a.held {
|
||||
<-a.sem
|
||||
a.held = false
|
||||
}
|
||||
}
|
||||
|
||||
// Alias возвращает алиас сервера (для сообщений).
|
||||
func (a *API) Alias() string { return a.Srv.Alias }
|
||||
|
||||
// withTimeout накладывает таймаут сервера на вызов.
|
||||
func (a *API) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, time.Duration(a.Srv.TimeoutSec)*time.Second)
|
||||
}
|
||||
|
||||
// withUploadTimeout — увеличенный таймаут для загрузки фронта.
|
||||
func (a *API) withUploadTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(ctx, time.Duration(a.Srv.UploadTimeoutSec)*time.Second)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package configserver
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Типизированные модели ответов config_server v1.1.0. Используются и для
|
||||
// разбора, и для отдачи модели — так схема ответа остаётся явной и стабильной.
|
||||
|
||||
// Application — краткая запись сервиса (GET /api/applications).
|
||||
type Application struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HostIP string `json:"host_ip"`
|
||||
Hostname string `json:"hostname"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
IsFront bool `json:"is_front"`
|
||||
BuildFolder string `json:"build_folder"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ComposeVersionRef — ссылка на версию compose в детализации приложения.
|
||||
type ComposeVersionRef struct {
|
||||
ID int `json:"id"`
|
||||
Version int `json:"version"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// MigrationLog — запись журнала миграций приложения.
|
||||
type MigrationLog struct {
|
||||
ID int `json:"id"`
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
Output string `json:"output"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// ApplicationDetail — расширенная детализация (GET /api/application/<id>).
|
||||
type ApplicationDetail struct {
|
||||
Application
|
||||
CurrentCompose string `json:"current_compose"`
|
||||
ComposeVersions []ComposeVersionRef `json:"compose_versions"`
|
||||
EnvFiles []string `json:"env_files"`
|
||||
CurrentEnvFiles map[string]string `json:"current_env_files"`
|
||||
MigrationLogs []MigrationLog `json:"migration_logs"`
|
||||
}
|
||||
|
||||
// RawConfig — текущие compose/env приложения (GET /api/get_config/<id>).
|
||||
type RawConfig struct {
|
||||
Compose string `json:"compose"`
|
||||
Env string `json:"env"`
|
||||
}
|
||||
|
||||
// ComposeVersion — конкретная версия compose (GET /api/compose_version/<id>).
|
||||
type ComposeVersion struct {
|
||||
ID int `json:"id"`
|
||||
Version int `json:"version"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
// EnvVersion — конкретная версия env-файла (GET /api/env_version/<id>).
|
||||
type EnvVersion struct {
|
||||
ID int `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Version int `json:"version"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
// Task — задача агента (GET /api/tasks, /api/task/<id>, /api/deployment/tasks).
|
||||
type Task struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ApplicationID int `json:"application_id"`
|
||||
ApplicationName string `json:"application_name"`
|
||||
TaskType string `json:"task_type"`
|
||||
Command string `json:"command"`
|
||||
Status string `json:"status"`
|
||||
HostIP string `json:"host_ip"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
Output string `json:"output"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
}
|
||||
|
||||
// Stats — счётчики версий (GET /api/stats).
|
||||
type Stats struct {
|
||||
ComposeVersions int `json:"compose_versions"`
|
||||
EnvVersions int `json:"env_versions"`
|
||||
MigrationLogs int `json:"migration_logs"`
|
||||
}
|
||||
|
||||
// ServiceInfo — сервис в карте серверов.
|
||||
type ServiceInfo struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
Active bool `json:"active"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ServerNode — хост и его сервисы в карте (GET /api/service_map).
|
||||
type ServerNode struct {
|
||||
HostIP string `json:"host_ip"`
|
||||
Hostname string `json:"hostname"`
|
||||
TotalServices int `json:"total_services"`
|
||||
ActiveServices int `json:"active_services"`
|
||||
Services []ServiceInfo `json:"services"`
|
||||
}
|
||||
|
||||
// ServiceMap — ответ GET /api/service_map.
|
||||
type ServiceMap struct {
|
||||
Servers []ServerNode `json:"servers"`
|
||||
ServersCount int `json:"servers_count"`
|
||||
ServicesCount int `json:"services_count"`
|
||||
}
|
||||
|
||||
// DeploymentFiles — schema/manifest/prebuild_vars/ip_overrides.
|
||||
type DeploymentFiles struct {
|
||||
Schema string `json:"schema"`
|
||||
Manifest string `json:"manifest"`
|
||||
PrebuildVars string `json:"prebuild_vars"`
|
||||
IPOverrides string `json:"ip_overrides"`
|
||||
}
|
||||
|
||||
// ReleaseJob — статус фоновой загрузки релиза (GET /api/deployment/release_job).
|
||||
type ReleaseJob struct {
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase"`
|
||||
ImagesPulled int `json:"images_pulled"`
|
||||
ImagesTotal int `json:"images_total"`
|
||||
Message string `json:"message"`
|
||||
Output string `json:"output"`
|
||||
Stderr string `json:"stderr"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// IPMatch — диагностика сопоставления IP схемы и агентов.
|
||||
type IPMatch struct {
|
||||
SchemaIPs []string `json:"schema_ips"`
|
||||
AgentIPsInDB []string `json:"agent_ips_in_db"`
|
||||
IPOverrides map[string]string `json:"ip_overrides"`
|
||||
MatchedPairs map[string]string `json:"matched_pairs"`
|
||||
UnmatchedSchemaIPs []string `json:"unmatched_schema_ips"`
|
||||
}
|
||||
|
||||
// TaskAck — ответ операций жизненного цикла (deploy/restart/down/migrate/update_config).
|
||||
type TaskAck struct {
|
||||
Status string `json:"status"`
|
||||
AppID int `json:"app_id"`
|
||||
AppName string `json:"app_name"`
|
||||
HostIP string `json:"host_ip"`
|
||||
TaskID string `json:"task_id"`
|
||||
LogID int `json:"log_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ConfigAck — ответ установки compose/env.
|
||||
type ConfigAck struct {
|
||||
Status string `json:"status"`
|
||||
AppID int `json:"app_id"`
|
||||
Version int `json:"version"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// StatusAck — простой ответ {"status": "..."} c опциональным сообщением.
|
||||
type StatusAck struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// FrontAck — ответ update_front.
|
||||
type FrontAck struct {
|
||||
Status string `json:"status"`
|
||||
AppID int `json:"app_id"`
|
||||
BuildFolder string `json:"build_folder"`
|
||||
TaskID string `json:"task_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CleanupAck — ответ cleanup_old_versions.
|
||||
type CleanupAck struct {
|
||||
Status string `json:"status"`
|
||||
DeletedComposeVersions int `json:"deleted_compose_versions"`
|
||||
DeletedEnvVersions int `json:"deleted_env_versions"`
|
||||
DeletedMigrationLogs int `json:"deleted_migration_logs"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SeedHostsAck — ответ seed_hosts.
|
||||
type SeedHostsAck struct {
|
||||
Status string `json:"status"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
IPs []string `json:"ips"`
|
||||
}
|
||||
|
||||
// DownloadReleaseAck — ответ download_release (синхронный и фоновый).
|
||||
type DownloadReleaseAck struct {
|
||||
Status string `json:"status"`
|
||||
LoadImages bool `json:"load_images"`
|
||||
Message string `json:"message"`
|
||||
DownloadedServices []string `json:"downloaded_services"`
|
||||
SyncTaskIDs []string `json:"sync_task_ids"`
|
||||
}
|
||||
|
||||
// StartServicesAck — ответ start_services.
|
||||
type StartServicesAck struct {
|
||||
Status string `json:"status"`
|
||||
StartTaskIDs []string `json:"start_task_ids"`
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxResponseBytes — жёсткий предохранитель на размер тела ответа (защита от
|
||||
// OOM). Доменные ответы (compose/env/schema) заметно меньше.
|
||||
const maxResponseBytes = 16 << 20 // 16 MiB
|
||||
|
||||
// Session — авторизованная HTTP-сессия к одному config_server.
|
||||
//
|
||||
// Авторизация устроена как Flask-сессия: login кладёт подписанную cookie, она
|
||||
// хранится в cookiejar и автоматически прикладывается к запросам. Сессия
|
||||
// потокобезопасна: login выполняется один раз (single-flight), при 401 cookie
|
||||
// пересоздаётся и запрос повторяется ровно один раз.
|
||||
type Session struct {
|
||||
baseURL string
|
||||
username string
|
||||
password string
|
||||
label string // alias (+ "(ro)") для сообщений об ошибках
|
||||
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
loggedIn bool
|
||||
}
|
||||
|
||||
// response — низкоуровневый ответ (тело уже прочитано и ограничено).
|
||||
type response struct {
|
||||
status int
|
||||
body []byte
|
||||
ctype string
|
||||
}
|
||||
|
||||
// NewSession создаёт сессию для сервера. readonly=true выбирает отдельную
|
||||
// read-only учётную запись, если она задана (иначе — основная).
|
||||
func NewSession(srv *Server, readonly bool) (*Session, error) {
|
||||
user, pass := srv.Username, srv.Password
|
||||
label := srv.Alias
|
||||
if readonly && srv.ReadonlyUsername != "" {
|
||||
user, pass = srv.ReadonlyUsername, srv.ReadonlyPassword
|
||||
label += "(ro)"
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cookie jar: %w", err)
|
||||
}
|
||||
// #nosec G402 — InsecureSkipVerify включается осознанно оператором для
|
||||
// стендов с самоподписанными сертификатами (флаг в конфиге).
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: srv.InsecureSkipVerify},
|
||||
}
|
||||
return &Session{
|
||||
baseURL: srv.BaseURL,
|
||||
username: user,
|
||||
password: pass,
|
||||
label: label,
|
||||
client: &http.Client{Jar: jar, Transport: transport},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BaseURL возвращает адрес сервера сессии.
|
||||
func (s *Session) BaseURL() string { return s.baseURL }
|
||||
|
||||
// ensureLogin выполняет login, если сессия ещё не авторизована.
|
||||
func (s *Session) ensureLogin(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.loggedIn {
|
||||
return nil
|
||||
}
|
||||
return s.loginLocked(ctx)
|
||||
}
|
||||
|
||||
// loginLocked — login под уже взятым мьютексом (single-flight).
|
||||
func (s *Session) loginLocked(ctx context.Context) error {
|
||||
if s.username == "" || s.password == "" {
|
||||
return &APIError{Status: http.StatusUnauthorized,
|
||||
Message: fmt.Sprintf("для сервера %s не заданы username/password", s.label)}
|
||||
}
|
||||
resp, err := s.once(ctx, http.MethodPost, "/api/login", nil,
|
||||
map[string]string{"username": s.username, "password": s.password})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := classify(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
s.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// invalidate сбрасывает признак авторизации (cookie могла истечь).
|
||||
func (s *Session) invalidate() {
|
||||
s.mu.Lock()
|
||||
s.loggedIn = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// getJSON выполняет авторизованный GET и разбирает JSON в out.
|
||||
func (s *Session) getJSON(ctx context.Context, path string, query url.Values, out any) error {
|
||||
resp, err := s.do(ctx, http.MethodGet, path, query, nil, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := classify(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return decodeJSON(resp, out)
|
||||
}
|
||||
|
||||
// postJSON выполняет авторизованный POST с JSON-телом и разбирает ответ.
|
||||
func (s *Session) postJSON(ctx context.Context, path string, body, out any) error {
|
||||
resp, err := s.do(ctx, http.MethodPost, path, nil, body, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := classify(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return decodeJSON(resp, out)
|
||||
}
|
||||
|
||||
// getPublic выполняет GET без авторизации (например, /health).
|
||||
func (s *Session) getPublic(ctx context.Context, path string) (*response, error) {
|
||||
return s.do(ctx, http.MethodGet, path, nil, nil, false)
|
||||
}
|
||||
|
||||
// do выполняет запрос с автоматическим повтором: один релогin при 401 и
|
||||
// (только для GET) один повтор при сетевом сбое или 5xx. Мутации НЕ
|
||||
// повторяются, чтобы не выполнить действие дважды.
|
||||
func (s *Session) do(ctx context.Context, method, path string, query url.Values, body any, authenticated bool) (*response, error) {
|
||||
if authenticated {
|
||||
if err := s.ensureLogin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
attempts := 1
|
||||
if method == http.MethodGet {
|
||||
attempts = 2
|
||||
}
|
||||
|
||||
var resp *response
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
r, err := s.once(ctx, method, path, query, body)
|
||||
if err != nil {
|
||||
if attempt < attempts && ctx.Err() == nil {
|
||||
if err := sleepBackoff(ctx, attempt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
resp = r
|
||||
|
||||
if authenticated && resp.status == http.StatusUnauthorized {
|
||||
// Cookie-сессия истекла: перелогиниваемся и повторяем ровно один раз.
|
||||
s.invalidate()
|
||||
if lerr := s.ensureLogin(ctx); lerr != nil {
|
||||
return resp, nil
|
||||
}
|
||||
if r2, err2 := s.once(ctx, method, path, query, body); err2 == nil {
|
||||
return r2, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
if attempt < attempts && resp.status >= 500 {
|
||||
if err := sleepBackoff(ctx, attempt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// once выполняет ровно один HTTP-запрос.
|
||||
func (s *Session) once(ctx context.Context, method, path string, query url.Values, body any) (*response, error) {
|
||||
u := s.baseURL + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("кодирование тела запроса: %w", err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("сборка запроса: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", method, path, err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение ответа %s: %w", path, err)
|
||||
}
|
||||
return &response{status: res.StatusCode, body: data, ctype: res.Header.Get("Content-Type")}, nil
|
||||
}
|
||||
|
||||
// postMultipart загружает файлы как multipart/form-data (update_front). Файлы
|
||||
// передаются потоково из памяти вызывающего; имена — относительные пути.
|
||||
func (s *Session) postMultipart(ctx context.Context, path string, files []UploadFile) (json.RawMessage, error) {
|
||||
if err := s.ensureLogin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
for _, f := range files {
|
||||
part, err := writer.CreateFormFile("files", f.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("multipart: %w", err)
|
||||
}
|
||||
if _, err := part.Write(f.Data); err != nil {
|
||||
return nil, fmt.Errorf("multipart: %w", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("multipart: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, &buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("сборка запроса: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
res, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("POST %s: %w", path, err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("чтение ответа %s: %w", path, err)
|
||||
}
|
||||
resp := &response{status: res.StatusCode, body: data, ctype: res.Header.Get("Content-Type")}
|
||||
if err := classify(resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.RawMessage(data), nil
|
||||
}
|
||||
|
||||
// UploadFile — файл для multipart-загрузки фронта.
|
||||
type UploadFile struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// classify превращает HTTP-статус ≥400 в доменную APIError с текстом сервера.
|
||||
// Текст берём из JSON-поля "error"; HTML/мусорные тела заменяем кратким
|
||||
// сообщением, чтобы не засорять контекст модели.
|
||||
func classify(resp *response) error {
|
||||
if resp.status < 400 {
|
||||
return nil
|
||||
}
|
||||
var payload struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(resp.body, &payload)
|
||||
msg := strings.TrimSpace(payload.Error)
|
||||
if msg == "" {
|
||||
msg = defaultHTTPMessage(resp.status)
|
||||
}
|
||||
return &APIError{Status: resp.status, Message: msg}
|
||||
}
|
||||
|
||||
// defaultHTTPMessage — краткое человекочитаемое описание статуса.
|
||||
func defaultHTTPMessage(status int) string {
|
||||
switch status {
|
||||
case 400:
|
||||
return "неверный запрос (400)"
|
||||
case 401:
|
||||
return "не авторизован (401)"
|
||||
case 404:
|
||||
return "не найдено (404)"
|
||||
case 409:
|
||||
return "конфликт (409)"
|
||||
case 500:
|
||||
return "внутренняя ошибка сервера (500)"
|
||||
default:
|
||||
return fmt.Sprintf("HTTP %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(resp *response, out any) error {
|
||||
if out == nil || len(resp.body) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(resp.body, out); err != nil {
|
||||
return fmt.Errorf("разбор ответа сервера: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sleepBackoff делает паузу перед повтором, уважая отмену контекста.
|
||||
func sleepBackoff(ctx context.Context, attempt int) error {
|
||||
d := time.Duration(200*attempt) * time.Millisecond
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsDomainError сообщает, относится ли ошибка к доменным (config_server вернул
|
||||
// 4xx/5xx, ошибка валидации модуля или политики вроде отсутствия front_roots),
|
||||
// а не к инфраструктурным (сеть). Доменные — recoverable: текст видит модель.
|
||||
func IsDomainError(err error) bool {
|
||||
var apiErr *APIError
|
||||
var valErr *ValidationError
|
||||
if errors.As(err, &apiErr) || errors.As(err, &valErr) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, ErrNoFrontRoots)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package configserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockState — разделяемое состояние тестового config_server.
|
||||
type mockState struct {
|
||||
mu sync.Mutex
|
||||
logins int
|
||||
failOnceApps bool
|
||||
flakyCalls int
|
||||
mutationCalls int
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *mockState) {
|
||||
t.Helper()
|
||||
st := &mockState{}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
writeJSON := func(w http.ResponseWriter, code int, body string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["username"] != "u" || body["password"] != "p" {
|
||||
writeJSON(w, http.StatusUnauthorized, `{"error":"Invalid username or password"}`)
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.logins++
|
||||
st.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "sess", Value: "ok", Path: "/"})
|
||||
writeJSON(w, http.StatusOK, `{"status":"ok","username":"u"}`)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/applications", func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("sess"); err != nil || c.Value != "ok" {
|
||||
writeJSON(w, http.StatusUnauthorized, `{"error":"Authentication required"}`)
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
fail := st.failOnceApps
|
||||
st.failOnceApps = false
|
||||
st.mu.Unlock()
|
||||
if fail {
|
||||
writeJSON(w, http.StatusUnauthorized, `{"error":"Authentication required"}`)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, `[{"id":1,"name":"x","status":"OK"}]`)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/flaky", func(w http.ResponseWriter, r *http.Request) {
|
||||
st.mu.Lock()
|
||||
st.flakyCalls++
|
||||
n := st.flakyCalls
|
||||
st.mu.Unlock()
|
||||
if n == 1 {
|
||||
writeJSON(w, http.StatusInternalServerError, `{"error":"boom"}`)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, `{"ok":true}`)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/mutate", func(w http.ResponseWriter, r *http.Request) {
|
||||
st.mu.Lock()
|
||||
st.mutationCalls++
|
||||
st.mu.Unlock()
|
||||
writeJSON(w, http.StatusInternalServerError, `{"error":"boom"}`)
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, st
|
||||
}
|
||||
|
||||
func newTestSession(t *testing.T, url string) *Session {
|
||||
t.Helper()
|
||||
s, err := NewSession(&Server{Alias: "t", BaseURL: url, Username: "u", Password: "p"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSessionLoginAndGet(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
s := newTestSession(t, ts.URL)
|
||||
|
||||
var apps []Application
|
||||
if err := s.getJSON(context.Background(), "/api/applications", nil, &apps); err != nil {
|
||||
t.Fatalf("getJSON: %v", err)
|
||||
}
|
||||
if len(apps) != 1 || apps[0].Name != "x" {
|
||||
t.Fatalf("неожиданный ответ: %+v", apps)
|
||||
}
|
||||
if st.logins != 1 {
|
||||
t.Fatalf("logins = %d, ожидалось 1", st.logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionReloginOn401(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
s := newTestSession(t, ts.URL)
|
||||
st.failOnceApps = true
|
||||
|
||||
var apps []Application
|
||||
if err := s.getJSON(context.Background(), "/api/applications", nil, &apps); err != nil {
|
||||
t.Fatalf("getJSON после relogin: %v", err)
|
||||
}
|
||||
if st.logins != 2 {
|
||||
t.Fatalf("logins = %d, ожидалось 2 (релогin при 401)", st.logins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRetriedOn5xx(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
s := newTestSession(t, ts.URL)
|
||||
|
||||
var out map[string]any
|
||||
if err := s.getJSON(context.Background(), "/api/flaky", nil, &out); err != nil {
|
||||
t.Fatalf("getJSON flaky: %v", err)
|
||||
}
|
||||
if st.flakyCalls != 2 {
|
||||
t.Fatalf("GET-повтор не сработал: calls = %d", st.flakyCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationNotRetried(t *testing.T) {
|
||||
ts, st := newTestServer(t)
|
||||
s := newTestSession(t, ts.URL)
|
||||
|
||||
var out map[string]any
|
||||
err := s.postJSON(context.Background(), "/api/mutate", map[string]string{"x": "y"}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка 500")
|
||||
}
|
||||
if st.mutationCalls != 1 {
|
||||
t.Fatalf("мутация НЕ должна повторяться: calls = %d", st.mutationCalls)
|
||||
}
|
||||
if !IsDomainError(err) {
|
||||
t.Fatalf("ошибка 500 должна классифицироваться как доменная: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// configure.go — изменяющие инструменты конфигурации (compose/env и версии).
|
||||
// Требуют read_only=false и обычно помечаются require_approval в agent.yaml.
|
||||
|
||||
func registerConfigureTools(s *mcp.Server) {
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "set_compose",
|
||||
Description: "Заменить текущий docker-compose сервиса, создать версию и отправить агенту. Изменяет конфигурацию.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
"content": strProps("Полный текст docker-compose (YAML)", true),
|
||||
}, []string{"app_id", "content"}),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
content, err := requireString(args, "content")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.SetCompose(ctx, id, content))
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "set_env",
|
||||
Description: "Заменить текущий env-файл сервиса (например .env), создать версию и отправить агенту. Изменяет конфигурацию.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
"filename": strProps("Имя env-файла, например .env", true),
|
||||
"content": strProps("Полное содержимое env-файла", true),
|
||||
}, []string{"app_id", "filename", "content"}),
|
||||
}, envPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
filename, err := requireString(args, "filename")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
content, err := requireString(args, "content")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.SetEnv(ctx, id, filename, content))
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "restore_compose",
|
||||
Description: "Восстановить версию compose (создаёт новую текущую версию) и отправить агенту. Разрушительно: требует confirm=\"true\".",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"version_id": intProps("Идентификатор версии compose", true),
|
||||
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||
}, []string{"version_id", "confirm"}),
|
||||
}, versionPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
if res := confirmGate(args, "restore_compose"); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
id, res := versionIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.RestoreCompose(ctx, id))
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "restore_env_version",
|
||||
Description: "Восстановить версию env-файла (создаёт новую текущую версию) и отправить агенту. Разрушительно: требует confirm=\"true\".",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"version_id": intProps("Идентификатор версии env", true),
|
||||
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||
}, []string{"version_id", "confirm"}),
|
||||
}, versionPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
if res := confirmGate(args, "restore_env_version"); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
id, res := versionIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.RestoreEnvVersion(ctx, id))
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "update_config",
|
||||
Description: "Повторно отправить агенту текущую конфигурацию сервиса из БД (compose + env). Изменяет файлы на хосте.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
}, []string{"app_id"}),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.UpdateConfig(ctx, id))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// maintain.go — обслуживание: очистка старых версий.
|
||||
|
||||
func registerMaintainTools(s *mcp.Server) {
|
||||
// cleanup_old_versions не эмитит паттерн по конкретному ресурсу: операция
|
||||
// затрагивает всю БД версий, поэтому используем release-паттерн стенда.
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "cleanup_old_versions",
|
||||
Description: "Удалить версии compose/env и логи миграций старше N дней (текущие версии не трогаются). Разрушительно: требует confirm=\"true\".",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"days": intProps("Порог в днях (по умолчанию 90)", false),
|
||||
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||
}, []string{"confirm"}),
|
||||
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
if res := confirmGate(args, "cleanup_old_versions"); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
days := getInt(args, "days", 90)
|
||||
if days <= 0 {
|
||||
return errorResult("days должен быть положительным"), nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.CleanupOldVersions(ctx, days))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"omnichannel-mcp/internal/configserver"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// observe.go — read-only инструменты: наблюдение, диагностика, аудит.
|
||||
|
||||
// observeCall — общий каркас read-инструмента: открыть API, вызвать домен,
|
||||
// вернуть результат. Убирает повторяющуюся обвязку из хендлеров.
|
||||
func observeCall(ctx context.Context, req *mcp.CallToolRequest, call func(*configserver.API) (any, error)) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
api, res := openAPI(ctx, args, false)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
v, err := call(api)
|
||||
return result(v, err)
|
||||
}
|
||||
|
||||
func registerObserveTools(s *mcp.Server) {
|
||||
registerHealthTools(s)
|
||||
registerApplicationTools(s)
|
||||
registerConfigReadTools(s)
|
||||
registerTaskTools(s)
|
||||
registerDeploymentReadTools(s)
|
||||
registerSettingsTools(s)
|
||||
}
|
||||
|
||||
func registerHealthTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "server_health",
|
||||
Description: "Живость config_server (GET /health, без авторизации). Read-only. Помогает отличить проблему стенда от проблемы авторизации.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
status, body, err := api.Health(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"server": api.Alias(), "http_status": status, "body": body}, nil
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "whoami",
|
||||
Description: "Проверить авторизацию: кто залогинен на config_server. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.WhoAmI(ctx)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "server_info",
|
||||
Description: "Сведения о сервере и версии API (capability-probe): алиас, адрес, режим read_only, доступные JSON-эндпоинты. Read-only. Вызывай первым при неясностях.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
caps, err := api.Probe(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"server": api.Alias(),
|
||||
"base_url": api.Srv.BaseURL,
|
||||
"read_only": api.Config.ReadOnly,
|
||||
"readonly_creds": api.Srv.ReadonlyUsername != "",
|
||||
"capabilities": caps,
|
||||
}, nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func registerApplicationTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "list_applications",
|
||||
Description: "Список сервисов с хостом и статусом. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"include_presence": boolProps("Включить служебные записи хостов (__agent_presence__)", false),
|
||||
}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.ListApplications(ctx, getBool(args, "include_presence", false))
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "get_application",
|
||||
Description: "Детализация сервиса: статус, текущий compose, версии compose, env-файлы и их содержимое, журнал миграций. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
}, []string{"app_id"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.GetApplication(ctx, id)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func registerConfigReadTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "get_config",
|
||||
Description: "Текущие compose и env сервиса из БД. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
}, []string{"app_id"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.GetConfig(ctx, id)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "env_files",
|
||||
Description: "Список текущих env-файлов сервиса. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
}, []string{"app_id"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.EnvFiles(ctx, id)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "compose_version",
|
||||
Description: "Содержимое конкретной версии compose по version_id. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"version_id": intProps("Идентификатор версии compose", true),
|
||||
}, []string{"version_id"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := versionIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.ComposeVersion(ctx, id)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "env_version",
|
||||
Description: "Содержимое конкретной версии env-файла по version_id. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"version_id": intProps("Идентификатор версии env", true),
|
||||
}, []string{"version_id"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := versionIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.EnvVersion(ctx, id)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "env_versions",
|
||||
Description: "Все версии конкретного env-файла сервиса. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
"filename": strProps("Имя env-файла, например .env", true),
|
||||
}, []string{"app_id", "filename"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
filename, err := requireString(args, "filename")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.EnvVersions(ctx, id, filename)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func registerTaskTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "list_tasks",
|
||||
Description: "Список задач агентов с фильтрами (аудит, поиск failed/pending). Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"host_ip": strProps("Фильтр по IP хоста", false),
|
||||
"status": strProps("Фильтр по статусу", false, "pending", "in_progress", "completed", "failed"),
|
||||
"task_type": strProps("Фильтр по типу задачи (deploy, restart, down, migration, update_config, release_sync, release_start, update_front)", false),
|
||||
"limit": intProps("Максимум записей (1..500, по умолчанию 100)", false),
|
||||
}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
filter := configserver.TaskFilter{
|
||||
HostIP: getString(args, "host_ip", ""),
|
||||
Status: getString(args, "status", ""),
|
||||
TaskType: getString(args, "task_type", ""),
|
||||
Limit: getInt(args, "limit", 100),
|
||||
}
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.ListTasks(ctx, filter)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "get_task",
|
||||
Description: "Статус и вывод задачи. wait=true — дождаться завершения (ограничено task_poll_max_sec); иначе вернуть текущее состояние. Read-only.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"task_id": strProps("UUID задачи", true),
|
||||
"wait": boolProps("Дождаться завершения (по умолчанию false)", false),
|
||||
"tail": boolProps("Оставить хвост вывода при обрезке (по умолчанию true — ошибки обычно в конце)", false),
|
||||
"output_bytes": intProps("Лимит вывода в байтах (по умолчанию — max_output_bytes сервера)", false),
|
||||
}, []string{"task_id"}),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
taskID, err := requireString(args, "task_id")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
wait := getBool(args, "wait", false)
|
||||
tail := getBool(args, "tail", true)
|
||||
limit := getInt(args, "output_bytes", 0)
|
||||
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
var task *configserver.Task
|
||||
if wait {
|
||||
task, err = api.WaitTask(ctx, taskID)
|
||||
} else {
|
||||
task, err = api.GetTask(ctx, taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = api.Srv.MaxOutputBytes
|
||||
}
|
||||
out := *task
|
||||
out.Output, _ = truncateText(out.Output, limit, tail)
|
||||
return out, nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func registerDeploymentReadTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "get_deployment_files",
|
||||
Description: "Файлы развёртывания: schema, manifest, prebuild_vars, ip_overrides. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.DeploymentFiles(ctx)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "ip_match",
|
||||
Description: "Диагностика сопоставления IP из schema.json с IP агентов в БД. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.IPMatch(ctx)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "release_job",
|
||||
Description: "Статус фоновой загрузки релиза (status: idle/running/completed/failed, фаза, прогресс образов). Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.ReleaseJob(ctx)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "deployment_tasks",
|
||||
Description: "Последние release-задачи (sync/start). Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.DeploymentTasks(ctx)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func registerSettingsTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "stats",
|
||||
Description: "Статистика: количество версий compose/env и логов миграций. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.Stats(ctx)
|
||||
})
|
||||
})
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "service_map",
|
||||
Description: "Карта хостов и сервисов. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
return api.ServiceMap(ctx)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"omnichannel-mcp/internal/configserver"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// operate.go — жизненный цикл сервиса (deploy/restart/down/migrate) и загрузка
|
||||
// фронта. Мутации; обычно require_approval в agent.yaml.
|
||||
|
||||
func registerOperateTools(s *mcp.Server) {
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "deploy",
|
||||
Description: "Поднять сервис (docker-compose up -d). Создаёт задачу агенту; wait=true — дождаться результата.",
|
||||
InputSchema: lifecycleSchema(),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return runLifecycle(ctx, req, "deploy", false)
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "restart",
|
||||
Description: "Перезапустить сервис (down + up -d). Создаёт задачу агенту; wait=true — дождаться результата.",
|
||||
InputSchema: lifecycleSchema(),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return runLifecycle(ctx, req, "restart", false)
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "migrate",
|
||||
Description: "Запустить миграции сервиса (docker-compose run migration). Создаёт задачу агенту.",
|
||||
InputSchema: lifecycleSchema(),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return runLifecycle(ctx, req, "migrate", false)
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "down",
|
||||
Description: "Остановить сервис (docker-compose down). Разрушительно: требует confirm=\"true\".",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
"wait": boolProps("Дождаться завершения (по умолчанию false)", false),
|
||||
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||
}, []string{"app_id", "confirm"}),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return runLifecycle(ctx, req, "down", true)
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "update_front",
|
||||
Description: "Загрузить локальную сборку фронта на хост сервиса (полная замена build_folder). Разрушительно: требует confirm=\"true\". Каталог — внутри front_roots.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор фронт-сервиса", true),
|
||||
"build_dir": strProps("Локальный каталог сборки (абсолютный путь внутри front_roots)", true),
|
||||
"confirm": boolProps("Подтверждение разрушительной операции (обязательно true)", true),
|
||||
}, []string{"app_id", "build_dir", "confirm"}),
|
||||
}, appPatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
if res := confirmGate(args, "update_front"); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
buildDir, err := requireString(args, "build_dir")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.UpdateFront(ctx, id, buildDir))
|
||||
})
|
||||
}
|
||||
|
||||
// lifecycleSchema — общая схема deploy/restart/migrate/down.
|
||||
func lifecycleSchema() map[string]any {
|
||||
return schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"app_id": intProps("Идентификатор сервиса", true),
|
||||
"wait": boolProps("Дождаться завершения задачи (по умолчанию false)", false),
|
||||
}, []string{"app_id"})
|
||||
}
|
||||
|
||||
// runLifecycle — общий путь операций жизненного цикла: вызвать действие, при
|
||||
// wait=true ограниченно дождаться задачи агента.
|
||||
func runLifecycle(ctx context.Context, req *mcp.CallToolRequest, action string, needConfirm bool) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
if needConfirm {
|
||||
if res := confirmGate(args, action); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
id, res := appIDArg(args)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
wait := getBool(args, "wait", false)
|
||||
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
|
||||
var (
|
||||
ack *configserver.TaskAck
|
||||
err error
|
||||
)
|
||||
switch action {
|
||||
case "deploy":
|
||||
ack, err = api.Deploy(ctx, id)
|
||||
case "restart":
|
||||
ack, err = api.Restart(ctx, id)
|
||||
case "down":
|
||||
ack, err = api.Down(ctx, id)
|
||||
case "migrate":
|
||||
ack, err = api.Migrate(ctx, id)
|
||||
}
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
|
||||
// Без wait отдаём task_id, чтобы не блокировать последовательный loop агента.
|
||||
if !wait {
|
||||
return result(ack, nil)
|
||||
}
|
||||
task, err := api.WaitTask(ctx, ack.TaskID)
|
||||
if err != nil {
|
||||
return result(nil, err)
|
||||
}
|
||||
return result(map[string]any{"ack": ack, "task": task}, nil)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"omnichannel-mcp/internal/configserver"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// overview.go — сводные read-only инструменты для эксплуатации. Они не
|
||||
// изменяют состояние, а собирают картину несколькими GET-вызовами (best-effort:
|
||||
// сбой одного источника не рушит остальные, а попадает в поле errors).
|
||||
|
||||
func registerOverviewTools(s *mcp.Server) {
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "overview",
|
||||
Description: "Сводка состояния стенда одним вызовом: health, статистика, карта сервисов, приложения не в OK, последние failed-задачи. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, handleOverview)
|
||||
|
||||
addTool(s, &mcp.Tool{
|
||||
Name: "release_status",
|
||||
Description: "Состояние развёртывания релиза: release_job, release-задачи, приложения, сопоставление IP. Read-only.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, handleReleaseStatus)
|
||||
}
|
||||
|
||||
func handleOverview(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
out := map[string]any{"server": api.Alias(), "read_only": api.Config.ReadOnly}
|
||||
var errs []string
|
||||
|
||||
if status, body, err := api.Health(ctx); err != nil {
|
||||
errs = append(errs, "health: "+err.Error())
|
||||
} else {
|
||||
out["health"] = map[string]any{"http_status": status, "body": body}
|
||||
}
|
||||
if st, err := api.Stats(ctx); err != nil {
|
||||
errs = append(errs, "stats: "+err.Error())
|
||||
} else {
|
||||
out["stats"] = st
|
||||
}
|
||||
if sm, err := api.ServiceMap(ctx); err != nil {
|
||||
errs = append(errs, "service_map: "+err.Error())
|
||||
} else {
|
||||
out["service_map"] = sm
|
||||
}
|
||||
if apps, err := api.ListApplications(ctx, false); err != nil {
|
||||
errs = append(errs, "applications: "+err.Error())
|
||||
} else {
|
||||
out["unhealthy_applications"] = unhealthy(apps)
|
||||
}
|
||||
if tasks, err := api.ListTasks(ctx, configserver.TaskFilter{Status: "failed", Limit: 10}); err != nil {
|
||||
errs = append(errs, "failed_tasks: "+err.Error())
|
||||
} else {
|
||||
out["recent_failed_tasks"] = tasks
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
out["errors"] = errs
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
func handleReleaseStatus(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return observeCall(ctx, req, func(api *configserver.API) (any, error) {
|
||||
out := map[string]any{"server": api.Alias()}
|
||||
var errs []string
|
||||
|
||||
if job, err := api.ReleaseJob(ctx); err != nil {
|
||||
errs = append(errs, "release_job: "+err.Error())
|
||||
} else {
|
||||
out["release_job"] = job
|
||||
}
|
||||
if tasks, err := api.DeploymentTasks(ctx); err != nil {
|
||||
errs = append(errs, "deployment_tasks: "+err.Error())
|
||||
} else {
|
||||
out["deployment_tasks"] = tasks
|
||||
}
|
||||
if apps, err := api.ListApplications(ctx, false); err != nil {
|
||||
errs = append(errs, "applications: "+err.Error())
|
||||
} else {
|
||||
out["applications"] = apps
|
||||
}
|
||||
if ipm, err := api.IPMatch(ctx); err != nil {
|
||||
errs = append(errs, "ip_match: "+err.Error())
|
||||
} else {
|
||||
out["ip_match"] = ipm
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
out["errors"] = errs
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// unhealthy возвращает приложения со статусом, отличным от OK.
|
||||
func unhealthy(apps []configserver.Application) []configserver.Application {
|
||||
out := []configserver.Application{}
|
||||
for _, app := range apps {
|
||||
if app.Status != "OK" {
|
||||
out = append(out, app)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// patterns.go — доменные эмиттеры probe-паттернов разрешений для omnichannel-mcp.
|
||||
//
|
||||
// Канонический паттерн несёт алиас сервера и ресурс, чтобы мультисерверная
|
||||
// политика оператора могла различать стенды:
|
||||
//
|
||||
// server=<alias> app=<id>
|
||||
// server=<alias> app=<id> file=<filename>
|
||||
// server=<alias> version=<id>
|
||||
// server=<alias> release
|
||||
//
|
||||
// «always» не эмитим: каждая разрушительная операция должна получать отдельный
|
||||
// ask/allow у оператора (как в proxmox-модуле).
|
||||
|
||||
// effectiveServer возвращает алиас сервера для паттерна: явный аргумент либо
|
||||
// default из конфига. Без конфига — "default".
|
||||
func effectiveServer(args map[string]any) string {
|
||||
if s := getString(args, "server", ""); s != "" {
|
||||
return s
|
||||
}
|
||||
if m := manager(); m != nil {
|
||||
if cfg, _, err := m.Config(configPathArg(args)); err == nil && cfg.Default != "" {
|
||||
return cfg.Default
|
||||
}
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func serverPart(args map[string]any) string { return "server=" + effectiveServer(args) }
|
||||
|
||||
// appPatterns — паттерн ресурса-приложения (deploy/restart/down/migrate/...).
|
||||
func appPatterns(args map[string]any) ([]string, []string) {
|
||||
parts := []string{serverPart(args)}
|
||||
if id := getInt(args, "app_id", 0); id > 0 {
|
||||
parts = append(parts, "app="+strconv.Itoa(id))
|
||||
}
|
||||
return []string{strings.Join(parts, " ")}, nil
|
||||
}
|
||||
|
||||
// envPatterns — паттерн env-файла (set_env/restore_env_version).
|
||||
func envPatterns(args map[string]any) ([]string, []string) {
|
||||
parts := []string{serverPart(args)}
|
||||
if id := getInt(args, "app_id", 0); id > 0 {
|
||||
parts = append(parts, "app="+strconv.Itoa(id))
|
||||
}
|
||||
if fn := getString(args, "filename", ""); fn != "" {
|
||||
parts = append(parts, "file="+fn)
|
||||
}
|
||||
return []string{strings.Join(parts, " ")}, nil
|
||||
}
|
||||
|
||||
// versionPatterns — паттерн версии конфигурации (restore_compose).
|
||||
func versionPatterns(args map[string]any) ([]string, []string) {
|
||||
parts := []string{serverPart(args)}
|
||||
if id := getInt(args, "version_id", 0); id > 0 {
|
||||
parts = append(parts, "version="+strconv.Itoa(id))
|
||||
}
|
||||
return []string{strings.Join(parts, " ")}, nil
|
||||
}
|
||||
|
||||
// releasePatterns — паттерн операций релиза/развёртывания.
|
||||
func releasePatterns(args map[string]any) ([]string, []string) {
|
||||
return []string{serverPart(args) + " release"}, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Package tools реализует MCP-инструменты модуля omnichannel-mcp поверх
|
||||
// доменного пакета configserver. Слой тонкий: разобрать аргументы, вызвать
|
||||
// домен, вернуть текст/ошибку. Бизнес-логики здесь нет.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"omnichannel-mcp/internal/configserver"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// mgr — одиночный Manager процесса (см. ARCHITECTURE.md §2). Устанавливается
|
||||
// один раз в RegisterAll; Manager потокобезопасен.
|
||||
var mgr *configserver.Manager
|
||||
|
||||
func manager() *configserver.Manager { return mgr }
|
||||
|
||||
// RegisterAll — единая точка регистрации инструментов, сгруппированных по
|
||||
// фазам жизненного цикла: observe (чтение) → configure → operate → release →
|
||||
// maintain. Имена инструментов — без префикса (префикс может добавить MCP-хост).
|
||||
func RegisterAll(s *mcp.Server, m *configserver.Manager) {
|
||||
mgr = m
|
||||
registerObserveTools(s)
|
||||
registerConfigureTools(s)
|
||||
registerOperateTools(s)
|
||||
registerReleaseTools(s)
|
||||
registerMaintainTools(s)
|
||||
registerOverviewTools(s)
|
||||
}
|
||||
|
||||
// openAPI резолвит конфиг/сервер и открывает фасад API. Любая ошибка на этом
|
||||
// шаге (нет конфига, неизвестный алиас, read_only) — доменная: отдаём модели.
|
||||
func openAPI(ctx context.Context, args map[string]any, write bool) (*configserver.API, *mcp.CallToolResult) {
|
||||
m := manager()
|
||||
if m == nil {
|
||||
return nil, errorResult("omnichannel-mcp: manager не инициализирован")
|
||||
}
|
||||
alias := getString(args, "server", "")
|
||||
api, err := m.Open(ctx, configPathArg(args), alias, write)
|
||||
if err != nil {
|
||||
return nil, errorResult(err.Error())
|
||||
}
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// configPathArg возвращает опциональный путь к конфигу из аргументов вызова.
|
||||
// Основной способ задания конфига — флаг `-config`; per-call переопределение
|
||||
// (`config_path`) поддержано для многотенантных обёрток. `_tenant_config` —
|
||||
// устаревший алиас, принимается для совместимости.
|
||||
func configPathArg(args map[string]any) string {
|
||||
if v := getString(args, "config_path", ""); v != "" {
|
||||
return v
|
||||
}
|
||||
return getString(args, "_tenant_config", "")
|
||||
}
|
||||
|
||||
// result приводит (значение, ошибка) домена к результату инструмента:
|
||||
// доменные ошибки — recoverable (текст видит модель), инфраструктурные — Go-ошибка.
|
||||
func result(v any, err error) (*mcp.CallToolResult, error) {
|
||||
if err != nil {
|
||||
if configserver.IsDomainError(err) {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return textResult(jsonText(v)), nil
|
||||
}
|
||||
|
||||
// confirmGate требует confirm="true" для разрушительных операций. Это второй
|
||||
// слой поверх require_approval в agent.yaml.
|
||||
func confirmGate(args map[string]any, what string) *mcp.CallToolResult {
|
||||
if !getBool(args, "confirm", false) {
|
||||
return errorResult("операция \"" + what + "\" требует confirm=\"true\"")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverProp — общее описание аргумента выбора сервера.
|
||||
func serverProp() map[string]any {
|
||||
return strProps("Алиас сервера из config.json (по умолчанию — default)", false)
|
||||
}
|
||||
|
||||
// appIDArg читает обязательный app_id.
|
||||
func appIDArg(args map[string]any) (int, *mcp.CallToolResult) {
|
||||
id := getInt(args, "app_id", 0)
|
||||
if id <= 0 {
|
||||
return 0, errorResult("нужен положительный app_id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// versionIDArg читает обязательный version_id.
|
||||
func versionIDArg(args map[string]any) (int, *mcp.CallToolResult) {
|
||||
id := getInt(args, "version_id", 0)
|
||||
if id <= 0 {
|
||||
return 0, errorResult("нужен положительный version_id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"omnichannel-mcp/internal/configserver"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// release.go — страница «Развёртывание»: файлы схемы, seed/fill, релиз.
|
||||
|
||||
func registerReleaseTools(s *mcp.Server) {
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "save_deployment_files",
|
||||
Description: "Сохранить файлы развёртывания (schema/manifest/prebuild_vars/ip_overrides). Передавай только изменяемые поля.",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"schema": strProps("JSON-схема развёртывания (опционально)", false),
|
||||
"manifest": strProps("JSON-манифест (опционально)", false),
|
||||
"prebuild_vars": strProps("Строка prebuild_vars (опционально)", false),
|
||||
"ip_overrides": strProps("JSON ip_overrides (опционально)", false),
|
||||
}, nil),
|
||||
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
upd := configserver.DeploymentUpdate{
|
||||
Schema: optionalString(args, "schema"),
|
||||
Manifest: optionalString(args, "manifest"),
|
||||
PrebuildVars: optionalString(args, "prebuild_vars"),
|
||||
IPOverrides: optionalString(args, "ip_overrides"),
|
||||
}
|
||||
if upd.Schema == nil && upd.Manifest == nil && upd.PrebuildVars == nil && upd.IPOverrides == nil {
|
||||
return errorResult("нужно передать хотя бы одно из полей: schema, manifest, prebuild_vars, ip_overrides"), nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.SaveDeploymentFiles(ctx, upd))
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "seed_hosts",
|
||||
Description: "Создать служебные записи хостов по IP из schema.json. Изменяет БД.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.SeedHosts(ctx))
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "fill_vars",
|
||||
Description: "Заполнить prebuild_vars из schema.json. Изменяет файл конфигурации развёртывания.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
v, err := api.FillVars(ctx)
|
||||
return result(v, err)
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "download_release",
|
||||
Description: "Загрузить релиз. load_images=false — синхронно (пакеты + sync-задачи); true — фоновая загрузка образов (прогресс через release_job). Разрушительно/тяжело: требует confirm=\"true\".",
|
||||
InputSchema: schema(map[string]any{
|
||||
"server": serverProp(),
|
||||
"load_images": boolProps("Загружать docker-образы в фоне (по умолчанию false)", false),
|
||||
"gitlab_token": strProps("GitLab-токен (опционально; иначе из конфига/env сервера)", false),
|
||||
"confirm": boolProps("Подтверждение операции (обязательно true)", true),
|
||||
}, []string{"confirm"}),
|
||||
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
if res := confirmGate(args, "download_release"); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
ack, err := api.DownloadRelease(ctx, getString(args, "gitlab_token", ""), getBool(args, "load_images", false))
|
||||
return result(ack, err)
|
||||
})
|
||||
|
||||
addPatternTool(s, &mcp.Tool{
|
||||
Name: "start_services",
|
||||
Description: "Создать задачи запуска сервисов для хостов из schema.json. Изменяет состояние стенда.",
|
||||
InputSchema: schema(map[string]any{"server": serverProp()}, nil),
|
||||
}, releasePatterns, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := requestArgs(req)
|
||||
api, res := openAPI(ctx, args, true)
|
||||
if res != nil {
|
||||
return res, nil
|
||||
}
|
||||
defer api.Close()
|
||||
return result(api.StartServices(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// optionalString возвращает указатель на строковый аргумент, если он задан
|
||||
// (в том числе пустой строкой), иначе nil.
|
||||
func optionalString(args map[string]any, key string) *string {
|
||||
if _, ok := args[key]; !ok {
|
||||
return nil
|
||||
}
|
||||
v := getString(args, key, "")
|
||||
return &v
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.totmin.ru/en2zmax/forge-toolkit"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// toolkit.go — тонкие адаптеры к общему тулкиту (модуль forge-toolkit).
|
||||
// Локальные имена сохраняют хендлеры независимыми от пакета-источника.
|
||||
|
||||
func requestArgs(req *mcp.CallToolRequest) map[string]any { return toolkit.RequestArgs(req) }
|
||||
|
||||
func getString(args map[string]any, key, def string) string { return toolkit.GetString(args, key, def) }
|
||||
|
||||
func requireString(args map[string]any, key string) (string, error) {
|
||||
return toolkit.RequireString(args, key)
|
||||
}
|
||||
|
||||
func getInt(args map[string]any, key string, def int) int { return toolkit.GetInt(args, key, def) }
|
||||
|
||||
func getBool(args map[string]any, key string, def bool) bool { return toolkit.GetBool(args, key, def) }
|
||||
|
||||
func schema(properties map[string]any, required []string) map[string]any {
|
||||
return toolkit.Schema(properties, required)
|
||||
}
|
||||
|
||||
func strProps(desc string, required bool, enum ...string) map[string]any {
|
||||
return toolkit.StrProps(desc, required, enum...)
|
||||
}
|
||||
|
||||
func intProps(desc string, required bool) map[string]any { return toolkit.IntProps(desc, required) }
|
||||
|
||||
func boolProps(desc string, required bool) map[string]any { return toolkit.BoolProps(desc, required) }
|
||||
|
||||
func textResult(text string) *mcp.CallToolResult { return toolkit.Text(text) }
|
||||
|
||||
func errorResult(msg string) *mcp.CallToolResult { return toolkit.Error(msg) }
|
||||
|
||||
// addTool регистрирует инструмент без probe-паттернов (read-only).
|
||||
func addTool(s *mcp.Server, tool *mcp.Tool, h mcp.ToolHandler) { s.AddTool(tool, h) }
|
||||
|
||||
// patternsFn — эмиттер permission-паттернов разрешений для probe-запросов.
|
||||
type patternsFn = toolkit.PatternsFn
|
||||
|
||||
// addPatternTool регистрирует изменяющий инструмент с поддержкой probe: хост
|
||||
// получает канонический паттерн ресурса, не выполняя действие.
|
||||
func addPatternTool(s *mcp.Server, tool *mcp.Tool, fn patternsFn, h mcp.ToolHandler) {
|
||||
toolkit.RegisterPatternTool(s, tool, fn, h)
|
||||
}
|
||||
|
||||
// jsonText сериализует значение в читаемый JSON для ответа модели.
|
||||
func jsonText(v any) string {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package tools
|
||||
|
||||
import "strconv"
|
||||
|
||||
// util.go — небольшие помощники MCP-слоя.
|
||||
|
||||
// truncateText ограничивает длинный вывод (задачи, compose, env), чтобы он не
|
||||
// съел контекст модели. По умолчанию оставляем хвост: ошибки сборки/деплоя
|
||||
// обычно в конце вывода. Второй результат — был ли вывод обрезан.
|
||||
func truncateText(s string, maxBytes int, tail bool) (string, bool) {
|
||||
if maxBytes <= 0 || len(s) <= maxBytes {
|
||||
return s, false
|
||||
}
|
||||
cut := len(s) - maxBytes
|
||||
if tail {
|
||||
return "…(обрезано " + strconv.Itoa(cut) + " байт, показан хвост)\n" + s[len(s)-maxBytes:], true
|
||||
}
|
||||
return s[:maxBytes] + "\n…(обрезано " + strconv.Itoa(cut) + " байт, показано начало)", true
|
||||
}
|
||||
Reference in New Issue
Block a user