Files
pipu-music/services/api/internal/library/agent.go
T
mrleo1nidandClaude Opus 5.5 f66be2ba0a
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:56 +03:00

128 lines
4.2 KiB
Go

package library
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"pipu/api/internal/storageagent"
)
// Agent — клиент storage-agent (монтирование NFS, запись импортированных файлов).
type Agent interface {
// Mount монтирует NFS; writable — источник принимает загрузки (ещё rw-точка, видимая только агенту).
Mount(ctx context.Context, sourceID uuid.UUID, spec storageagent.NFSSpec, writable bool) (storageagent.MountResult, error)
Unmount(ctx context.Context, sourceID uuid.UUID) error
Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error)
TestWrite(ctx context.Context, req storageagent.WriteTestRequest) (storageagent.WriteTestResult, error)
WriteFile(ctx context.Context, req storageagent.WriteRequest) (storageagent.WriteResult, error)
}
// HTTPAgent ходит в storage-agent по внутренней сети с общим секретом.
type HTTPAgent struct {
BaseURL string
Token string
Client *http.Client
}
// NewHTTPAgent создаёт клиента агента.
func NewHTTPAgent(baseURL, token string) *HTTPAgent {
return &HTTPAgent{BaseURL: baseURL, Token: token, Client: &http.Client{Timeout: 40 * time.Second}}
}
// AgentError — агент отклонил запрос (например, недопустимые параметры NFS).
type AgentError struct {
Status int
Field string
Reason string
}
func (e *AgentError) Error() string {
return fmt.Sprintf("storage-agent: %d %s %s", e.Status, e.Field, e.Reason)
}
func (a *HTTPAgent) do(ctx context.Context, method, path string, body any, out any) error {
var rd *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(raw)
} else {
rd = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, a.BaseURL+path, rd)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+a.Token)
req.Header.Set("Content-Type", "application/json")
res, err := a.Client.Do(req)
if err != nil {
return fmt.Errorf("storage-agent: %w", err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode >= 400 {
var p struct {
Detail string `json:"detail"`
Errors []struct {
Field string `json:"field"`
} `json:"errors"`
}
_ = json.NewDecoder(res.Body).Decode(&p)
ae := &AgentError{Status: res.StatusCode, Reason: p.Detail}
if len(p.Errors) > 0 {
ae.Field = p.Errors[0].Field
}
return ae
}
if out != nil {
return json.NewDecoder(res.Body).Decode(out)
}
return nil
}
// Mount реализует Agent.
func (a *HTTPAgent) Mount(ctx context.Context, id uuid.UUID, spec storageagent.NFSSpec, writable bool) (storageagent.MountResult, error) {
var res storageagent.MountResult
err := a.do(ctx, http.MethodPost, "/internal/mounts",
storageagent.MountRequest{SourceID: id.String(), NFSSpec: spec, Writable: writable}, &res)
return res, err
}
// TestWrite реализует Agent.
func (a *HTTPAgent) TestWrite(ctx context.Context, req storageagent.WriteTestRequest) (storageagent.WriteTestResult, error) {
var res storageagent.WriteTestResult
err := a.do(ctx, http.MethodPost, "/internal/files/test", req, &res)
return res, err
}
// WriteFile реализует Agent. Копирование большого файла по NFS может идти минуты — отдельный таймаут.
func (a *HTTPAgent) WriteFile(ctx context.Context, req storageagent.WriteRequest) (storageagent.WriteResult, error) {
var res storageagent.WriteResult
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
c := *a
c.Client = &http.Client{}
err := c.do(ctx, http.MethodPost, "/internal/files", req, &res)
return res, err
}
// Unmount реализует Agent.
func (a *HTTPAgent) Unmount(ctx context.Context, id uuid.UUID) error {
return a.do(ctx, http.MethodDelete, "/internal/mounts/"+id.String(), nil, nil)
}
// Test реализует Agent.
func (a *HTTPAgent) Test(ctx context.Context, spec storageagent.NFSSpec) (storageagent.MountResult, error) {
var res storageagent.MountResult
err := a.do(ctx, http.MethodPost, "/internal/mounts/test", spec, &res)
return res, err
}