fix(storage-agent): write nothing but the music files to the mounted storage
ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
ci / db / db (push) Failing after 19s

- Imported tracks are created directly under their final name with
  O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked
  against SHA-256 — no hidden .pipu-tmp file next to them any more.
- On any failure the agent removes its own partial file and the empty
  directories it created for it.
- The write check uses access(W_OK) and the read-only mount flag instead
  of creating and deleting .pipu-write-test in the storage root.
- Tests assert the storage tree holds only audio files after successful
  and failed writes and after the write check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 19:22:49 +03:00
1 parent 6ad49fe0e3
commit 85fceaa686
6 files changed
+116 -56

No files matched your search

+50 -46
View File
@@ -142,27 +142,19 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, res)
}
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
// каталоги удаляются — агент убирает только то, что создал сам.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
target := filepath.Join(root, filepath.FromSlash(rel))
dir := filepath.Dir(target)
if err := a.mkdirs(root, dir); err != nil {
return WriteResult{}, err
}
tmp := filepath.Join(dir, "."+filepath.Base(target)+".pipu-tmp")
size, err := a.copyTemp(staging, tmp)
if err != nil {
_ = os.Remove(tmp)
return WriteResult{}, err
}
defer func() { _ = os.Remove(tmp) }()
got, err := fileSHA256(tmp)
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
created, err := a.mkdirs(root, dir)
if err != nil {
return WriteResult{}, err
}
if !bytes.Equal(got, want) {
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
fail := func(err error) (WriteResult, error) {
removeEmpty(created)
return WriteResult{}, err
}
ext := path.Ext(rel)
@@ -172,24 +164,39 @@ func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, erro
if n > 1 {
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
err := os.Link(tmp, filepath.Join(root, filepath.FromSlash(name)))
target := filepath.Join(root, filepath.FromSlash(name))
dst, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if os.IsExist(err) {
continue
}
if err != nil {
return fail(fmt.Errorf("create: %w", err))
}
size, err := copyInto(dst, staging)
if err == nil {
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
var got []byte
if got, err = fileSHA256(target); err == nil && !bytes.Equal(got, want) {
err = fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
}
}
if !os.IsExist(err) {
return WriteResult{}, fmt.Errorf("link: %w", err)
if err != nil {
_ = os.Remove(target) // наш собственный недописанный / испорченный файл
return fail(err)
}
a.chown(target)
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
}
return WriteResult{}, errors.New("too many files with the same name")
return fail(errors.New("too many files with the same name"))
}
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID).
func (a *agent) mkdirs(root, dir string) error {
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID) и возвращает созданные.
func (a *agent) mkdirs(root, dir string) ([]string, error) {
rel, err := filepath.Rel(root, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return errBadPath
return nil, errBadPath
}
var created []string
cur := root
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "." || seg == "" {
@@ -200,24 +207,30 @@ func (a *agent) mkdirs(root, dir string) error {
if os.IsExist(err) {
continue
}
return fmt.Errorf("mkdir: %w", err)
removeEmpty(created)
return nil, fmt.Errorf("mkdir: %w", err)
}
created = append(created, cur)
a.chown(cur)
}
return nil
return created, nil
}
func (a *agent) copyTemp(staging, tmp string) (int64, error) {
// removeEmpty удаляет созданные каталоги от глубоких к верхним; os.Remove не трогает непустые.
func removeEmpty(dirs []string) {
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
}
// copyInto копирует staging в открытый файл и делает fsync; файл закрывается.
func copyInto(dst *os.File, staging string) (int64, error) {
src, err := os.Open(staging)
if err != nil {
_ = dst.Close()
return 0, fmt.Errorf("staging: %w", err)
}
defer func() { _ = src.Close() }()
_ = os.Remove(tmp) // остаток прерванной попытки
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return 0, fmt.Errorf("create temp: %w", err)
}
n, err := io.Copy(dst, src)
if err == nil {
err = dst.Sync()
@@ -228,7 +241,6 @@ func (a *agent) copyTemp(staging, tmp string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("copy: %w", err)
}
a.chown(tmp)
return n, nil
}
@@ -240,7 +252,8 @@ func (a *agent) chown(p string) {
}
}
// testWrite создаёт и удаляет .pipu-write-test в корне источника на запись.
// testWrite проверяет право записи в корень источника, ничего не создавая на диске (access(W_OK);
// на NFS ответ даёт сам сервер с учётом root_squash и прав экспорта).
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
var req WriteTestRequest
if !a.decode(w, r, &req) {
@@ -251,16 +264,7 @@ func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
return
}
p := filepath.Join(root, ".pipu-write-test")
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err == nil {
_, err = f.WriteString("pipu")
if cerr := f.Close(); err == nil {
err = cerr
}
}
_ = os.Remove(p)
if err != nil {
if err := canWrite(root); err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
return
}