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
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:
1 parent
6ad49fe0e3
commit
85fceaa686
6 files changed
+116
-56
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user