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//, возвращающий 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) }