Initial commit: omnichannel-mcp — MCP-сервер управления платформой Omnichannel
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user