Compare commits

..
4 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 f7722e28ad ci(release): rebalance APK build memory so it does not thrash and get OOM-killed
release / android (APK в релиз) (push) Successful in 3m19s
release / release (версия и заметки) (push) Successful in 8s
v0.7.3 crawled for 35 minutes at the 6 GB cap and was killed without a
log. The container now gets 7 GB, the Gradle heap is 2 GB, the Metro
node process is capped at 1.5 GB and the job times out after 60 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:12:54 +03:00
mrleo1nidandClaude Opus 5.5 ed0302d90f docs(uploads): import writes only the final track file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:11:56 +03:00
mrleo1nidandClaude Opus 5.5 85fceaa686 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>
2026-10-20 19:22:49 +03:00
mrleo1nidandClaude Opus 5.5 6ad49fe0e3 fix(wave): switch to the new context right away when activity or mood changes
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
ci / ts / ts (packages + web) (push) Successful in 1m47s
release / release (версия и заметки) (push) Successful in 12s
release / android (APK в релиз) (push) Failing after 34m58s
Changing the activity or mood used to keep the current track playing
and only replace the upcoming ones, so nothing seemed to happen. Now the
first track of the new batch starts immediately (web and Android). The
interrupted track is not reported to the wave as a skip and is recorded
in history only if it played for at least 30 s, so a context change does
not teach the recommender a false dislike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:26 +03:00
11 changed files with 164 additions and 66 deletions

No files matched your search

+6 -2
View File
@@ -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
+16
View File
@@ -90,6 +90,7 @@ class Controller {
private lastSave = 0; private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false; private waveLoading = false;
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null; private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private state: PlayerState = { private state: PlayerState = {
@@ -189,6 +190,14 @@ class Controller {
if (!a) return; if (!a) return;
this.attempt = null; this.attempt = null;
const pos = Math.round(lastPositionS * 1000); const pos = Math.round(lastPositionS * 1000);
if (this.neutralSwitch) {
this.neutralSwitch = false;
if (a.playedMs >= EARLY_SKIP_MS) {
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
}
return;
}
const kind = ended ? 'play_end' : a.outcome(pos); const kind = ended ? 'play_end' : a.outcome(pos);
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) }); this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush(); void this.events?.flush();
@@ -316,6 +325,13 @@ class Controller {
); );
await this.replaceUpcoming(res.tracks); await this.replaceUpcoming(res.tracks);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
this.neutralSwitch = true;
await TrackPlayer.skipToNext().catch(() => {
this.neutralSwitch = false;
});
}
} }
private leaveWave() { private leaveWave() {
+22 -4
View File
@@ -186,7 +186,10 @@ export class Player {
void this.load(true); void this.load(true);
} }
/** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */ /**
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) { async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave; const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood); if (!wave) return this.startWave(activity, mood);
@@ -197,10 +200,17 @@ export class Player {
body: { activity, mood }, body: { activity, mood },
}), }),
); );
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming(); this.queue.dropUpcoming();
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url)); this.queue.append(fresh);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
this.syncQueue(); if (fresh.length && this.queue.next()) {
this.finishAttempt('skip', true);
this.syncQueue();
void this.load(true);
} else {
this.syncQueue();
}
} }
stopWave() { stopWave() {
@@ -395,11 +405,19 @@ export class Player {
} }
/** Завершает текущую попытку: событие с реально прослушанным временем. */ /** Завершает текущую попытку: событие с реально прослушанным временем. */
private finishAttempt(type: 'play_end' | 'skip') { /** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
const a = this.attempt; const a = this.attempt;
if (!a) return; if (!a) return;
this.attempt = null; this.attempt = null;
const pos = Math.round(this.audio.currentTime * 1000); const pos = Math.round(this.audio.currentTime * 1000);
if (neutral) {
if (a.playedMs >= EARLY_SKIP_MS) {
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
}
return;
}
const kind = type === 'skip' ? a.outcome(pos) : 'play_end'; const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) }); this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush(); void this.events.flush();
+1 -1
View File
@@ -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 — иначе тяжёлая сборка может подвесить весь хост.
+6 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+50 -46
View File
@@ -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"}})