Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65aef3164a | ||
|
|
12fc866c26 | ||
|
|
f76b3c89b3 | ||
|
|
38edcd3e0e | ||
|
|
42d64cd49f | ||
|
|
58fd82e52b | ||
|
|
4fa1add80d |
No files matched your search
@@ -31,7 +31,10 @@ jobs:
|
|||||||
(cd services/api && go generate ./...)
|
(cd services/api && go generate ./...)
|
||||||
pnpm generate
|
pnpm generate
|
||||||
- name: Проверка, что всё закоммичено
|
- name: Проверка, что всё закоммичено
|
||||||
run: git diff --exit-code -- services/api/internal/api/gen packages
|
# status, а не diff: diff не видит новые неотслеживаемые файлы
|
||||||
|
run: |
|
||||||
|
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
|
||||||
|
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
|
||||||
|
|
||||||
api:
|
api:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -64,14 +64,34 @@ jobs:
|
|||||||
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Свободное место
|
||||||
|
run: df -h / /tmp
|
||||||
|
|
||||||
- name: Сборка APK
|
- name: Сборка APK
|
||||||
working-directory: apps/mobile
|
working-directory: apps/mobile
|
||||||
|
env:
|
||||||
|
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
|
||||||
|
CI: "1"
|
||||||
|
NODE_ENV: production
|
||||||
|
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||||
|
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||||
|
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||||
|
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
|
||||||
run: |
|
run: |
|
||||||
npx expo prebuild --platform android --clean --no-install
|
npx expo prebuild --platform android --clean --no-install
|
||||||
cd android && ./gradlew --no-daemon assembleRelease
|
cd android
|
||||||
|
./gradlew --no-daemon --max-workers=2 assembleRelease -PreactNativeArchitectures=arm64-v8a -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m"
|
||||||
|
|
||||||
|
- name: Свободное место после сбоя
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
df -h / /tmp; free -m || true
|
||||||
|
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
|
||||||
|
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
|
||||||
|
|
||||||
- name: Подпись APK
|
- name: Подпись APK
|
||||||
env:
|
env:
|
||||||
|
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
@@ -86,17 +106,25 @@ jobs:
|
|||||||
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
|
||||||
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
|
||||||
rm release.keystore
|
rm release.keystore
|
||||||
else
|
elif [ "$PRERELEASE" = "true" ]; then
|
||||||
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
|
||||||
cp "$src" "$out"
|
cp "$src" "$out"
|
||||||
|
else
|
||||||
|
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
"$bt/apksigner" verify --print-certs "$out" | head -3
|
"$bt/apksigner" verify --print-certs "$out" | head -3
|
||||||
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
|
||||||
echo "APK=$out" >> "$GITHUB_ENV"
|
echo "APK=$out" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Changelog
|
- name: Changelog
|
||||||
|
env:
|
||||||
|
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
|
||||||
run: |
|
run: |
|
||||||
prev="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
# для финального релиза — изменения с прошлого финального, а не с последнего rc
|
||||||
|
exclude=()
|
||||||
|
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
|
||||||
|
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
||||||
range="${prev:+$prev..}${GITHUB_REF_NAME}"
|
range="${prev:+$prev..}${GITHUB_REF_NAME}"
|
||||||
{
|
{
|
||||||
echo "## Pipu Music ${PIPU_VERSION}"
|
echo "## Pipu Music ${PIPU_VERSION}"
|
||||||
|
|||||||
+1
-1
@@ -85,7 +85,7 @@ components:
|
|||||||
description: Результат проверки каждой зависимости
|
description: Результат проверки каждой зависимости
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
description: "`ok` или текст ошибки"
|
description: "`ok` или `fail` (причина — только в логе сервиса)"
|
||||||
ServerInfo:
|
ServerInfo:
|
||||||
type: object
|
type: object
|
||||||
required: [name, version, api_version, supported_locales, min_android_version_code]
|
required: [name, version, api_version, supported_locales, min_android_version_code]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||||
|
|
||||||
// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=10203.
|
// Версию проставляет CI из git-тега (scripts/version.mjs): PIPU_VERSION=1.2.3, PIPU_VERSION_CODE=1020399.
|
||||||
const version = process.env.PIPU_VERSION ?? '0.0.0';
|
const version = process.env.PIPU_VERSION ?? '0.0.0';
|
||||||
const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1');
|
const versionCode = Number(process.env.PIPU_VERSION_CODE ?? '1');
|
||||||
|
|
||||||
@@ -22,5 +22,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
permissions: ['REQUEST_INSTALL_PACKAGES'],
|
permissions: ['REQUEST_INSTALL_PACKAGES'],
|
||||||
},
|
},
|
||||||
platforms: ['android'],
|
platforms: ['android'],
|
||||||
|
plugins: [
|
||||||
|
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||||
|
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||||
|
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
||||||
|
],
|
||||||
extra: { versionCode },
|
extra: { versionCode },
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,21 @@
|
|||||||
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
|
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
|
||||||
|
const path = require('node:path');
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
module.exports = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
// watchFolders включает весь apps/mobile, а без watchman Metro обходит и хэширует каждый файл.
|
||||||
|
// android/ и ios/ после prebuild содержат гигабайты промежуточных файлов Gradle/CMake —
|
||||||
|
// в release-сборке Metro на них «висит». В JS-бандл из этих папок ничего не попадает.
|
||||||
|
const escape = (p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const nativeDirs = ['android', 'ios'].map((d) => new RegExp(`^${escape(path.join(__dirname, d) + path.sep)}.*`));
|
||||||
|
// Gradle собирает нативные модули прямо в node_modules/<пакет>/android/{build,.cxx,.gradle},
|
||||||
|
// а корневой node_modules тоже в watchFolders.
|
||||||
|
const nativeBuildOutputs = /[\\/]android[\\/](build|\.cxx|\.gradle)[\\/].*/;
|
||||||
|
|
||||||
|
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs, nativeBuildOutputs];
|
||||||
|
|
||||||
|
// В CI раннер делит 8 ГБ RAM с Gradle: по умолчанию Metro запускает воркер на каждое ядро.
|
||||||
|
if (process.env.CI) config.maxWorkers = 2;
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
"@pipu/design-tokens": "workspace:*",
|
"@pipu/design-tokens": "workspace:*",
|
||||||
"@pipu/i18n": "workspace:*",
|
"@pipu/i18n": "workspace:*",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
|
"expo-build-properties": "~0.14.8",
|
||||||
"expo-constants": "~17.1.7",
|
"expo-constants": "~17.1.7",
|
||||||
|
"expo-modules-autolinking": "~2.1.15",
|
||||||
"expo-status-bar": "~2.2.3",
|
"expo-status-bar": "~2.2.3",
|
||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ export function ConnectScreen() {
|
|||||||
const [address, setAddress] = useState('');
|
const [address, setAddress] = useState('');
|
||||||
const [state, setState] = useState<State>({ kind: 'idle' });
|
const [state, setState] = useState<State>({ kind: 'idle' });
|
||||||
|
|
||||||
|
const checking = state.kind === 'checking';
|
||||||
|
|
||||||
async function onCheck() {
|
async function onCheck() {
|
||||||
|
if (checking) return;
|
||||||
const baseUrl = normalizeServerUrl(address);
|
const baseUrl = normalizeServerUrl(address);
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
setState({ kind: 'invalid' });
|
setState({ kind: 'invalid' });
|
||||||
@@ -39,6 +42,7 @@ export function ConnectScreen() {
|
|||||||
value={address}
|
value={address}
|
||||||
onChangeText={setAddress}
|
onChangeText={setAddress}
|
||||||
onSubmitEditing={onCheck}
|
onSubmitEditing={onCheck}
|
||||||
|
editable={!checking}
|
||||||
placeholder="https://music.home.lan"
|
placeholder="https://music.home.lan"
|
||||||
placeholderTextColor={colors.text3}
|
placeholderTextColor={colors.text3}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -62,7 +66,12 @@ export function ConnectScreen() {
|
|||||||
)}
|
)}
|
||||||
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
|
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
|
||||||
|
|
||||||
<Pressable onPress={onCheck} style={({ pressed }) => [styles.button, pressed && { opacity: 0.85 }]}>
|
<Pressable
|
||||||
|
onPress={onCheck}
|
||||||
|
disabled={checking}
|
||||||
|
accessibilityState={{ disabled: checking, busy: checking }}
|
||||||
|
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
|
||||||
|
>
|
||||||
<Text style={styles.buttonText}>{t('server.check')}</Text>
|
<Text style={styles.buttonText}>{t('server.check')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ RUN pnpm --filter @pipu/web build
|
|||||||
|
|
||||||
FROM nginx:1.28-alpine
|
FROM nginx:1.28-alpine
|
||||||
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
COPY deploy/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf
|
||||||
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
|
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
|
||||||
ARG VERSION=0.0.0-dev
|
ARG VERSION=0.0.0-dev
|
||||||
LABEL org.opencontainers.image.version=${VERSION}
|
LABEL org.opencontainers.image.version=${VERSION}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
# PIPU_VERSION проставляет update.sh по git-тегу — вручную не менять
|
# PIPU_VERSION проставляет update.sh по git-тегу — вручную не менять
|
||||||
PIPU_VERSION=dev
|
PIPU_VERSION=dev
|
||||||
PIPU_DATA=/srv/pipu
|
PIPU_DATA=/srv/pipu
|
||||||
|
# Папка с музыкой на локальном диске сервера (источники type=local), например смонтированный диск.
|
||||||
|
# Пусто — $PIPU_DATA/library-host. NFS-хранилища сюда не относятся: их подключает админка.
|
||||||
|
LIBRARY_HOST=
|
||||||
|
|
||||||
# --- Домен и HTTPS (docs/install-guide.md) ---
|
# --- Домен и HTTPS (docs/install-guide.md) ---
|
||||||
# Вариант А: встроенный Caddy с Let's Encrypt — оставьте строку COMPOSE_FILE и заполните DOMAIN, ACME_EMAIL.
|
# Вариант А: встроенный Caddy с Let's Encrypt — оставьте строку COMPOSE_FILE и заполните DOMAIN, ACME_EMAIL.
|
||||||
|
|||||||
@@ -3,16 +3,17 @@
|
|||||||
# docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников
|
# docker compose -f deploy/compose.dev.yaml --profile app up # + api и recommender из исходников
|
||||||
# Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080),
|
# Затем: веб — `pnpm --filter @pipu/web dev` (проксирует /api на :8080),
|
||||||
# api без docker — см. README, раздел «Разработка».
|
# api без docker — см. README, раздел «Разработка».
|
||||||
|
# Порты слушают только 127.0.0.1: у Redis нет пароля, а у dev-Postgres он известен всем.
|
||||||
name: pipu-dev
|
name: pipu-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:0.8.7-pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: pipu
|
POSTGRES_USER: pipu
|
||||||
POSTGRES_PASSWORD: pipu
|
POSTGRES_PASSWORD: pipu
|
||||||
POSTGRES_DB: pipu
|
POSTGRES_DB: pipu
|
||||||
ports: ["5432:5432"]
|
ports: ["127.0.0.1:5432:5432"]
|
||||||
volumes: [pg-data:/var/lib/postgresql/data]
|
volumes: [pg-data:/var/lib/postgresql/data]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"]
|
test: ["CMD-SHELL", "pg_isready -U pipu -d pipu"]
|
||||||
@@ -20,8 +21,8 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7.4-alpine
|
||||||
ports: ["6379:6379"]
|
ports: ["127.0.0.1:6379:6379"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -32,7 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
MEILI_MASTER_KEY: dev-master-key-change-me
|
MEILI_MASTER_KEY: dev-master-key-change-me
|
||||||
MEILI_ENV: development
|
MEILI_ENV: development
|
||||||
ports: ["7700:7700"]
|
ports: ["127.0.0.1:7700:7700"]
|
||||||
volumes: [meili-data:/meili_data]
|
volumes: [meili-data:/meili_data]
|
||||||
|
|
||||||
api:
|
api:
|
||||||
@@ -45,7 +46,7 @@ services:
|
|||||||
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
LOG_LEVEL: debug
|
LOG_LEVEL: debug
|
||||||
ports: ["8080:8080"]
|
ports: ["127.0.0.1:8080:8080"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
@@ -59,7 +60,7 @@ services:
|
|||||||
DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu
|
DATABASE_URL: postgresql://pipu:pipu@postgres:5432/pipu
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
INTERNAL_TOKEN: dev-internal-token
|
INTERNAL_TOKEN: dev-internal-token
|
||||||
ports: ["8000:8000"]
|
ports: ["127.0.0.1:8000:8000"]
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
redis: { condition: service_healthy }
|
redis: { condition: service_healthy }
|
||||||
|
|||||||
+29
-8
@@ -17,6 +17,14 @@ x-library-ro: &library-ro
|
|||||||
read_only: true
|
read_only: true
|
||||||
bind: { propagation: rslave }
|
bind: { propagation: rslave }
|
||||||
|
|
||||||
|
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
||||||
|
# Пишет в него только storage-agent (импорт одобренных загрузок), остальным — только чтение.
|
||||||
|
x-library-host-ro: &library-host-ro
|
||||||
|
type: bind
|
||||||
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
|
target: /library-host
|
||||||
|
read_only: true
|
||||||
|
|
||||||
x-restart: &restart
|
x-restart: &restart
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
@@ -31,6 +39,7 @@ services:
|
|||||||
ports: ["${HTTP_PORT:-80}:80"]
|
ports: ["${HTTP_PORT:-80}:80"]
|
||||||
volumes:
|
volumes:
|
||||||
- *library-ro
|
- *library-ro
|
||||||
|
- *library-host-ro
|
||||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
|
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
|
||||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||||
depends_on: [api]
|
depends_on: [api]
|
||||||
@@ -45,9 +54,10 @@ services:
|
|||||||
args: *version
|
args: *version
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment: &db-env
|
environment: &db-env
|
||||||
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD}@postgres:5432/pipu?sslmode=disable
|
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
|
||||||
volumes:
|
volumes:
|
||||||
- *library-ro
|
- *library-ro
|
||||||
|
- *library-host-ro
|
||||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
|
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
|
||||||
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
|
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
|
||||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
|
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
|
||||||
@@ -67,13 +77,17 @@ services:
|
|||||||
cap_add: [SYS_ADMIN]
|
cap_add: [SYS_ADMIN]
|
||||||
security_opt: ["apparmor:unconfined"]
|
security_opt: ["apparmor:unconfined"]
|
||||||
environment:
|
environment:
|
||||||
INTERNAL_TOKEN: ${INTERNAL_TOKEN}
|
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
|
||||||
LIBRARY_ROOT: /library
|
LIBRARY_ROOT: /library
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ${PIPU_DATA:-/srv/pipu}/library
|
source: ${PIPU_DATA:-/srv/pipu}/library
|
||||||
target: /library
|
target: /library
|
||||||
bind: { propagation: rshared }
|
bind: { propagation: rshared }
|
||||||
|
- type: bind
|
||||||
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
|
target: /library-host
|
||||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||||
|
|
||||||
recommender:
|
recommender:
|
||||||
@@ -81,8 +95,12 @@ services:
|
|||||||
image: pipu-music-recommender:${PIPU_VERSION:-dev}
|
image: pipu-music-recommender:${PIPU_VERSION:-dev}
|
||||||
build: *build-rec
|
build: *build-rec
|
||||||
command: ["serve"]
|
command: ["serve"]
|
||||||
env_file: .env
|
# только нужные переменные, а не весь .env с секретами Meilisearch и Gitea
|
||||||
environment: *db-env
|
environment:
|
||||||
|
<<: *db-env
|
||||||
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
|
|
||||||
@@ -91,10 +109,13 @@ services:
|
|||||||
image: pipu-music-recommender:${PIPU_VERSION:-dev}
|
image: pipu-music-recommender:${PIPU_VERSION:-dev}
|
||||||
build: *build-rec
|
build: *build-rec
|
||||||
command: ["analyze", "--workers", "4"]
|
command: ["analyze", "--workers", "4"]
|
||||||
env_file: .env
|
environment:
|
||||||
environment: *db-env
|
<<: *db-env
|
||||||
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- *library-ro
|
- *library-ro
|
||||||
|
- *library-host-ro
|
||||||
- ${PIPU_DATA:-/srv/pipu}/models:/models
|
- ${PIPU_DATA:-/srv/pipu}/models:/models
|
||||||
deploy: { resources: { limits: { cpus: "4" } } }
|
deploy: { resources: { limits: { cpus: "4" } } }
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -102,7 +123,7 @@ services:
|
|||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:0.8.7-pg17
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: pipu
|
POSTGRES_USER: pipu
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
@@ -115,7 +136,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: redis:7-alpine
|
image: redis:7.4-alpine
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ["redis-server", "--appendonly", "yes"]
|
||||||
volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ]
|
volumes: [ "${PIPU_DATA:-/srv/pipu}/redis:/data" ]
|
||||||
|
|
||||||
|
|||||||
+26
-2
@@ -12,6 +12,21 @@ DATA="${PIPU_DATA:-/srv/pipu}"
|
|||||||
echo "→ каталоги в $DATA"
|
echo "→ каталоги в $DATA"
|
||||||
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config}
|
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config}
|
||||||
|
|
||||||
|
# Каталоги, куда пишут сервисы без root: api (distroless nonroot, uid 65532) и analyzer (uid 10001).
|
||||||
|
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
|
||||||
|
chown 65532:65532 "$DATA"/{cache,cache/transcode,cache/covers,updates,uploads}
|
||||||
|
chown 10001:10001 "$DATA/models"
|
||||||
|
|
||||||
|
# Папка с локальной музыкой: sudo LIBRARY_HOST=/mnt/music ./install.sh (или LIBRARY_HOST в .env)
|
||||||
|
LIBRARY_HOST="${LIBRARY_HOST:-$( [ -f .env ] && sed -n 's/^LIBRARY_HOST=//p' .env || true )}"
|
||||||
|
if [ -n "$LIBRARY_HOST" ]; then
|
||||||
|
if [ ! -d "$LIBRARY_HOST" ]; then
|
||||||
|
echo "LIBRARY_HOST=$LIBRARY_HOST: каталога нет — смонтируйте диск с музыкой (docs/install-guide.md, шаг 4)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
findmnt -n --target "$LIBRARY_HOST" -o TARGET,SOURCE | sed 's/^/→ музыка: /'
|
||||||
|
fi
|
||||||
|
|
||||||
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
|
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
|
||||||
# каталог должен быть отдельной точкой монтирования с shared propagation.
|
# каталог должен быть отдельной точкой монтирования с shared propagation.
|
||||||
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
|
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
|
||||||
@@ -30,7 +45,7 @@ Before=docker.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/bin/sh -c 'findmnt -n $DATA/library || mount --bind $DATA/library $DATA/library; mount --make-rshared $DATA/library'
|
ExecStart=/bin/sh -c 'findmnt -n "$DATA/library" || mount --bind "$DATA/library" "$DATA/library"; mount --make-rshared "$DATA/library"'
|
||||||
RemainAfterExit=yes
|
RemainAfterExit=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -45,10 +60,19 @@ secret() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40; }
|
|||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "→ .env из .env.example"
|
echo "→ .env из .env.example"
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|" .env
|
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|; s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
|
||||||
chmod 600 .env
|
chmod 600 .env
|
||||||
chown "$OWNER" .env
|
chown "$OWNER" .env
|
||||||
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
|
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||||
|
if [ -n "$LIBRARY_HOST" ]; then
|
||||||
|
if grep -q '^LIBRARY_HOST=' .env; then
|
||||||
|
sed -i "s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
|
||||||
|
else
|
||||||
|
echo "LIBRARY_HOST=$LIBRARY_HOST" >> .env
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
||||||
@@ -26,6 +26,8 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
client_max_body_size 16m;
|
client_max_body_size 16m;
|
||||||
|
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $pipu_scheme;
|
proxy_set_header X-Forwarded-Proto $pipu_scheme;
|
||||||
|
# api берёт IP клиента только из X-Real-IP; остальные заголовки клиент может подделать
|
||||||
|
proxy_set_header True-Client-IP "";
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /healthz { proxy_pass http://pipu_api; }
|
location = /healthz { proxy_pass http://pipu_api; }
|
||||||
@@ -44,17 +48,20 @@ server {
|
|||||||
|
|
||||||
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
|
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
|
||||||
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
|
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
|
||||||
|
location /_internal/library-host/ { internal; alias /library-host/; sendfile on; aio threads; }
|
||||||
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
|
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
|
||||||
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
|
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
|
||||||
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
|
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
|
||||||
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Заголовки безопасности для SPA. Подключается через include: add_header внутри location
|
||||||
|
# отменяет унаследованные с уровня server, поэтому include повторяется там, где есть свои add_header.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||||
@@ -26,6 +26,23 @@ elif [[ "$REF" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
|
|||||||
REF="v$REF"
|
REF="v$REF"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему.
|
||||||
|
CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)"
|
||||||
|
TARGET="${REF#v}"
|
||||||
|
core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; }
|
||||||
|
cur_core="$(core "$CURRENT" || true)"
|
||||||
|
tgt_core="$(core "$TARGET" || true)"
|
||||||
|
# сравниваем только MAJOR.MINOR.PATCH: переход 0.2.0-rc.1 → 0.2.0 откатом не считается
|
||||||
|
if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
|
||||||
|
[ "$(printf '%s\n%s\n' "$cur_core" "$tgt_core" | sort -V | head -1)" = "$tgt_core" ]; then
|
||||||
|
echo "внимание: $CURRENT → $TARGET — это откат. Миграции БД не откатываются;" >&2
|
||||||
|
echo " сделайте резервную копию (docs/deployment.md) и проверьте, что старая версия совместима со схемой." >&2
|
||||||
|
if [ "${PIPU_ALLOW_DOWNGRADE:-}" != 1 ]; then
|
||||||
|
echo " Чтобы продолжить: PIPU_ALLOW_DOWNGRADE=1 ./update.sh $REF" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "→ переключаемся на $REF"
|
echo "→ переключаемся на $REF"
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||||
git checkout --quiet "$REF"
|
git checkout --quiet "$REF"
|
||||||
|
|||||||
+5
-1
@@ -3,6 +3,10 @@
|
|||||||
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
|
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
|
||||||
с доступом к Docker.
|
с доступом к Docker.
|
||||||
|
|
||||||
|
Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle,
|
||||||
|
C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`,
|
||||||
|
освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`.
|
||||||
|
|
||||||
## Версионирование
|
## Версионирование
|
||||||
|
|
||||||
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
|
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
|
||||||
@@ -66,7 +70,7 @@ Docker-образы **не публикуются**: сервер собирае
|
|||||||
| Секрет | Назначение |
|
| Секрет | Назначение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
|
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
|
||||||
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. |
|
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -15,7 +15,7 @@
|
|||||||
```
|
```
|
||||||
/srv/pipu/
|
/srv/pipu/
|
||||||
├── library/ # точки монтирования источников (storage-agent, rshared)
|
├── library/ # точки монтирования источников (storage-agent, rshared)
|
||||||
├── library-host/ # опционально: заранее смонтированные папки для type=local
|
├── library-host/ # папка для type=local по умолчанию; обычно вместо неё LIBRARY_HOST=/mnt/music
|
||||||
├── cache/
|
├── cache/
|
||||||
│ ├── transcode/
|
│ ├── transcode/
|
||||||
│ └── covers/
|
│ └── covers/
|
||||||
@@ -29,6 +29,10 @@
|
|||||||
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
|
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
|
||||||
|
каталог хоста монтируется в `api`, `nginx` и `analyzer` только на чтение, в `storage-agent` — на запись
|
||||||
|
(импорт одобренных загрузок), внутри контейнеров он виден как `/library-host`.
|
||||||
|
|
||||||
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
|
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
|
||||||
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
|
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
|
||||||
|
|
||||||
@@ -46,8 +50,8 @@
|
|||||||
| `api` | `services/api/Dockerfile`, target `api` | Миграции при старте |
|
| `api` | `services/api/Dockerfile`, target `api` | Миграции при старте |
|
||||||
| `storage-agent` | `services/api/Dockerfile`, target `storage-agent` | `CAP_SYS_ADMIN`, `apparmor:unconfined`, библиотека с `propagation: rshared` |
|
| `storage-agent` | `services/api/Dockerfile`, target `storage-agent` | `CAP_SYS_ADMIN`, `apparmor:unconfined`, библиотека с `propagation: rshared` |
|
||||||
| `recommender`, `analyzer` | `services/recommender/Dockerfile` (один образ) | Команды `serve` и `analyze --workers 4` |
|
| `recommender`, `analyzer` | `services/recommender/Dockerfile` (один образ) | Команды `serve` и `analyze --workers 4` |
|
||||||
| `postgres` | `pgvector/pgvector:pg17` | Healthcheck `pg_isready` |
|
| `postgres` | `pgvector/pgvector:0.8.7-pg17` | Healthcheck `pg_isready` |
|
||||||
| `redis` | `redis:7-alpine` | AOF |
|
| `redis` | `redis:7.4-alpine` | AOF |
|
||||||
| `meilisearch` | `getmeili/meilisearch:v1.11` | |
|
| `meilisearch` | `getmeili/meilisearch:v1.11` | |
|
||||||
| `caddy` (из `compose.tls.yaml`) | `caddy:2.10-alpine` | HTTPS на `DOMAIN`, nginx тогда без открытых портов |
|
| `caddy` (из `compose.tls.yaml`) | `caddy:2.10-alpine` | HTTPS на `DOMAIN`, nginx тогда без открытых портов |
|
||||||
|
|
||||||
|
|||||||
+76
-17
@@ -6,8 +6,9 @@
|
|||||||
|
|
||||||
В примерах:
|
В примерах:
|
||||||
- домен — `music.example.com`, замените на свой;
|
- домен — `music.example.com`, замените на свой;
|
||||||
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`;
|
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`, клонируется в `/opt/pipu-music`;
|
||||||
- каталог установки — `/opt/pipu-music`.
|
- диск с музыкой смонтирован в `/mnt/music`;
|
||||||
|
- данные сервиса (БД, кэши, сертификаты) — в `/srv/pipu`.
|
||||||
|
|
||||||
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
|
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
|
||||||
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
|
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
|
||||||
@@ -22,11 +23,11 @@
|
|||||||
|
|
||||||
| Что | Требование |
|
| Что | Требование |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на диске (система, образы, кэш сборки, данные) |
|
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на системном диске (система, образы, кэш сборки, данные) |
|
||||||
| Доступ | root или пользователь с `sudo` |
|
| Доступ | root или пользователь с `sudo` |
|
||||||
| Домен | Поддомен, которым вы управляете, например `music.example.com` |
|
| Домен | Поддомен, которым вы управляете, например `music.example.com` |
|
||||||
| Сеть | Входящие **80/tcp** и **443/tcp** (+ **443/udp** для HTTP/3) из интернета на сервер |
|
| Сеть | Входящие **80/tcp** и **443/tcp** (+ **443/udp** для HTTP/3) из интернета на сервер |
|
||||||
| Музыка | NFS-сервер с библиотекой (подключается позже из админки) |
|
| Музыка | Отдельный диск с библиотекой, подключённый к серверу (шаг 4). NFS-хранилища можно добавить позже из админки |
|
||||||
| Интернет на сервере | Нужен при сборке: скачиваются базовые образы и зависимости Go / npm / Python |
|
| Интернет на сервере | Нужен при сборке: скачиваются базовые образы и зависимости Go / npm / Python |
|
||||||
| Gitea | Доступ к репозиторию на чтение (если он приватный — токен или SSH-ключ) |
|
| Gitea | Доступ к репозиторию на чтение (если он приватный — токен или SSH-ключ) |
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ dig +short music.example.com
|
|||||||
```
|
```
|
||||||
|
|
||||||
> Сертификат Let's Encrypt выдаётся, только когда домен уже указывает на сервер и порт 80 доступен из интернета.
|
> Сертификат Let's Encrypt выдаётся, только когда домен уже указывает на сервер и порт 80 доступен из интернета.
|
||||||
> Не запускайте шаг 6, пока эта проверка не проходит.
|
> Не запускайте шаг 7, пока эта проверка не проходит.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,7 +87,51 @@ sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 443/udp
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Клонирование репозитория
|
## 4. Диск с музыкой
|
||||||
|
|
||||||
|
Музыка лежит на отдельном диске, смонтированном в `/mnt/music`. Если диск уже смонтирован — проверьте его
|
||||||
|
командой из конца шага и переходите к шагу 5.
|
||||||
|
|
||||||
|
Найдите диск и его UUID (раздел с музыкой — например, `/dev/sdb1`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsblk -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Создайте точку монтирования:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /mnt/music
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавьте в `/etc/fstab` строку (подставьте свой UUID и файловую систему из вывода `lsblk -f`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
UUID=1234abcd-0000-0000-0000-000000000000 /mnt/music ext4 defaults,nofail,noatime 0 2
|
||||||
|
```
|
||||||
|
|
||||||
|
`nofail` нужен, чтобы сервер загружался, даже если диск не подключён. Смонтируйте и проверьте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mount -a && findmnt /mnt/music && ls /mnt/music | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервисы Pipu работают в контейнерах не от root, поэтому музыка должна быть доступна на чтение всем:
|
||||||
|
файлы — `644`, каталоги — `755`. Если раньше права были закрытыми:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chmod -R a+rX /mnt/music
|
||||||
|
```
|
||||||
|
|
||||||
|
> Для дисков с NTFS / exFAT права задаются опциями монтирования, а не `chmod`: добавьте в fstab
|
||||||
|
> `uid=0,gid=0,umask=022` вместо `defaults`.
|
||||||
|
|
||||||
|
Pipu только читает этот диск. Записывать в него будет лишь импорт одобренных треков из очереди на добавление,
|
||||||
|
и только если в админке разрешить это для хранилища ([uploads.md](uploads.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Клонирование репозитория
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo mkdir -p /opt/pipu-music && sudo chown "$USER" /opt/pipu-music
|
sudo mkdir -p /opt/pipu-music && sudo chown "$USER" /opt/pipu-music
|
||||||
@@ -104,16 +149,18 @@ git clone https://gitea.hsrv.site/mrleo1nid/pipu-music.git /opt/pipu-music && cd
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Подготовка сервера
|
## 6. Подготовка сервера
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./install.sh
|
sudo LIBRARY_HOST=/mnt/music ./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт:
|
Скрипт:
|
||||||
- создаёт каталоги данных в `/srv/pipu` (БД, кэши, загрузки, сертификаты);
|
- создаёт каталоги данных в `/srv/pipu` (БД, кэши, загрузки, сертификаты);
|
||||||
- делает `/srv/pipu/library` точкой монтирования с shared propagation, чтобы NFS-хранилища, которые потом
|
- делает `/srv/pipu/library` точкой монтирования с shared propagation, чтобы NFS-хранилища, которые потом
|
||||||
подключит админ, были видны всем контейнерам, и ставит systemd-юнит, повторяющий это после перезагрузки;
|
подключит админ, были видны всем контейнерам, и ставит systemd-юнит, повторяющий это после перезагрузки;
|
||||||
|
- проверяет, что `/mnt/music` существует, и записывает его в `.env` как `LIBRARY_HOST` — эту папку получат
|
||||||
|
контейнеры `api`, `nginx` и `analyzer` (внутри они видят её как `/library-host`);
|
||||||
- создаёт `.env` со случайными паролями БД, ключом поиска и межсервисным токеном.
|
- создаёт `.env` со случайными паролями БД, ключом поиска и межсервисным токеном.
|
||||||
|
|
||||||
Заполните в `/opt/pipu-music/deploy/.env`:
|
Заполните в `/opt/pipu-music/deploy/.env`:
|
||||||
@@ -124,6 +171,14 @@ DOMAIN=music.example.com
|
|||||||
ACME_EMAIL=you@example.com
|
ACME_EMAIL=you@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Проверьте, что там же стоит путь к музыке (его вписал `install.sh`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
LIBRARY_HOST=/mnt/music
|
||||||
|
```
|
||||||
|
|
||||||
|
Если диск смонтирован в другое место — поправьте путь здесь и перезапустите сервисы (`./update.sh`).
|
||||||
|
|
||||||
`ACME_EMAIL` — адрес, на который Let's Encrypt пишет, если с сертификатом что-то не так.
|
`ACME_EMAIL` — адрес, на который Let's Encrypt пишет, если с сертификатом что-то не так.
|
||||||
Остальные значения менять не нужно; `PIPU_VERSION` проставляет `update.sh`.
|
Остальные значения менять не нужно; `PIPU_VERSION` проставляет `update.sh`.
|
||||||
|
|
||||||
@@ -131,7 +186,7 @@ ACME_EMAIL=you@example.com
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Сборка и запуск
|
## 7. Сборка и запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./update.sh
|
./update.sh
|
||||||
@@ -161,7 +216,7 @@ curl -s https://music.example.com/readyz
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Веб-клиент
|
## 8. Веб-клиент
|
||||||
|
|
||||||
Откройте `https://music.example.com` в браузере.
|
Откройте `https://music.example.com` в браузере.
|
||||||
|
|
||||||
@@ -176,12 +231,14 @@ curl -s https://music.example.com/readyz
|
|||||||
Сразу после входа приложение попросит сменить логин и пароль администратора
|
Сразу после входа приложение попросит сменить логин и пароль администратора
|
||||||
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
|
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
|
||||||
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
|
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
|
||||||
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → адрес NFS-сервера и путь экспорта
|
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
|
||||||
→ «Проверить» → шаблоны путей → «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
|
подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
|
||||||
|
→ «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
|
||||||
|
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Android-приложение
|
## 9. Android-приложение
|
||||||
|
|
||||||
1. На телефоне откройте страницу релизов
|
1. На телефоне откройте страницу релизов
|
||||||
`https://gitea.hsrv.site/mrleo1nid/pipu-music/releases` и скачайте `pipu-music-<версия>.apk`.
|
`https://gitea.hsrv.site/mrleo1nid/pipu-music/releases` и скачайте `pipu-music-<версия>.apk`.
|
||||||
@@ -201,7 +258,7 @@ curl -s https://music.example.com/readyz
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Вариант: свой reverse proxy вместо Caddy
|
## 10. Вариант: свой reverse proxy вместо Caddy
|
||||||
|
|
||||||
Если на сервере уже есть Traefik, Nginx Proxy Manager или свой nginx с сертификатами:
|
Если на сервере уже есть Traefik, Nginx Proxy Manager или свой nginx с сертификатами:
|
||||||
|
|
||||||
@@ -238,14 +295,14 @@ curl -s https://music.example.com/readyz
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Запуск и проверки — как в шагах 6–8.
|
3. Запуск и проверки — как в шагах 7–9.
|
||||||
|
|
||||||
Если прокси работает на другой машине, укажите в `HTTP_PORT` внутренний адрес сервера (`192.168.1.10:8081`) и
|
Если прокси работает на другой машине, укажите в `HTTP_PORT` внутренний адрес сервера (`192.168.1.10:8081`) и
|
||||||
закройте этот порт от интернета файрволом.
|
закройте этот порт от интернета файрволом.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Обновление, бэкапы, удаление
|
## 11. Обновление, бэкапы, удаление
|
||||||
|
|
||||||
Обновление до последнего релиза:
|
Обновление до последнего релиза:
|
||||||
|
|
||||||
@@ -271,7 +328,7 @@ cd /opt/pipu-music/deploy && docker compose down
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Если что-то не работает
|
## 12. Если что-то не работает
|
||||||
|
|
||||||
| Симптом | Что проверить |
|
| Симптом | Что проверить |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -282,6 +339,8 @@ cd /opt/pipu-music/deploy && docker compose down
|
|||||||
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
||||||
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
||||||
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
|
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
|
||||||
|
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
|
||||||
|
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
|
||||||
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
|
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
|
||||||
| Android: «Сервер не отвечает» | Тот же адрес открывается в браузере телефона; в адресе нет опечатки; телефон в сети с доступом к серверу |
|
| Android: «Сервер не отвечает» | Тот же адрес открывается в браузере телефона; в адресе нет опечатки; телефон в сети с доступом к серверу |
|
||||||
| Android: «Эта версия приложения слишком старая» | Установите APK из последнего релиза |
|
| Android: «Эта версия приложения слишком старая» | Установите APK из последнего релиза |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
| Тип | Как подключается |
|
| Тип | Как подключается |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o <opts> server:export /library/<id>`. |
|
| `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o <opts> server:export /library/<id>`. |
|
||||||
| `local` | Папка, уже смонтированная на хосте в `/srv/pipu/library-host` (например, через fstab). Админ выбирает подпапку. Нужен как запасной путь и для dev. |
|
| `local` | Папка на диске сервера, заранее смонтированная на хосте (например, диск `/mnt/music` через fstab) и указанная в `LIBRARY_HOST`. В контейнерах видна как `/library-host`; админ выбирает подпапку. |
|
||||||
|
|
||||||
### Монтирование (storage-agent)
|
### Монтирование (storage-agent)
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
|
||||||
@@ -52,6 +52,11 @@ describe('checkServer', () => {
|
|||||||
expect(res).toEqual({ status: 'offline', error: 'Failed to fetch' });
|
expect(res).toEqual({ status: 'offline', error: 'Failed to fetch' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports offline when the server does not answer in time', async () => {
|
||||||
|
const client = createApiClient({ baseUrl: 'https://music.test', fetch: () => new Promise<Response>(() => {}) });
|
||||||
|
await expect(checkServer(client, undefined, 20)).resolves.toEqual({ status: 'offline', error: 'timeout' });
|
||||||
|
});
|
||||||
|
|
||||||
it('reports offline with the problem code on API errors', async () => {
|
it('reports offline with the problem code on API errors', async () => {
|
||||||
const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500)));
|
const res = await checkServer(clientReturning(() => json({ status: 500, code: 'common.internal' }, 500)));
|
||||||
expect(res).toEqual({ status: 'offline', error: 'common.internal' });
|
expect(res).toEqual({ status: 'offline', error: 'common.internal' });
|
||||||
|
|||||||
@@ -25,19 +25,42 @@ export type ServerCheck =
|
|||||||
| { status: 'unsupported_app'; info: ServerInfo }
|
| { status: 'unsupported_app'; info: ServerInfo }
|
||||||
| { status: 'offline'; error: string };
|
| { status: 'offline'; error: string };
|
||||||
|
|
||||||
|
/** Сколько ждать ответа сервера, прежде чем считать его недоступным. */
|
||||||
|
export const SERVER_CHECK_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверяет сервер через GET /api/v1/server-info.
|
* Проверяет сервер через GET /api/v1/server-info.
|
||||||
* appVersionCode — versionCode Android-клиента; для веба не передаётся.
|
* appVersionCode — versionCode Android-клиента; для веба не передаётся.
|
||||||
*/
|
*/
|
||||||
export async function checkServer(client: ApiClient, appVersionCode?: number): Promise<ServerCheck> {
|
export async function checkServer(
|
||||||
|
client: ApiClient,
|
||||||
|
appVersionCode?: number,
|
||||||
|
timeoutMs = SERVER_CHECK_TIMEOUT_MS,
|
||||||
|
): Promise<ServerCheck> {
|
||||||
|
// Гонка с таймером, а не только abort: не каждая реализация fetch (Hermes, полифилы)
|
||||||
|
// надёжно прерывает запрос по сигналу. AbortSignal.timeout не используется — его может не быть в Hermes.
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
reject(new Error('timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const { data, error } = await client.GET('/api/v1/server-info');
|
const { data, error } = await Promise.race([
|
||||||
|
client.GET('/api/v1/server-info', { signal: controller.signal }),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
if (!data) return { status: 'offline', error: error ? error.code : 'unknown' };
|
if (!data) return { status: 'offline', error: error ? error.code : 'unknown' };
|
||||||
if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) {
|
if (appVersionCode !== undefined && appVersionCode < data.min_android_version_code) {
|
||||||
return { status: 'unsupported_app', info: data };
|
return { status: 'unsupported_app', info: data };
|
||||||
}
|
}
|
||||||
return { status: 'ok', info: data };
|
return { status: 'ok', info: data };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (controller.signal.aborted) return { status: 'offline', error: 'timeout' };
|
||||||
return { status: 'offline', error: e instanceof Error ? e.message : String(e) };
|
return { status: 'offline', error: e instanceof Error ? e.message : String(e) };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+91
-59
@@ -31,13 +31,19 @@ importers:
|
|||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
expo:
|
expo:
|
||||||
specifier: ~53.0.20
|
specifier: ~53.0.20
|
||||||
version: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
|
version: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
|
expo-build-properties:
|
||||||
|
specifier: ~0.14.8
|
||||||
|
version: 0.14.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))
|
||||||
expo-constants:
|
expo-constants:
|
||||||
specifier: ~17.1.7
|
specifier: ~17.1.7
|
||||||
version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
|
version: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||||
|
expo-modules-autolinking:
|
||||||
|
specifier: ~2.1.15
|
||||||
|
version: 2.1.15
|
||||||
expo-status-bar:
|
expo-status-bar:
|
||||||
specifier: ~2.2.3
|
specifier: ~2.2.3
|
||||||
version: 2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
version: 2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^25.2.1
|
specifier: ^25.2.1
|
||||||
version: 25.10.10(typescript@5.8.3)
|
version: 25.10.10(typescript@5.8.3)
|
||||||
@@ -46,10 +52,10 @@ importers:
|
|||||||
version: 19.0.0
|
version: 19.0.0
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^15.5.3
|
specifier: ^15.5.3
|
||||||
version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3)
|
version: 15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(typescript@5.8.3)
|
||||||
react-native:
|
react-native:
|
||||||
specifier: 0.79.6
|
specifier: 0.79.6
|
||||||
version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ~19.0.10
|
specifier: ~19.0.10
|
||||||
@@ -108,13 +114,13 @@ importers:
|
|||||||
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
|
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^26.1.0
|
specifier: ^26.1.0
|
||||||
version: 26.1.0(supports-color@10.2.2)
|
version: 26.1.0
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.3.5
|
specifier: ^6.3.5
|
||||||
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
|
|
||||||
packages/api-client:
|
packages/api-client:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -134,7 +140,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
|
|
||||||
packages/design-tokens: {}
|
packages/design-tokens: {}
|
||||||
|
|
||||||
@@ -1370,6 +1376,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||||
|
|
||||||
anser@1.4.10:
|
anser@1.4.10:
|
||||||
resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==}
|
resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==}
|
||||||
|
|
||||||
@@ -1880,6 +1889,11 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-build-properties@0.14.8:
|
||||||
|
resolution: {integrity: sha512-GTFNZc5HaCS9RmCi6HspCe2+isleuOWt2jh7UEKHTDQ9tdvzkIoWc7U6bQO9lH3Mefk4/BcCUZD/utl7b1wdqw==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-constants@17.1.8:
|
expo-constants@17.1.8:
|
||||||
resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==}
|
resolution: {integrity: sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1943,6 +1957,9 @@ packages:
|
|||||||
fast-json-stable-stringify@2.1.0:
|
fast-json-stable-stringify@2.1.0:
|
||||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||||
|
|
||||||
|
fast-uri@3.1.8:
|
||||||
|
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||||
|
|
||||||
fb-watchman@2.0.4:
|
fb-watchman@2.0.4:
|
||||||
resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==}
|
resolution: {integrity: sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==}
|
||||||
|
|
||||||
@@ -4454,11 +4471,11 @@ snapshots:
|
|||||||
|
|
||||||
'@expo/sudo-prompt@9.3.2': {}
|
'@expo/sudo-prompt@9.3.2': {}
|
||||||
|
|
||||||
'@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
|
'@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
|
|
||||||
'@expo/ws-tunnel@1.0.6': {}
|
'@expo/ws-tunnel@1.0.6': {}
|
||||||
|
|
||||||
@@ -4652,13 +4669,13 @@ snapshots:
|
|||||||
nullthrows: 1.1.1
|
nullthrows: 1.1.1
|
||||||
yargs: 17.7.3
|
yargs: 17.7.3
|
||||||
|
|
||||||
'@react-native/community-cli-plugin@0.79.6':
|
'@react-native/community-cli-plugin@0.79.6(supports-color@10.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@react-native/dev-middleware': 0.79.6
|
'@react-native/dev-middleware': 0.79.6
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
debug: 2.6.9
|
debug: 2.6.9
|
||||||
invariant: 2.2.4
|
invariant: 2.2.4
|
||||||
metro: 0.82.5
|
metro: 0.82.5(supports-color@10.2.2)
|
||||||
metro-config: 0.82.5
|
metro-config: 0.82.5
|
||||||
metro-core: 0.82.5
|
metro-core: 0.82.5
|
||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
@@ -4693,12 +4710,12 @@ snapshots:
|
|||||||
|
|
||||||
'@react-native/normalize-colors@0.79.6': {}
|
'@react-native/normalize-colors@0.79.6': {}
|
||||||
|
|
||||||
'@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)':
|
'@react-native/virtualized-lists@0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
invariant: 2.2.4
|
invariant: 2.2.4
|
||||||
nullthrows: 1.1.1
|
nullthrows: 1.1.1
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.0.14
|
'@types/react': 19.0.14
|
||||||
|
|
||||||
@@ -4995,6 +5012,13 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
|
ajv@8.20.0:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-uri: 3.1.8
|
||||||
|
json-schema-traverse: 1.0.0
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
anser@1.4.10: {}
|
anser@1.4.10: {}
|
||||||
|
|
||||||
ansi-colors@4.1.3: {}
|
ansi-colors@4.1.3: {}
|
||||||
@@ -5511,39 +5535,45 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.4.0: {}
|
expect-type@1.4.0: {}
|
||||||
|
|
||||||
expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
|
expo-asset@11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@expo/image-utils': 0.7.6
|
'@expo/image-utils': 0.7.6
|
||||||
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
|
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
|
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)):
|
expo-build-properties@0.14.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)):
|
||||||
|
dependencies:
|
||||||
|
ajv: 8.20.0
|
||||||
|
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
|
semver: 7.8.5
|
||||||
|
|
||||||
|
expo-constants@17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@expo/config': 11.0.13
|
'@expo/config': 11.0.13
|
||||||
'@expo/env': 1.0.7
|
'@expo/env': 1.0.7
|
||||||
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
|
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)):
|
expo-file-system@18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
|
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
|
|
||||||
expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
|
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
fontfaceobserver: 2.3.0
|
fontfaceobserver: 2.3.0
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
|
|
||||||
expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
expo-keep-awake@14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2)
|
expo: 53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2)
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
|
|
||||||
expo-modules-autolinking@2.1.15:
|
expo-modules-autolinking@2.1.15:
|
||||||
@@ -5560,14 +5590,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
invariant: 2.2.4
|
invariant: 2.2.4
|
||||||
|
|
||||||
expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
|
expo-status-bar@2.2.3(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
react-native-is-edge-to-edge: 1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
|
|
||||||
expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2):
|
expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.10
|
'@babel/runtime': 7.29.10
|
||||||
'@expo/cli': 0.24.24(supports-color@10.2.2)
|
'@expo/cli': 0.24.24(supports-color@10.2.2)
|
||||||
@@ -5575,18 +5605,18 @@ snapshots:
|
|||||||
'@expo/config-plugins': 10.1.2
|
'@expo/config-plugins': 10.1.2
|
||||||
'@expo/fingerprint': 0.13.4(supports-color@10.2.2)
|
'@expo/fingerprint': 0.13.4(supports-color@10.2.2)
|
||||||
'@expo/metro-config': 0.20.18
|
'@expo/metro-config': 0.20.18
|
||||||
'@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
'@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
babel-preset-expo: 13.2.5(@babel/core@7.29.7)(supports-color@10.2.2)
|
babel-preset-expo: 13.2.5(@babel/core@7.29.7)(supports-color@10.2.2)
|
||||||
expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
expo-asset: 11.1.7(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
|
expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||||
expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))
|
expo-file-system: 18.1.11(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
|
||||||
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
expo-font: 13.3.2(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
expo-keep-awake: 14.1.4(expo@53.0.27(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
expo-modules-autolinking: 2.1.15
|
expo-modules-autolinking: 2.1.15
|
||||||
expo-modules-core: 2.5.0
|
expo-modules-core: 2.5.0
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
react-native-edge-to-edge: 1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
whatwg-url-without-unicode: 8.0.0-3
|
whatwg-url-without-unicode: 8.0.0-3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -5602,6 +5632,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
|
|
||||||
|
fast-uri@3.1.8: {}
|
||||||
|
|
||||||
fb-watchman@2.0.4:
|
fb-watchman@2.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
bser: 2.1.2
|
bser: 2.1.2
|
||||||
@@ -5724,7 +5756,7 @@ snapshots:
|
|||||||
statuses: 2.0.2
|
statuses: 2.0.2
|
||||||
toidentifier: 1.0.1
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
http-proxy-agent@7.0.2(supports-color@10.2.2):
|
http-proxy-agent@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@10.2.2)
|
debug: 4.4.3(supports-color@10.2.2)
|
||||||
@@ -5909,13 +5941,13 @@ snapshots:
|
|||||||
|
|
||||||
jsc-safe-url@0.2.4: {}
|
jsc-safe-url@0.2.4: {}
|
||||||
|
|
||||||
jsdom@26.1.0(supports-color@10.2.2):
|
jsdom@26.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
cssstyle: 4.6.0
|
cssstyle: 4.6.0
|
||||||
data-urls: 5.0.0
|
data-urls: 5.0.0
|
||||||
decimal.js: 10.6.0
|
decimal.js: 10.6.0
|
||||||
html-encoding-sniffer: 4.0.0
|
html-encoding-sniffer: 4.0.0
|
||||||
http-proxy-agent: 7.0.2(supports-color@10.2.2)
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
https-proxy-agent: 7.0.6(supports-color@10.2.2)
|
||||||
is-potential-custom-element-name: 1.0.1
|
is-potential-custom-element-name: 1.0.1
|
||||||
nwsapi: 2.2.28
|
nwsapi: 2.2.28
|
||||||
@@ -6076,7 +6108,7 @@ snapshots:
|
|||||||
cosmiconfig: 5.2.1
|
cosmiconfig: 5.2.1
|
||||||
flow-enums-runtime: 0.0.6
|
flow-enums-runtime: 0.0.6
|
||||||
jest-validate: 29.7.0
|
jest-validate: 29.7.0
|
||||||
metro: 0.82.5
|
metro: 0.82.5(supports-color@10.2.2)
|
||||||
metro-cache: 0.82.5
|
metro-cache: 0.82.5
|
||||||
metro-core: 0.82.5
|
metro-core: 0.82.5
|
||||||
metro-runtime: 0.82.5
|
metro-runtime: 0.82.5
|
||||||
@@ -6163,7 +6195,7 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.9
|
'@babel/parser': 7.29.9
|
||||||
'@babel/types': 7.29.8
|
'@babel/types': 7.29.8
|
||||||
flow-enums-runtime: 0.0.6
|
flow-enums-runtime: 0.0.6
|
||||||
metro: 0.82.5
|
metro: 0.82.5(supports-color@10.2.2)
|
||||||
metro-babel-transformer: 0.82.5
|
metro-babel-transformer: 0.82.5
|
||||||
metro-cache: 0.82.5
|
metro-cache: 0.82.5
|
||||||
metro-cache-key: 0.82.5
|
metro-cache-key: 0.82.5
|
||||||
@@ -6176,7 +6208,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
|
|
||||||
metro@0.82.5:
|
metro@0.82.5(supports-color@10.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
@@ -6516,40 +6548,40 @@ snapshots:
|
|||||||
react-dom: 19.3.1(react@19.3.1)
|
react-dom: 19.3.1(react@19.3.1)
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)(typescript@5.8.3):
|
react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.10
|
'@babel/runtime': 7.29.10
|
||||||
html-parse-stringify: 3.1.0
|
html-parse-stringify: 3.1.0
|
||||||
i18next: 25.10.10(typescript@5.8.3)
|
i18next: 25.10.10(typescript@5.8.3)
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
react-is@17.0.2: {}
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
|
||||||
react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
|
react-native-edge-to-edge@1.6.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
|
|
||||||
react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0):
|
react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)
|
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
|
||||||
|
|
||||||
react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0):
|
react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/create-cache-key-function': 29.7.0
|
'@jest/create-cache-key-function': 29.7.0
|
||||||
'@react-native/assets-registry': 0.79.6
|
'@react-native/assets-registry': 0.79.6
|
||||||
'@react-native/codegen': 0.79.6(@babel/core@7.29.7)
|
'@react-native/codegen': 0.79.6(@babel/core@7.29.7)
|
||||||
'@react-native/community-cli-plugin': 0.79.6
|
'@react-native/community-cli-plugin': 0.79.6(supports-color@10.2.2)
|
||||||
'@react-native/gradle-plugin': 0.79.6
|
'@react-native/gradle-plugin': 0.79.6
|
||||||
'@react-native/js-polyfills': 0.79.6
|
'@react-native/js-polyfills': 0.79.6
|
||||||
'@react-native/normalize-colors': 0.79.6
|
'@react-native/normalize-colors': 0.79.6
|
||||||
'@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)
|
'@react-native/virtualized-lists': 0.79.6(@types/react@19.0.14)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
|
||||||
abort-controller: 3.0.0
|
abort-controller: 3.0.0
|
||||||
anser: 1.4.10
|
anser: 1.4.10
|
||||||
ansi-regex: 5.0.1
|
ansi-regex: 5.0.1
|
||||||
@@ -7050,7 +7082,7 @@ snapshots:
|
|||||||
lightningcss: 1.27.0
|
lightningcss: 1.27.0
|
||||||
terser: 5.51.2
|
terser: 5.51.2
|
||||||
|
|
||||||
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.7
|
'@vitest/expect': 3.2.7
|
||||||
@@ -7077,7 +7109,7 @@ snapshots:
|
|||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 26.6.5
|
'@types/node': 26.6.5
|
||||||
jsdom: 26.1.0(supports-color@10.2.2)
|
jsdom: 26.1.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
|
|||||||
+19
-4
@@ -1,7 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование).
|
// Вычисляет версию продукта из git-тега (docs/ci-cd.md#версионирование).
|
||||||
// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT)
|
// node scripts/version.mjs v1.2.3 → печатает KEY=VALUE (для $GITHUB_ENV / $GITHUB_OUTPUT);
|
||||||
// node scripts/version.mjs → берёт GITHUB_REF_NAME, иначе 0.0.0-dev
|
// тег не по формату — ошибка (релиз не должен стать 0.0.0-dev)
|
||||||
|
// node scripts/version.mjs → берёт GITHUB_REF_NAME, если это релизный тег, иначе 0.0.0-dev
|
||||||
//
|
//
|
||||||
// Android versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N),
|
// Android versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N),
|
||||||
// поэтому 1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1.
|
// поэтому 1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1.
|
||||||
@@ -23,9 +24,23 @@ export function parseVersion(tag) {
|
|||||||
return { version, versionCode, prerelease: rc !== null };
|
return { version, versionCode, prerelease: rc !== null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEV = { version: '0.0.0-dev', versionCode: 1, prerelease: true };
|
||||||
|
|
||||||
|
export function resolveVersion(argTag, refName) {
|
||||||
|
if (argTag !== undefined) return parseVersion(argTag);
|
||||||
|
const ref = refName ?? '';
|
||||||
|
return TAG_RE.test(ref) ? parseVersion(ref) : DEV;
|
||||||
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME ?? '';
|
let v;
|
||||||
const v = TAG_RE.test(tag) ? parseVersion(tag) : { version: '0.0.0-dev', versionCode: 1, prerelease: true };
|
try {
|
||||||
|
v = resolveVersion(process.argv[2], process.env.GITHUB_REF_NAME);
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(`${e.message}
|
||||||
|
`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`,
|
`PIPU_VERSION=${v.version}\nPIPU_VERSION_CODE=${v.versionCode}\nPIPU_PRERELEASE=${v.prerelease}\n`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
|
|
||||||
import { parseVersion } from './version.mjs';
|
import { parseVersion, resolveVersion } from './version.mjs';
|
||||||
|
|
||||||
test('release tag', () => {
|
test('release tag', () => {
|
||||||
assert.deepEqual(parseVersion('v0.1.0'), { version: '0.1.0', versionCode: 10_099, prerelease: false });
|
assert.deepEqual(parseVersion('v0.1.0'), { version: '0.1.0', versionCode: 10_099, prerelease: false });
|
||||||
@@ -23,3 +23,14 @@ test('rejects malformed tags', () => {
|
|||||||
assert.throws(() => parseVersion(tag), undefined, tag);
|
assert.throws(() => parseVersion(tag), undefined, tag);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('explicit tag must be a release tag', () => {
|
||||||
|
assert.throws(() => resolveVersion('v1.2.3-beta', undefined));
|
||||||
|
assert.equal(resolveVersion('v1.2.3', 'main').version, '1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('branch builds fall back to the dev version', () => {
|
||||||
|
assert.equal(resolveVersion(undefined, 'main').version, '0.0.0-dev');
|
||||||
|
assert.equal(resolveVersion(undefined, undefined).version, '0.0.0-dev');
|
||||||
|
assert.equal(resolveVersion(undefined, 'v2.0.0').version, '2.0.0');
|
||||||
|
});
|
||||||
@@ -3,13 +3,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
@@ -63,37 +60,12 @@ func run() error {
|
|||||||
Version: buildinfo.Version,
|
Version: buildinfo.Version,
|
||||||
SupportedLocales: cfg.SupportedLocales,
|
SupportedLocales: cfg.SupportedLocales,
|
||||||
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
|
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
|
||||||
}, map[string]system.Checker{
|
}, log, map[string]system.Checker{
|
||||||
"postgres": system.CheckerFunc(pool.Ping),
|
"postgres": system.CheckerFunc(pool.Ping),
|
||||||
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
|
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
|
||||||
Addr: cfg.HTTPAddr,
|
log.Info("starting", "version", buildinfo.Version)
|
||||||
Handler: server.NewRouter(server.Deps{Log: log, System: sys}),
|
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
return serve(ctx, log, srv, cfg.ShutdownTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error {
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version)
|
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
errCh <- err
|
|
||||||
}
|
|
||||||
close(errCh)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
return err
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("shutting down")
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
||||||
defer cancel()
|
|
||||||
return srv.Shutdown(shutdownCtx)
|
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"pipu/api/internal/buildinfo"
|
"pipu/api/internal/buildinfo"
|
||||||
"pipu/api/internal/platform/config"
|
"pipu/api/internal/platform/config"
|
||||||
@@ -14,21 +15,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load[config.StorageAgent]()
|
if err := run(); err != nil {
|
||||||
if err != nil {
|
slog.Error("storage-agent stopped", "err", err)
|
||||||
slog.Error("storage-agent: config", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
log := httpx.NewLogger(cfg.LogLevel)
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: cfg.HTTPAddr,
|
|
||||||
Handler: storageagent.NewRouter(cfg.InternalToken, cfg.LibraryRoot),
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
log.Info("listening", "addr", srv.Addr, "version", buildinfo.Version)
|
|
||||||
if err := srv.ListenAndServe(); err != nil {
|
|
||||||
log.Error("storage-agent stopped", "err", err)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
cfg, err := config.Load[config.StorageAgent]()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log := httpx.NewLogger(cfg.LogLevel)
|
||||||
|
slog.SetDefault(log)
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
srv := httpx.NewServer(cfg.HTTPAddr, storageagent.NewRouter(log, cfg.InternalToken, cfg.LibraryRoot))
|
||||||
|
log.Info("starting", "version", buildinfo.Version)
|
||||||
|
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||||
|
}
|
||||||
@@ -474,28 +474,28 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||||
var swaggerSpec = []string{
|
var swaggerSpec = []string{
|
||||||
|
|
||||||
"H4sIAAAAAAAC/7xWX28bRRD/KquFh1hcbIdSQf1WVUD7gBQliJc2SjZ322Sb+8feXkSILMUJapFSGqkg",
|
"H4sIAAAAAAAC/7xW3W4bRRR+ldHARSw2tkOpoL6rKqC9QIoSxE0bJZPdSTLN/jE7GxEiS3GCWqSURioI",
|
||||||
"xANq6Tdwgt0cdn35CrNfgU+CZu/OvnMuITyUJ/t2Z3fm95vfzM4+tQMvDHzuq4h29qnkURj4ETcfyzLY",
|
"cYFa+gabYDdLHG9e4cwr8CTozO7aa2cTwkW5snf3zPn5vu+cOXvUDrww8LmvItrZo5JHYeBH3DwsymDd",
|
||||||
"dLmHf+3AV9xX+JeFoStspkTgt8LM4qMnUeDjXmRvc4/hvw8lf0w79IPW7P5Wthu1inu73a5FHR7ZUoR4",
|
"5R7+tQNfcV/hXxaGrrCZEoHfCnOLj55GgY/fInuLewz/fSj5Bu3QD1oT/638a9Qq/Xa7XYs6PLKlCNEd",
|
||||||
"He1QeKV/hAROYQR9srDyxT1y55PbnzYoWubH8fb7nLlqe1UxFZvvUAYhl0pkcUfTde7HHu08pMEOXbOo",
|
"7VB4rX+EFE7gHBIyt/TFA3Lvk7ufNihaFsfR+0POXLW1rJiKzXMog5BLJfK8o/F77sce7TymwTZdsaja",
|
||||||
"2gs57dBISeFvmSsl/zYWkjtok5+a2QWbT7itaNcqE1H1ZAcOx985EL9D38CYwEQfw18ERpDCgEA6BZcQ",
|
"DTnt0EhJ4W8al5J/GwvJHbQpTk3sgvWn3Fa0a1WBmI5kBw7H35kifofElDGCkT6CvwicQwZ9Atm4uJRA",
|
||||||
"GMBYnxCx9JlvEZhAHy70ASTwDob6gGywWG03hb/LXOGs25I73FeCudEGvYQDOVRMuDWBvIZUn+geTGAI",
|
"H4b6mIiFz3yLwAgSuNT7kMIFDPQ+WWOx2moKf4e5wlm1JXe4rwRzozV6pQ7EUDHh1iTyBjJ9rHswggGM",
|
||||||
"E0hgWLiFMaTwJ6RwRhZwd7p+ASlyD+fQzz7G+jmcoyH09SEMYaxfNOpiCJlkniGFOY7AAJi7XCJLyZjX",
|
"IIVBGRaGkMGfkMEpmcOv4/eXkCH2cAZJ/jDUL+AMDSHRBzCAoX7ZqMshZJJ5BhTmOAITYO5iBSwlY14D",
|
||||||
"UDvLVb4lfMW3uMS9bAVBPWaxq2iHss0gVp1Nl/k79Ib5tLIk1aV1hTNn7yoR2dvc3rkGzzzXG8HOBoEE",
|
"7YSr4pPwFd/kEr/lb7CoDRa7inYoWw9i1Vl3mb9Nb8mnlZNUR+sSZ87udSKyt7i9fUM9s1ivBdtrBFIY",
|
||||||
"xpAQQ9RI9/RhJef1qask7A8Ywrk+Qtb1ITJOjCyQ/qE+yISD6XkLA0hRWefQhzNIdM9IJ0WfZUd1RJeK",
|
"QkrWNphw18hcTql+bqSQkL/3fyH6oMD0HDICpyURA6J7SDycQqp7kDTqaZ4i9w8YwJk+RG/6ANkhJh5S",
|
||||||
"Av1vSeZw58b1YRXM1DG6yuUulw/8x8FlQlko1ne5jETWKvh3zAtdPL67VEeMJ/x15jsyEE5xbP3KckuM",
|
"hZ6MyJDKd9CHDFV4BknhH2WW6Z4+gJTeSEqlgTD+pmQOd27dS1aJYh36y1zucPnI3wiugs9CsbrDZSTy",
|
||||||
"ut9B38g1K7r80L3A4eRudtFiXmMo/rdZSegTy5SnPoTUED3MZD+AgWH8LSQofRjqQ6J7ZsmkglozBEvt",
|
"scK/Y17o4vGdhTpgPOGvMt+RgXDKY6vXtmZqOuECEkND3qDFoQeBw8n93NF80Y/Iz7u8ffSxZVoZOTRA",
|
||||||
"9p07Vo2AfebxKtZQhPGiF0fCrsMcxWEYSMWddTewmZvxNj38kMoY3fpIvVDcK5fN7JJ8gUnJ9vC7xPkc",
|
"D/IW6UPfIP4OUmwTGOiDCZ0DvU+tSQUL7fa9e1aN2H3m8elaQxHG814cCbuu5igOw0Aq7qy6gc3cHLfx",
|
||||||
"by8RCCpHn1SwYetd5d43XDbKMGm7udRs/2vdGdAzt1Yl8XUYr8n1ZY2hN5Hra77Z6SM4xfrTz3IJrHy+",
|
"4cdUxhjWR+iF4l61xSZOihdMSraLzxXMZ3B7hYWgcvTxVG04ppe59w2XjWqZtN1caLb/tUdN0ZOw1hTx",
|
||||||
"+jW5u/yALIswJl8h7U0Cv5kiSdEKEhgRrB6sGjxU9MF5OgIWikUMaYv7jUd+VoZj7KgwwcuwjwYh99EM",
|
"dTXewPVVjWE0UehrdjDqQzjBXtXPCwksfb78Nbm/+IgsijAmXyHsTQK/mSbJ0ApSOCfYPdg1eKicmbNw",
|
||||||
"I84CazQJvDGNdmxehFxx5O+DX4gT2FGLhaLpOc1HSIwSyrA8ixVDLzFZJKBr0dwV7dBbzXbzFsX2q7aN",
|
"BCwU85jSJvcbT/y8DYc4fWFk2v+UzAUh99EMM84TazQJvDWzYGhuj0JxZmg4gR21WCiantN8gsAooQzK",
|
||||||
"HvDO1u5SKzKluFhwtcVVnQL0MZzr40LhuodPwCn2IAJ9fWT6GOJL9A9IFfT1U0gg6RBkBk4JJEVpVfqU",
|
"k1wx9QqSJQFdixahaIfeababdyiOarVl9IA+WzsLrci04nyJ1SZXdQrQR3Cmj0qF6x5eFyc4gwgk+tBc",
|
||||||
"PtEvsHWdTQX2Yo5R65Gve8Yc37uM/rx96edVbvvopb4m4Z0+hiHRJwYENscF/ROM0AFMijMjfLL0s4L8",
|
"Dlhfqn9AqCDRzyCFtEMQGTghkJatNTWn9LF+iaPrdCywlzOIWk983TPmeDfm8BfjS7+YxjbBKPU9CRf6",
|
||||||
"RsY2diYzwzxwaId+yVWpdVnVMejjdvuaEei/jT4lL3XTzxvD62AqFEirvA2zJyN/DutdTWMvzVlYdp7H",
|
"CMfssSkCh+Oc/gnOMQCMyjPneL3p5yX4jRxtnExm33nk0A79kqvK6LKmV6aP2+0b1qX/tiZVotRtSm8N",
|
||||||
"5N6NnEyHgTmJo0jZVmQeg71IcY+u4dWtbTONfV+S2SV27+cm75HaykxYR+5rFKh+avTWI5mK5rn51Uhr",
|
"rv2xUCCbxm2QXxnF1Vkfapx7ZSfDtvM8JndvFWS8OMxIHEXKNiNzGexGint0BV23tszm9n1FZlfQfViY",
|
||||||
"qsOL8hHoX0WAxEHiWvwrmcV7hF8eZurQv8QcXzEvYMIzzEdwgW0QZXa7fet/C+4VDHAELuYnnEh1z0zN",
|
"vEdop/bHOnDfoED1M6O3HslVNIvNr0ZaYx1eVo9Ach0AEpeOG+tfyi3eY/nVxaeu+lfI8TX7AhKe13wI",
|
||||||
"+J6PIK0PfIgT0MSIeS78alZ/zt53OIPJLLcpnOoejPVR0U+y5fN8Eke7Y7JwQ8IatdLAKEzB4/o+jaVL",
|
"lzgGUWZ323f+t+ReQ99sU8Wuhdur7pkNe7xW1SU+wA1oZMQ8k/40qz/n9zucwmjCbQYnugdDfVjOk/z1",
|
||||||
"O7RFu2tT4/18RigOdde6/wQAAP//nG+aXpMNAAA=",
|
"WbG1o90RmbslYI1aaWAWpuHx/R6NpUs7tEW7K2PjvWJHKA91V7r/BAAA//8arOezvw0AAA==",
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSwagger returns the content of the embedded swagger specification file
|
// GetSwagger returns the content of the embedded swagger specification file
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ type API struct {
|
|||||||
|
|
||||||
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
|
// StorageAgent — конфигурация внутреннего сервиса storage-agent.
|
||||||
type StorageAgent struct {
|
type StorageAgent struct {
|
||||||
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
|
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8090"`
|
||||||
InternalToken string `env:"INTERNAL_TOKEN,required"`
|
InternalToken string `env:"INTERNAL_TOKEN,required,notEmpty"`
|
||||||
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
|
LibraryRoot string `env:"LIBRARY_ROOT" envDefault:"/library"`
|
||||||
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
|
||||||
|
ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"15s"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load заполняет структуру конфигурации T из окружения.
|
// Load заполняет структуру конфигурации T из окружения.
|
||||||
|
|||||||
@@ -24,3 +24,10 @@ func TestLoadAPIRequiresDatabase(t *testing.T) {
|
|||||||
_, err := Load[API]()
|
_, err := Load[API]()
|
||||||
assert.ErrorContains(t, err, "DATABASE_URL")
|
assert.ErrorContains(t, err, "DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadStorageAgentRejectsEmptyToken(t *testing.T) {
|
||||||
|
t.Setenv("INTERNAL_TOKEN", "")
|
||||||
|
|
||||||
|
_, err := Load[StorageAgent]()
|
||||||
|
assert.ErrorContains(t, err, "INTERNAL_TOKEN")
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
|
"github.com/pressly/goose/v3/lock"
|
||||||
|
|
||||||
"pipu/api/migrations"
|
"pipu/api/migrations"
|
||||||
)
|
)
|
||||||
@@ -26,12 +27,17 @@ func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
|
|||||||
return pool, nil
|
return pool, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate применяет встроенные миграции goose.
|
// Migrate применяет встроенные миграции goose. Advisory-lock в PostgreSQL не даёт
|
||||||
|
// нескольким экземплярам api применять миграции одновременно.
|
||||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
sqlDB := stdlib.OpenDBFromPool(pool)
|
sqlDB := stdlib.OpenDBFromPool(pool)
|
||||||
defer func(d *sql.DB) { _ = d.Close() }(sqlDB)
|
defer func(d *sql.DB) { _ = d.Close() }(sqlDB)
|
||||||
|
|
||||||
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS)
|
locker, err := lock.NewPostgresSessionLocker()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: migrations lock: %w", err)
|
||||||
|
}
|
||||||
|
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, migrations.FS, goose.WithSessionLocker(locker))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("db: migrations: %w", err)
|
return fmt.Errorf("db: migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewServer создаёт http.Server с общими таймаутами. WriteTimeout не задаётся:
|
||||||
|
// стриминг аудио и загрузки держат ответ открытым дольше любого разумного предела.
|
||||||
|
func NewServer(addr string, h http.Handler) *http.Server {
|
||||||
|
return &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: h,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve запускает сервер и корректно останавливает его при отмене ctx.
|
||||||
|
func Serve(ctx context.Context, log *slog.Logger, srv *http.Server, shutdownTimeout time.Duration) error {
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
log.Info("listening", "addr", srv.Addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("shutting down")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return srv.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealIP подставляет в RemoteAddr адрес клиента из X-Real-IP, который выставляет nginx.
|
||||||
|
// В отличие от middleware.RealIP из chi, не доверяет True-Client-IP и X-Forwarded-For:
|
||||||
|
// их клиент может прислать сам, а nginx пропускает их дальше без изменений.
|
||||||
|
func RealIP(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil {
|
||||||
|
r.RemoteAddr = ip
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRealIP(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
headers map[string]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no headers", nil, "192.0.2.1:1234"},
|
||||||
|
{"x-real-ip", map[string]string{"X-Real-IP": "203.0.113.7"}, "203.0.113.7"},
|
||||||
|
{"invalid x-real-ip", map[string]string{"X-Real-IP": "evil"}, "192.0.2.1:1234"},
|
||||||
|
{"spoofed true-client-ip", map[string]string{"True-Client-IP": "1.1.1.1"}, "192.0.2.1:1234"},
|
||||||
|
{"spoofed x-forwarded-for", map[string]string{"X-Forwarded-For": "1.1.1.1"}, "192.0.2.1:1234"},
|
||||||
|
{
|
||||||
|
"x-real-ip wins over spoofed headers",
|
||||||
|
map[string]string{"X-Real-IP": "203.0.113.7", "True-Client-IP": "1.1.1.1"},
|
||||||
|
"203.0.113.7",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var got string
|
||||||
|
h := RealIP(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { got = r.RemoteAddr }))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.RemoteAddr = "192.0.2.1:1234"
|
||||||
|
for k, v := range tc.headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
assert.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ func NewRouter(d Deps) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID, middleware.RealIP, httpx.AccessLog(log), middleware.Recoverer)
|
r.Use(middleware.RequestID, httpx.RealIP, httpx.AccessLog(log), middleware.Recoverer)
|
||||||
r.NotFound(httpx.NotFound)
|
r.NotFound(httpx.NotFound)
|
||||||
r.MethodNotAllowed(httpx.MethodNotAllowed)
|
r.MethodNotAllowed(httpx.MethodNotAllowed)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package storageagent
|
|||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -21,8 +22,12 @@ type Mount struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN.
|
// NewRouter возвращает роутер агента. token — общий секрет INTERNAL_TOKEN;
|
||||||
func NewRouter(token, libraryRoot string) http.Handler {
|
// при пустом token все запросы к /internal отклоняются.
|
||||||
|
func NewRouter(log *slog.Logger, token, libraryRoot string) http.Handler {
|
||||||
|
if log == nil {
|
||||||
|
log = slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.NotFound(httpx.NotFound)
|
r.NotFound(httpx.NotFound)
|
||||||
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
@@ -30,10 +35,11 @@ func NewRouter(token, libraryRoot string) http.Handler {
|
|||||||
})
|
})
|
||||||
r.Route("/internal", func(r chi.Router) {
|
r.Route("/internal", func(r chi.Router) {
|
||||||
r.Use(requireToken(token))
|
r.Use(requireToken(token))
|
||||||
r.Get("/mounts", func(w http.ResponseWriter, _ *http.Request) {
|
r.Get("/mounts", func(w http.ResponseWriter, r *http.Request) {
|
||||||
mounts, err := listMounts(libraryRoot)
|
mounts, err := listMounts(libraryRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", err.Error())
|
log.ErrorContext(r.Context(), "list mounts", "err", err)
|
||||||
|
httpx.WriteProblem(w, http.StatusInternalServerError, "storage.list_failed", "cannot list mounts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
|
writeJSON(w, http.StatusOK, map[string]any{"mounts": mounts})
|
||||||
@@ -46,7 +52,7 @@ func requireToken(token string) func(http.Handler) http.Handler {
|
|||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
|
if !ok || token == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
|
||||||
httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
|
httpx.WriteProblem(w, http.StatusUnauthorized, "auth.invalid_internal_token", "invalid internal token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestInternalRequiresToken(t *testing.T) {
|
func TestInternalRequiresToken(t *testing.T) {
|
||||||
h := NewRouter("secret", t.TempDir())
|
h := NewRouter(nil, "secret", t.TempDir())
|
||||||
|
|
||||||
for _, auth := range []string{"", "Bearer wrong", "secret"} {
|
for _, auth := range []string{"", "Bearer wrong", "secret"} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -26,6 +26,18 @@ func TestInternalRequiresToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInternalRejectsEverythingWithEmptyToken(t *testing.T) {
|
||||||
|
h := NewRouter(nil, "", t.TempDir())
|
||||||
|
|
||||||
|
for _, auth := range []string{"", "Bearer ", "Bearer x"} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, rec.Code, "auth=%q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListMounts(t *testing.T) {
|
func TestListMounts(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755))
|
require.NoError(t, os.Mkdir(filepath.Join(root, "src-1"), 0o755))
|
||||||
@@ -34,7 +46,7 @@ func TestListMounts(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
|
req := httptest.NewRequest(http.MethodGet, "/internal/mounts", nil)
|
||||||
req.Header.Set("Authorization", "Bearer secret")
|
req.Header.Set("Authorization", "Bearer secret")
|
||||||
NewRouter("secret", root).ServeHTTP(rec, req)
|
NewRouter(nil, "secret", root).ServeHTTP(rec, req)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, rec.Code)
|
require.Equal(t, http.StatusOK, rec.Code)
|
||||||
var body struct{ Mounts []Mount }
|
var body struct{ Mounts []Mount }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"pipu/api/internal/api/gen"
|
"pipu/api/internal/api/gen"
|
||||||
@@ -29,13 +31,17 @@ type Info struct {
|
|||||||
// Handler реализует системную часть gen.StrictServerInterface.
|
// Handler реализует системную часть gen.StrictServerInterface.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
info Info
|
info Info
|
||||||
|
log *slog.Logger
|
||||||
checks map[string]Checker
|
checks map[string]Checker
|
||||||
checkTimeout time.Duration
|
checkTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler создаёт обработчик. checks — именованные проверки для /readyz.
|
// NewHandler создаёт обработчик. checks — именованные проверки для /readyz.
|
||||||
func NewHandler(info Info, checks map[string]Checker) *Handler {
|
func NewHandler(info Info, log *slog.Logger, checks map[string]Checker) *Handler {
|
||||||
return &Handler{info: info, checks: checks, checkTimeout: 2 * time.Second}
|
if log == nil {
|
||||||
|
log = slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
return &Handler{info: info, log: log, checks: checks, checkTimeout: 2 * time.Second}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHealthz сообщает, что процесс жив; зависимости не проверяет.
|
// GetHealthz сообщает, что процесс жив; зависимости не проверяет.
|
||||||
@@ -43,21 +49,35 @@ func (h *Handler) GetHealthz(context.Context, gen.GetHealthzRequestObject) (gen.
|
|||||||
return gen.GetHealthz200JSONResponse{Status: gen.HealthStatusStatusOk}, nil
|
return gen.GetHealthz200JSONResponse{Status: gen.HealthStatusStatusOk}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetReadyz проверяет все зависимости; 503, если хотя бы одна недоступна.
|
// GetReadyz параллельно проверяет все зависимости; 503, если хотя бы одна недоступна.
|
||||||
|
// /readyz доступен снаружи, поэтому текст ошибки пишется только в лог, а в ответ — "fail".
|
||||||
func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (gen.GetReadyzResponseObject, error) {
|
func (h *Handler) GetReadyz(ctx context.Context, _ gen.GetReadyzRequestObject) (gen.GetReadyzResponseObject, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, h.checkTimeout)
|
ctx, cancel := context.WithTimeout(ctx, h.checkTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
results := make(map[string]string, len(h.checks))
|
var (
|
||||||
healthy := true
|
mu sync.Mutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
results = make(map[string]string, len(h.checks))
|
||||||
|
healthy = true
|
||||||
|
)
|
||||||
for name, c := range h.checks {
|
for name, c := range h.checks {
|
||||||
if err := c.Check(ctx); err != nil {
|
wg.Add(1)
|
||||||
results[name] = err.Error()
|
go func() {
|
||||||
healthy = false
|
defer wg.Done()
|
||||||
continue
|
err := c.Check(ctx)
|
||||||
}
|
mu.Lock()
|
||||||
results[name] = "ok"
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
h.log.WarnContext(ctx, "readiness check failed", "check", name, "err", err)
|
||||||
|
results[name] = "fail"
|
||||||
|
healthy = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results[name] = "ok"
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
if !healthy {
|
if !healthy {
|
||||||
return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil
|
return gen.GetReadyz503JSONResponse{Status: gen.ReadyStatusStatusDegraded, Checks: results}, nil
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func newServer(checks map[string]system.Checker) http.Handler {
|
|||||||
Version: "1.2.3",
|
Version: "1.2.3",
|
||||||
SupportedLocales: []string{"ru", "en"},
|
SupportedLocales: []string{"ru", "en"},
|
||||||
MinAndroidVersionCode: 10203,
|
MinAndroidVersionCode: 10203,
|
||||||
}, checks)
|
}, nil, checks)
|
||||||
return server.NewRouter(server.Deps{System: h})
|
return server.NewRouter(server.Deps{System: h})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ func TestReadyz(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||||
assert.Equal(t, "degraded", body["status"])
|
assert.Equal(t, "degraded", body["status"])
|
||||||
assert.Equal(t, map[string]any{"postgres": "ok", "redis": "connection refused"}, body["checks"])
|
assert.Equal(t, map[string]any{"postgres": "ok", "redis": "fail"}, body["checks"])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""FastAPI-приложение внутреннего API рекомендаций."""
|
"""FastAPI-приложение внутреннего API рекомендаций."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
@@ -11,6 +13,10 @@ from pipu_rec.settings import Settings
|
|||||||
|
|
||||||
Check = Callable[[], Awaitable[None]]
|
Check = Callable[[], Awaitable[None]]
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CHECK_TIMEOUT_S = 2.0
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI:
|
def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI:
|
||||||
app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None)
|
app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None)
|
||||||
@@ -26,15 +32,20 @@ def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> Fa
|
|||||||
async def healthz() -> dict[str, str]:
|
async def healthz() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
async def run_check(name: str, check: Check) -> str:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(check(), CHECK_TIMEOUT_S)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("readiness check %s failed: %r", name, exc)
|
||||||
|
return "fail"
|
||||||
|
return "ok"
|
||||||
|
|
||||||
@app.get("/readyz")
|
@app.get("/readyz")
|
||||||
async def readyz() -> JSONResponse:
|
async def readyz() -> JSONResponse:
|
||||||
results: dict[str, str] = {}
|
# Проверки параллельные, каждая ограничена таймаутом; причину ошибки пишем только в лог.
|
||||||
for name, check in checks.items():
|
names = list(checks)
|
||||||
try:
|
outcomes = await asyncio.gather(*(run_check(n, checks[n]) for n in names))
|
||||||
await check()
|
results = dict(zip(names, outcomes, strict=True))
|
||||||
results[name] = "ok"
|
|
||||||
except Exception as exc:
|
|
||||||
results[name] = str(exc) or type(exc).__name__
|
|
||||||
healthy = all(v == "ok" for v in results.values())
|
healthy = all(v == "ok" for v in results.values())
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"status": "ok" if healthy else "degraded", "checks": results},
|
{"status": "ok" if healthy else "degraded", "checks": results},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ log = logging.getLogger("pipu_rec")
|
|||||||
|
|
||||||
|
|
||||||
def _serve(settings: Settings) -> None:
|
def _serve(settings: Settings) -> None:
|
||||||
rdb = aioredis.Redis.from_url(settings.redis_url)
|
rdb = aioredis.Redis.from_url(settings.redis_url, socket_connect_timeout=2, socket_timeout=2)
|
||||||
|
|
||||||
async def check_postgres() -> None:
|
async def check_postgres() -> None:
|
||||||
async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn:
|
async with await psycopg.AsyncConnection.connect(settings.database_url, connect_timeout=2) as conn:
|
||||||
|
|||||||
@@ -32,7 +32,21 @@ def test_readyz_all_up() -> None:
|
|||||||
def test_readyz_dependency_down() -> None:
|
def test_readyz_dependency_down() -> None:
|
||||||
r = client({"postgres": ok, "redis": down}).get("/readyz")
|
r = client({"postgres": ok, "redis": down}).get("/readyz")
|
||||||
assert r.status_code == 503
|
assert r.status_code == 503
|
||||||
assert r.json()["checks"]["redis"] == "connection refused"
|
assert r.json()["checks"]["redis"] == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_readyz_dependency_hangs(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pipu_rec.api import app as app_module
|
||||||
|
|
||||||
|
async def hang() -> None:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
monkeypatch.setattr(app_module, "CHECK_TIMEOUT_S", 0.05)
|
||||||
|
r = client({"postgres": ok, "redis": hang}).get("/readyz")
|
||||||
|
assert r.status_code == 503
|
||||||
|
assert r.json()["checks"] == {"postgres": "ok", "redis": "fail"}
|
||||||
|
|
||||||
|
|
||||||
def test_internal_status_requires_token() -> None:
|
def test_internal_status_requires_token() -> None:
|
||||||
|
|||||||
Reference in new issue
Block a user