Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7722e28ad | ||
|
|
ed0302d90f | ||
|
|
85fceaa686 |
No files matched your search
@@ -48,6 +48,8 @@ jobs:
|
|||||||
android:
|
android:
|
||||||
name: android (APK в релиз)
|
name: android (APK в релиз)
|
||||||
needs: release
|
needs: release
|
||||||
|
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
|
||||||
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||||
@@ -60,7 +62,7 @@ jobs:
|
|||||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
||||||
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
||||||
options: --cpus=3 --memory=6g --memory-swap=6g
|
options: --cpus=3 --memory=7g --memory-swap=7g
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/ci-cache/gradle:/root/.gradle
|
- /opt/ci-cache/gradle:/root/.gradle
|
||||||
- /opt/ci-cache/android:/root/.android
|
- /opt/ci-cache/android:/root/.android
|
||||||
@@ -106,6 +108,8 @@ jobs:
|
|||||||
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
||||||
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
||||||
NINJAFLAGS: "-j2"
|
NINJAFLAGS: "-j2"
|
||||||
|
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=1536"
|
||||||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||||
@@ -120,7 +124,7 @@ jobs:
|
|||||||
cd android
|
cd android
|
||||||
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
||||||
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
|
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
|
||||||
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
||||||
if grep -q '^newArchEnabled=true' gradle.properties; then
|
if grep -q '^newArchEnabled=true' gradle.properties; then
|
||||||
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ container:
|
|||||||
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
|
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=6g` без свопа, CMake / ninja / Gradle
|
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
|
||||||
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
|
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
|
||||||
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
|
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
|
||||||
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
|
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
|
||||||
|
|||||||
@@ -35,8 +35,7 @@
|
|||||||
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
|
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
|
||||||
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
|
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
|
||||||
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
|
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
|
||||||
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет
|
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов.
|
||||||
`.pipu-write-test` в корне.
|
|
||||||
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
|
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
|
||||||
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
|
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
|
||||||
|
|
||||||
@@ -44,8 +43,11 @@
|
|||||||
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
|
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
|
||||||
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
|
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
|
||||||
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
|
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
|
||||||
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). Итоговое имя создаётся жёсткой ссылкой
|
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего,
|
||||||
на временный файл (`link` не перезаписывает, в отличие от `rename`); занято — ` (2)`, ` (3)`…
|
кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается;
|
||||||
|
занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные
|
||||||
|
для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла.
|
||||||
|
Остальные сервисы видят музыку только на чтение (`read_only` в compose).
|
||||||
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
|
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
|
||||||
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
|
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
|
||||||
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
|
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
|
||||||
|
|||||||
+3
-3
@@ -161,9 +161,9 @@
|
|||||||
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
|
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
|
||||||
Агент:
|
Агент:
|
||||||
- создаёт каталоги;
|
- создаёт каталоги;
|
||||||
- копирует во временный файл `.<name>.pipu-tmp` в целевой папке, `fsync`;
|
- создаёт файл сразу под итоговым именем (`O_EXCL`, существующий не перезаписывается), копирует, `fsync`;
|
||||||
- сверяет SHA-256 копии со staging;
|
- сверяет SHA-256 копии со staging; при ошибке удаляет свой файл и созданные пустые каталоги —
|
||||||
- атомарно переименовывает в итоговое имя.
|
на музыкальный диск не попадает ничего, кроме треков.
|
||||||
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
|
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
|
||||||
В `media_files` сохраняется `upload_id` — история происхождения.
|
В `media_files` сохраняется `upload_id` — история происхождения.
|
||||||
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
|
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
|
||||||
|
|||||||
+3
-3
@@ -6,6 +6,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/getkin/kin-openapi v0.127.0
|
github.com/getkin/kin-openapi v0.127.0
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
@@ -16,11 +17,12 @@ require (
|
|||||||
github.com/redis/go-redis/v9 v9.10.0
|
github.com/redis/go-redis/v9 v9.10.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
|
golang.org/x/sys v0.33.0
|
||||||
|
golang.org/x/text v0.25.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
@@ -43,8 +45,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
|
||||||
golang.org/x/text v0.25.0 // indirect
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package storageagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canWrite — запасная проверка для не-Unix (только разработка): бит записи у каталога.
|
||||||
|
func canWrite(dir string) error {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0o200 == 0 {
|
||||||
|
return errors.New("directory is not writable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package storageagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canWrite — можно ли создавать файлы в каталоге (без пробной записи); read-only монтирование — ошибка.
|
||||||
|
func canWrite(dir string) error {
|
||||||
|
var st unix.Statfs_t
|
||||||
|
if err := unix.Statfs(dir, &st); err == nil && st.Flags&unix.ST_RDONLY != 0 {
|
||||||
|
return errors.New("filesystem is mounted read-only")
|
||||||
|
}
|
||||||
|
return unix.Access(dir, unix.W_OK|unix.X_OK)
|
||||||
|
}
|
||||||
@@ -142,27 +142,19 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, res)
|
writeJSON(w, http.StatusOK, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
|
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
|
||||||
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
|
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
|
||||||
|
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
|
||||||
|
// каталоги удаляются — агент убирает только то, что создал сам.
|
||||||
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
||||||
target := filepath.Join(root, filepath.FromSlash(rel))
|
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
dir := filepath.Dir(target)
|
created, err := a.mkdirs(root, dir)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WriteResult{}, err
|
return WriteResult{}, err
|
||||||
}
|
}
|
||||||
if !bytes.Equal(got, want) {
|
fail := func(err error) (WriteResult, error) {
|
||||||
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
|
removeEmpty(created)
|
||||||
|
return WriteResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := path.Ext(rel)
|
ext := path.Ext(rel)
|
||||||
@@ -172,24 +164,39 @@ func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, erro
|
|||||||
if n > 1 {
|
if n > 1 {
|
||||||
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
|
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 {
|
if err == nil {
|
||||||
syncDir(dir)
|
var got []byte
|
||||||
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
|
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) {
|
if err != nil {
|
||||||
return WriteResult{}, fmt.Errorf("link: %w", err)
|
_ = 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).
|
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID) и возвращает созданные.
|
||||||
func (a *agent) mkdirs(root, dir string) error {
|
func (a *agent) mkdirs(root, dir string) ([]string, error) {
|
||||||
rel, err := filepath.Rel(root, dir)
|
rel, err := filepath.Rel(root, dir)
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
return errBadPath
|
return nil, errBadPath
|
||||||
}
|
}
|
||||||
|
var created []string
|
||||||
cur := root
|
cur := root
|
||||||
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
if seg == "." || seg == "" {
|
if seg == "." || seg == "" {
|
||||||
@@ -200,24 +207,30 @@ func (a *agent) mkdirs(root, dir string) error {
|
|||||||
if os.IsExist(err) {
|
if os.IsExist(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return fmt.Errorf("mkdir: %w", err)
|
removeEmpty(created)
|
||||||
|
return nil, fmt.Errorf("mkdir: %w", err)
|
||||||
}
|
}
|
||||||
|
created = append(created, cur)
|
||||||
a.chown(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)
|
src, err := os.Open(staging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
return 0, fmt.Errorf("staging: %w", err)
|
return 0, fmt.Errorf("staging: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = src.Close() }()
|
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)
|
n, err := io.Copy(dst, src)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = dst.Sync()
|
err = dst.Sync()
|
||||||
@@ -228,7 +241,6 @@ func (a *agent) copyTemp(staging, tmp string) (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("copy: %w", err)
|
return 0, fmt.Errorf("copy: %w", err)
|
||||||
}
|
}
|
||||||
a.chown(tmp)
|
|
||||||
return n, nil
|
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) {
|
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
|
||||||
var req WriteTestRequest
|
var req WriteTestRequest
|
||||||
if !a.decode(w, r, &req) {
|
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()})
|
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := filepath.Join(root, ".pipu-write-test")
|
if err := canWrite(root); err != nil {
|
||||||
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 {
|
|
||||||
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
|
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -74,6 +75,7 @@ func TestWriteFile(t *testing.T) {
|
|||||||
assert.True(t, res.Renamed)
|
assert.True(t, res.Renamed)
|
||||||
entries, _ := os.ReadDir(dir)
|
entries, _ := os.ReadDir(dir)
|
||||||
assert.Len(t, entries, 2)
|
assert.Len(t, entries, 2)
|
||||||
|
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
|
||||||
|
|
||||||
// Local-источник пишется в LIBRARY_HOST.
|
// Local-источник пишется в LIBRARY_HOST.
|
||||||
local := "music"
|
local := "music"
|
||||||
@@ -103,8 +105,21 @@ func TestWriteFileRejects(t *testing.T) {
|
|||||||
other := sha256.Sum256([]byte("other"))
|
other := sha256.Sum256([]byte("other"))
|
||||||
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
|
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
|
||||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
entries, _ := os.ReadDir(filepath.Join(e.paths.LibraryRW, srcID, "A"))
|
// Созданная под файл папка тоже убрана: на диске не остаётся следов неудачной записи.
|
||||||
assert.Empty(t, entries)
|
assert.NoDirExists(t, filepath.Join(e.paths.LibraryRW, srcID, "A"))
|
||||||
|
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertOnlyMusic — в дереве хранилища только аудиофайлы и каталоги: ни временных, ни служебных файлов.
|
||||||
|
func assertOnlyMusic(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assert.True(t, audioExt[strings.ToLower(filepath.Ext(p))] && !strings.HasPrefix(d.Name(), "."), "unexpected file %s", p)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWritableMount(t *testing.T) {
|
func TestWritableMount(t *testing.T) {
|
||||||
@@ -120,7 +135,8 @@ func TestWritableMount(t *testing.T) {
|
|||||||
var res WriteTestResult
|
var res WriteTestResult
|
||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
|
||||||
assert.True(t, res.OK, res.Message)
|
assert.True(t, res.OK, res.Message)
|
||||||
assert.NoFileExists(t, filepath.Join(rw, ".pipu-write-test"))
|
entries, _ := os.ReadDir(rw)
|
||||||
|
assert.Empty(t, entries, "write check creates nothing")
|
||||||
|
|
||||||
// Без accepts_uploads rw-точка снимается.
|
// Без accepts_uploads rw-точка снимается.
|
||||||
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
|
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
|
||||||
|
|||||||
Reference in new issue
Block a user