// Package storageagent — внутренний сервис монтирования хранилищ (см. docs/library-and-storage.md). package storageagent import ( "context" "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "log/slog" "net/http" "os" "path/filepath" "strings" "github.com/go-chi/chi/v5" "pipu/api/internal/platform/httpx" ) // Mount — состояние точки монтирования источника. type Mount struct { SourceID string `json:"source_id"` Path string `json:"path"` } // NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN; // при пустом token все запросы к /internal отклоняются. func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler { return NewRouterWithMounter(log, token, libraryRoot, ExecMounter{}) } // NewRouterWithMounter — NewRouter с заданным способом монтирования (для тестов). func NewRouterWithMounter(log *slog.Logger, token, libraryRoot string, mounter Mounter) http.Handler { return NewRouterWithPaths(log, token, Paths{Library: libraryRoot, FileUID: -1, FileGID: -1}, mounter) } // NewRouterWithPaths — роутер с каталогами записи (импорт загрузок). func NewRouterWithPaths(log *slog.Logger, token string, paths Paths, mounter Mounter) http.Handler { libraryRoot := paths.Library if log == nil { log = slog.New(slog.DiscardHandler) } r := chi.NewRouter() r.NotFound(httpx.NotFound) r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) r.Route("/internal", func(r chi.Router) { r.Use(requireToken(token)) r.Get("/mounts", func(w http.ResponseWriter, r *http.Request) { mounts, err := listMounts(libraryRoot) if err != nil { log.ErrorContext(r.Context(), "list mounts", "err", err) httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", "cannot list mounts") return } writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts}) }) a := &agent{log: log, root: libraryRoot, paths: paths, mounter: mounter} r.Post("/mounts", a.mount) r.Post("/mounts/test", a.test) r.Delete("/mounts/{sourceID}", a.unmount) r.Post("/files", a.writeFile) r.Post("/files/test", a.testWrite) }) return r } type agent struct { log *slog.Logger root string paths Paths mounter Mounter } // MountRequest — тело POST /internal/mounts. type MountRequest struct { SourceID string `json:"source_id"` NFSSpec Writable bool `json:"writable"` } // MountResult — итог монтирования или проверки. type MountResult struct { Status string `json:"status"` // mounted | error Message string `json:"message,omitempty"` AudioFiles int `json:"audio_files"` } func (a *agent) decode(w http.ResponseWriter, r *http.Request, v any) bool { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(v); err != nil { httpx.WriteProblem(w, http.StatusBadRequest, "common.bad_request", "invalid json") return false } return true } func writeValidation(w http.ResponseWriter, err error) { var ve *ValidationError if errors.As(err, &ve) { writeJSON(w, http.StatusUnprocessableEntity, map[string]any{ "status": http.StatusUnprocessableEntity, "code": "validation.nfs_invalid", "detail": ve.Reason, "errors": []map[string]string{{"field": ve.Field, "code": "validation.nfs_invalid"}}, }) return } httpx.WriteProblem(w, http.StatusBadRequest, "common.bad_request", err.Error()) } // mount (пере)монтирует источник в / и считает аудиофайлы для проверки. func (a *agent) mount(w http.ResponseWriter, r *http.Request) { var req MountRequest if !a.decode(w, r, &req) { return } target, err := mountPoint(a.root, req.SourceID) if err != nil { writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()}) return } spec, err := req.NFSSpec.Normalize(req.Writable) if err != nil { writeValidation(w, err) return } ctx, cancel := context.WithTimeout(r.Context(), probeTimeout) defer cancel() _ = a.mounter.Unmount(ctx, target) rwTarget := a.rwPoint(req.SourceID) if rwTarget != "" { _ = a.mounter.Unmount(ctx, rwTarget) _ = os.Remove(rwTarget) } if !req.Writable { writeJSON(w, http.StatusOK, a.mountAndCount(ctx, spec, target)) return } // Источник принимает загрузки: rw-точка видна только агенту, всем остальным — bind-копия только на чтение. if rwTarget == "" { writeJSON(w, http.StatusOK, MountResult{Status: "error", Message: "writable mounts are disabled (LIBRARY_RW_ROOT)"}) return } res := a.mountAndCount(ctx, spec, rwTarget) if res.Status == "mounted" { if err := a.mounter.BindReadOnly(ctx, rwTarget, target); err != nil { _ = a.mounter.Unmount(ctx, rwTarget) res = MountResult{Status: "error", Message: err.Error()} } } writeJSON(w, http.StatusOK, res) } // rwPoint — rw-точка источника ("" — запись в NFS выключена или id неверный). func (a *agent) rwPoint(sourceID string) string { if a.paths.LibraryRW == "" { return "" } p, err := mountPoint(a.paths.LibraryRW, sourceID) if err != nil { return "" } return p } // test монтирует во временную точку, считает файлы и отключает. func (a *agent) test(w http.ResponseWriter, r *http.Request) { var spec NFSSpec if !a.decode(w, r, &spec) { return } spec, err := spec.Normalize(false) if err != nil { writeValidation(w, err) return } var rnd [6]byte _, _ = rand.Read(rnd[:]) target := filepath.Join(a.root, ".test-"+hex.EncodeToString(rnd[:])) ctx, cancel := context.WithTimeout(r.Context(), probeTimeout) defer cancel() res := a.mountAndCount(ctx, spec, target) _ = a.mounter.Unmount(context.Background(), target) _ = os.Remove(target) writeJSON(w, http.StatusOK, res) } func (a *agent) mountAndCount(ctx context.Context, spec NFSSpec, target string) MountResult { if err := a.mounter.Mount(ctx, spec, target); err != nil { a.log.WarnContext(ctx, "nfs mount failed", "target", target, "err", err) return MountResult{Status: "error", Message: err.Error()} } n, err := countAudio(ctx, target, 1000) if err != nil { return MountResult{Status: "error", Message: "mounted, but listing failed: " + err.Error()} } return MountResult{Status: "mounted", AudioFiles: n, Message: "found " + itoa(n) + " audio files"} } func (a *agent) unmount(w http.ResponseWriter, r *http.Request) { target, err := mountPoint(a.root, chi.URLParam(r, "sourceID")) if err != nil { writeValidation(w, &ValidationError{Field: "source_id", Reason: err.Error()}) return } if err := a.mounter.Unmount(r.Context(), target); err != nil { httpx.WriteProblem(w, http.StatusInternalServerError, "storage.unmount_failed", err.Error()) return } _ = os.Remove(target) if rw := a.rwPoint(chi.URLParam(r, "sourceID")); rw != "" { _ = a.mounter.Unmount(r.Context(), rw) _ = os.Remove(rw) } w.WriteHeader(http.StatusNoContent) } func requireToken(token string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || token == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 { httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token") return } next.ServeHTTP(w, r) }) } } // listMounts перечисляет подкаталоги корня библиотеки (каждый — точка монтирования источника). func listMounts(root string) ([]Mount, error) { entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { return []Mount{}, nil } return nil, err } mounts := make([]Mount, 0, len(entries)) for _, e := range entries { if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { mounts = append(mounts, Mount{SourceID: e.Name(), Path: filepath.Join(root, e.Name())}) } } return mounts, nil } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }