feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
This commit is contained in:
1 parent
e33abc948c
commit
a29a8b18ad
49 files changed
+3575
-324
No files matched your search
@@ -20,6 +20,8 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
|
||||
# env / secrets
|
||||
deploy/config.local
|
||||
deploy/.management.lock
|
||||
.env
|
||||
*.pem
|
||||
*.keystore
|
||||
|
||||
@@ -22,6 +22,104 @@ tags:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/v1/admin/server/updates:
|
||||
get:
|
||||
operationId: getServerUpdates
|
||||
tags: [admin]
|
||||
parameters:
|
||||
- in: query
|
||||
name: source_id
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerUpdates"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
post:
|
||||
operationId: startServerUpdate
|
||||
tags: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerUpdateRequest"
|
||||
responses:
|
||||
"202":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerJob"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/server/update-sources:
|
||||
put:
|
||||
operationId: saveServerUpdateSources
|
||||
tags: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerSources"
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerSources"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/server/config:
|
||||
get:
|
||||
operationId: getServerConfig
|
||||
tags: [admin]
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerConfig"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
patch:
|
||||
operationId: saveServerConfig
|
||||
tags: [admin]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerConfigPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerConfig"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/server/config/apply:
|
||||
post:
|
||||
operationId: applyServerConfig
|
||||
tags: [admin]
|
||||
responses:
|
||||
"202":
|
||||
description: Server management result
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServerJob"
|
||||
default:
|
||||
$ref: "#/components/responses/Problem"
|
||||
/api/v1/admin/genius:
|
||||
get:
|
||||
operationId: getGeniusStats
|
||||
@@ -1832,6 +1930,85 @@ components:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
ServerSource:
|
||||
type: object
|
||||
required: [id, name, url]
|
||||
properties:
|
||||
id: {type: string, maxLength: 64}
|
||||
name: {type: string, maxLength: 100}
|
||||
url: {type: string, maxLength: 2048}
|
||||
ServerSources:
|
||||
type: object
|
||||
required: [sources]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
maxItems: 20
|
||||
items: {$ref: "#/components/schemas/ServerSource"}
|
||||
ServerJob:
|
||||
type: object
|
||||
required: [id, kind, state, started_at, finished_at, target, log]
|
||||
properties:
|
||||
id: {type: string}
|
||||
kind: {type: string, enum: [update, config]}
|
||||
state: {type: string, enum: [running, succeeded, failed]}
|
||||
started_at: {type: string}
|
||||
finished_at: {type: string}
|
||||
target: {type: string}
|
||||
log: {type: string}
|
||||
ServerUpdates:
|
||||
type: object
|
||||
required: [sources, versions, current_version, job, source_error]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
items: {$ref: "#/components/schemas/ServerSource"}
|
||||
versions:
|
||||
type: array
|
||||
items: {type: string}
|
||||
current_version: {type: string}
|
||||
source_error: {type: string}
|
||||
job:
|
||||
allOf: [{$ref: "#/components/schemas/ServerJob"}]
|
||||
nullable: true
|
||||
ServerUpdateRequest:
|
||||
type: object
|
||||
required: [source_id, channel]
|
||||
properties:
|
||||
source_id: {type: string}
|
||||
channel: {type: string, enum: [latest, version, main]}
|
||||
version: {type: string}
|
||||
ServerConfigEntry:
|
||||
type: object
|
||||
required: [key, group, type, secret, readonly, effective, env, local, default_value, source]
|
||||
properties:
|
||||
key: {type: string}
|
||||
group: {type: string}
|
||||
type: {type: string}
|
||||
secret: {type: boolean}
|
||||
readonly: {type: boolean}
|
||||
effective: {type: string}
|
||||
env: {type: string, nullable: true}
|
||||
local: {type: string, nullable: true}
|
||||
default_value: {type: string}
|
||||
source: {type: string, enum: [env, local, default]}
|
||||
ServerConfig:
|
||||
type: object
|
||||
required: [entries, revision, restart_required]
|
||||
properties:
|
||||
revision: {type: string}
|
||||
restart_required: {type: boolean}
|
||||
entries:
|
||||
type: array
|
||||
items: {$ref: "#/components/schemas/ServerConfigEntry"}
|
||||
ServerConfigPatch:
|
||||
type: object
|
||||
required: [revision, values]
|
||||
properties:
|
||||
revision: {type: string}
|
||||
values:
|
||||
type: object
|
||||
additionalProperties: {type: string, nullable: true}
|
||||
PlaylistShare:
|
||||
type: object
|
||||
required: [share_id]
|
||||
@@ -2495,6 +2672,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
@@ -2550,6 +2730,9 @@ components:
|
||||
nullable: true
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
|
||||
@@ -2590,6 +2773,9 @@ components:
|
||||
maxLength: 64
|
||||
role:
|
||||
$ref: "#/components/schemas/Role"
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Сразу выдать аудит загрузок. Может задать только админ
|
||||
@@ -2616,6 +2802,9 @@ components:
|
||||
$ref: "#/components/schemas/Role"
|
||||
is_blocked:
|
||||
type: boolean
|
||||
manages_updates:
|
||||
type: boolean
|
||||
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
|
||||
reviews_uploads:
|
||||
type: boolean
|
||||
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username = 'mobile-admin', target = '/') {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||
await page.getByLabel('Логин').fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/updates');
|
||||
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Добавить источник' }).click();
|
||||
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
|
||||
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
|
||||
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
|
||||
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
|
||||
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toContainText('Тестовая репа');
|
||||
await expect(dialog).toContainText('v1.10.0');
|
||||
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
|
||||
await expect(page.getByRole('status').filter({ hasText: 'Выполняется' })).toBeVisible();
|
||||
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled();
|
||||
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/config');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
|
||||
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
|
||||
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
|
||||
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
|
||||
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
|
||||
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
|
||||
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
|
||||
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
|
||||
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||
});
|
||||
|
||||
test('update delegate can update but cannot open configuration', async ({ page }) => {
|
||||
await login(page, 'update-manager', '/admin/updates');
|
||||
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('ordinary user is redirected away from server management', async ({ page }) => {
|
||||
await login(page, 'alice', '/admin/updates');
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
|
||||
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
|
||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||
@@ -274,6 +276,14 @@ const adminSettingsRoute = createRoute({
|
||||
component: AdminSettingsPage,
|
||||
});
|
||||
|
||||
const serverUpdatesRoute = createRoute({
|
||||
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
|
||||
},
|
||||
});
|
||||
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
@@ -304,6 +314,8 @@ const routeTree = rootRoute.addChildren([
|
||||
myUploadsRoute,
|
||||
uploadQueueRoute,
|
||||
adminSettingsRoute,
|
||||
serverUpdatesRoute,
|
||||
serverConfigRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<Record<string, string | null>>({});
|
||||
const [baseRevision, setBaseRevision] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [group, setGroup] = useState('all');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
|
||||
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
|
||||
const running = status.data?.job?.state === 'running';
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
||||
window.addEventListener('beforeunload', prevent);
|
||||
return () => window.removeEventListener('beforeunload', prevent);
|
||||
}, [dirty]);
|
||||
useEffect(() => {
|
||||
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
|
||||
}, [status.data?.job?.id, status.data?.job?.state, qc]);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
|
||||
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
|
||||
void qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
},
|
||||
});
|
||||
const change = (key: string, value: string | null) => {
|
||||
setBaseRevision((v) => v ?? query.data!.revision);
|
||||
setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset();
|
||||
};
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
|
||||
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
|
||||
<p className={s.note}>{t('management.config_files_hint')}</p>
|
||||
<div className={s.configList}>{entries.map((entry) => {
|
||||
const changed = Object.hasOwn(draft, entry.key);
|
||||
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
|
||||
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
|
||||
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
|
||||
<div className={s.values}><span>{t('management.effective')}: <code>{entry.effective || '—'}</code></span><span>ENV: <code>{entry.env ?? '—'}</code></span></div>
|
||||
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
|
||||
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} value={value} disabled={running || save.isPending} placeholder={entry.secret && entry.local ? t('management.secret_saved') : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
||||
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
|
||||
</div>}
|
||||
</section>;
|
||||
})}</div>
|
||||
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
|
||||
<section className={s.saveBar}>
|
||||
<div role="status">{saved ? t('management.saved') : dirty ? t('management.unsaved', { count: Object.keys(draft).length }) : query.data.restart_required ? t('management.pending_restart') : t('management.no_changes')}</div>
|
||||
<div className={s.actions}><Button disabled={!dirty || save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={!dirty || running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button><Button disabled={dirty || running || !query.data.restart_required} busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button></div>
|
||||
</section>
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
|
||||
<JobPanel job={status.data?.job} reconnecting={status.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import s from './management.module.css';
|
||||
|
||||
export type Job = components['schemas']['ServerJob'];
|
||||
|
||||
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
|
||||
const { t } = useTranslation('admin');
|
||||
if (!job) return null;
|
||||
return <section className={s.card} aria-label={t('management.job')}>
|
||||
<div className={s.cardHead}>
|
||||
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
|
||||
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
|
||||
</div>
|
||||
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
|
||||
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
|
||||
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
|
||||
<pre className={s.log} tabIndex={0} aria-label={t('management.log')}>{job.log || t('management.waiting')}</pre>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
type Source = components['schemas']['ServerSource'];
|
||||
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
|
||||
|
||||
export function UpdatesPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const qc = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [channel, setChannel] = useState<Channel>('latest');
|
||||
const [version, setVersion] = useState('');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editing, setEditing] = useState<Source[] | null>(null);
|
||||
const query = useQuery({
|
||||
queryKey: ['server-updates', sourceId],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', {
|
||||
params: { query: { source_id: sourceId || undefined } },
|
||||
})),
|
||||
refetchInterval: 3000,
|
||||
retry: false,
|
||||
});
|
||||
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
|
||||
const running = query.data?.job?.state === 'running';
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
const save = useMutation({
|
||||
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
|
||||
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
|
||||
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined },
|
||||
})),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', sourceId], (old) => old ? { ...old, job } : old);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
|
||||
<Button onClick={() => void query.refetch()} busy={query.isFetching}>{t('management.refresh')}</Button>
|
||||
</header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
|
||||
<section className={s.card}>
|
||||
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
|
||||
<label className={s.selectLabel}>{t('management.source')}
|
||||
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
|
||||
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className={s.url}>{source?.url}</p>
|
||||
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
|
||||
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
|
||||
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
|
||||
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button variant="primary" disabled={!source || running || !!query.data.source_error || (channel === 'version' && !version)} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button></div>
|
||||
</section>
|
||||
<JobPanel job={query.data.job} reconnecting={query.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
|
||||
<p>{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}</p>
|
||||
<p className={s.note}>{t('management.update_warning')}</p>
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
|
||||
</Dialog>}
|
||||
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
|
||||
<p className={s.note}>{t('management.sources_hint')}</p>
|
||||
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
|
||||
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
|
||||
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
|
||||
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
|
||||
</div>)}</div>
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
|
||||
</Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
|
||||
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
|
||||
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
|
||||
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
|
||||
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
|
||||
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
|
||||
.hero strong.priority { font-size: 19px; }
|
||||
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
|
||||
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
|
||||
.card h2 { margin: 0; font-size: 18px; }
|
||||
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
|
||||
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
|
||||
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
|
||||
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
|
||||
.status[data-state='succeeded'] { color: var(--ok); }
|
||||
.status[data-state='failed'] { color: var(--danger); }
|
||||
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
|
||||
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
|
||||
.configList { display: flex; flex-direction: column; gap: 12px; }
|
||||
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
|
||||
.configRow[data-changed] { border-color: var(--accent); }
|
||||
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
|
||||
.values code { overflow-wrap: anywhere; }
|
||||
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
|
||||
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
|
||||
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
|
||||
@@ -140,6 +140,11 @@ export function UsersPage() {
|
||||
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
|
||||
});
|
||||
if (me.role === 'admin' && u.role !== 'admin')
|
||||
items.push({
|
||||
label: t(u.manages_updates ? 'users.revoke_updates' : 'users.grant_updates'),
|
||||
onSelect: () => patch.mutate({ id: u.id, body: { manages_updates: !u.manages_updates } }),
|
||||
});
|
||||
if (can('delete'))
|
||||
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
|
||||
return items;
|
||||
@@ -214,6 +219,7 @@ export function UsersPage() {
|
||||
</td>
|
||||
<td data-label={t('users.col_role')}>
|
||||
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
|
||||
{u.manages_updates && <span className={cx(s.badge, s.role_moderator)}>{t('users.manage_updates')}</span>}
|
||||
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
|
||||
</td>
|
||||
<td data-label={t('users.col_status')}>
|
||||
|
||||
@@ -6,6 +6,7 @@ export function installE2EFetch() {
|
||||
const api = fakeApi([
|
||||
makeUser('admin', 'admin', { must_change_credentials: true }),
|
||||
makeUser('alice', 'user'),
|
||||
makeUser('update-manager', 'user', { manages_updates: true }),
|
||||
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
|
||||
makeUser('moderator', 'moderator', { reviews_uploads: true }),
|
||||
]);
|
||||
|
||||
@@ -79,12 +79,27 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const playlistOwners = new Map<string, string>();
|
||||
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||
|
||||
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
|
||||
let managementJob: components['schemas']['ServerJob'] | null = null;
|
||||
let configRevision = 1;
|
||||
let configPending = false;
|
||||
const configValues: Record<string, string> = {};
|
||||
const configEntries: components['schemas']['ServerConfigEntry'][] = [
|
||||
{ key: 'LOG_LEVEL', group: 'shared', type: 'string', secret: false, readonly: false, effective: 'info', env: 'info', local: null, default_value: 'info', source: 'env' },
|
||||
{ key: 'TRAIN_HOUR', group: 'recommender', type: 'integer', secret: false, readonly: false, effective: '3', env: null, local: null, default_value: '3', source: 'default' },
|
||||
{ key: 'GITEA_TOKEN', group: 'api', type: 'string', secret: true, readonly: false, effective: '••••••••', env: null, local: '••••••••', default_value: '', source: 'local' },
|
||||
];
|
||||
const serverConfig = () => ({ revision: String(configRevision), restart_required: configPending, entries: configEntries.map((entry) => {
|
||||
const local = configValues[entry.key] ?? entry.local;
|
||||
return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' };
|
||||
}) });
|
||||
const me = (u: FakeUser): Me => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
display_name: u.display_name,
|
||||
role: u.role,
|
||||
reviews_uploads: u.reviews_uploads,
|
||||
manages_updates: u.role === 'admin' || !!u.manages_updates,
|
||||
locale: u.locale,
|
||||
ui_prefs: prefs.get(u.id) ?? {},
|
||||
must_change_password: u.must_change_password,
|
||||
@@ -107,6 +122,25 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
calls.push({ method: req.method, path, body });
|
||||
const route = `${req.method} ${path}`;
|
||||
const user = current ? users.get(current) : undefined;
|
||||
if (path.startsWith('/api/v1/admin/server/')) {
|
||||
if (!user) return problem(401, 'auth.unauthorized');
|
||||
const configs = path.includes('/config');
|
||||
if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden');
|
||||
if (route === 'GET /api/v1/admin/server/updates') return json({ sources: managementSources, versions: ['v1.10.0', 'v1.9.0', 'v2.0.0-rc.1'], current_version: '1.9.0', source_error: '', job: managementJob });
|
||||
if (route === 'PUT /api/v1/admin/server/update-sources') { managementSources = body?.sources as typeof managementSources; return json({ sources: managementSources }); }
|
||||
if (route === 'GET /api/v1/admin/server/config') return json(serverConfig());
|
||||
if (route === 'PATCH /api/v1/admin/server/config') {
|
||||
if (body?.revision !== String(configRevision)) return problem(409, 'management.config_conflict');
|
||||
for (const [key, value] of Object.entries(body?.values as Record<string, string | null>)) {
|
||||
if (value === null) delete configValues[key]; else configValues[key] = value;
|
||||
}
|
||||
configRevision++; configPending = true; return json(serverConfig());
|
||||
}
|
||||
if (route === 'POST /api/v1/admin/server/updates' || route === 'POST /api/v1/admin/server/config/apply') {
|
||||
managementJob = { id: 'job-1', kind: configs ? 'config' : 'update', state: 'running', target: configs ? 'config.local' : body?.channel === 'version' ? String(body.version) : body?.channel === 'main' ? 'main' : 'v1.10.0', started_at: '2026-10-22T09:00:00Z', finished_at: '', log: '→ build and restart' };
|
||||
return json(managementJob, 202);
|
||||
}
|
||||
}
|
||||
|
||||
switch (route) {
|
||||
case 'GET /api/v1/server-info':
|
||||
@@ -323,6 +357,10 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
|
||||
target.username = username;
|
||||
}
|
||||
if (typeof body?.manages_updates === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.manages_updates = body.manages_updates;
|
||||
}
|
||||
if (typeof body?.reviews_uploads === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.reviews_uploads = body.reviews_uploads;
|
||||
|
||||
@@ -48,6 +48,8 @@ type NavPath =
|
||||
| '/admin/uploads'
|
||||
| '/uploads/new'
|
||||
| '/uploads/mine'
|
||||
| '/admin/updates'
|
||||
| '/admin/config'
|
||||
| '/admin/settings'
|
||||
| '/collection/playlists'
|
||||
| '/collection/downloads'
|
||||
@@ -197,6 +199,7 @@ function Shell() {
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||
{staff && (
|
||||
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
|
||||
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
|
||||
@@ -215,8 +218,10 @@ function Shell() {
|
||||
badge={pending}
|
||||
/>
|
||||
)}
|
||||
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
|
||||
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
# Share a lock with manual deployments and config application.
|
||||
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
|
||||
exec 9<.management.lock
|
||||
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
|
||||
touch config.local
|
||||
compose() { docker compose --env-file config.local --env-file .env "$@"; }
|
||||
# Validate before stopping any container.
|
||||
compose config --quiet
|
||||
compose up -d --force-recreate --remove-orphans
|
||||
echo '→ ждём готовности api'
|
||||
for _ in $(seq 1 90); do
|
||||
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
echo 'Конфигурация применена; API готов'
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo 'API не стал готов: проверьте docker compose logs api' >&2
|
||||
exit 1
|
||||
+12
-1
@@ -55,7 +55,7 @@ services:
|
||||
dockerfile: services/api/Dockerfile
|
||||
target: api
|
||||
args: *version
|
||||
env_file: .env
|
||||
env_file: [config.local, .env]
|
||||
environment: &db-env
|
||||
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
|
||||
volumes:
|
||||
@@ -63,6 +63,7 @@ services:
|
||||
- *library-host-ro
|
||||
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
|
||||
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
|
||||
- ${PIPU_DATA:-/srv/pipu}/management:/management
|
||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -112,6 +113,11 @@ services:
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
|
||||
TRAIN_HOUR: ${TRAIN_HOUR:-3}
|
||||
LIBRARY_ROOT: /library
|
||||
LIBRARY_HOST_ROOT: /library-host
|
||||
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
@@ -124,6 +130,11 @@ services:
|
||||
<<: *db-env
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
|
||||
TRAIN_HOUR: ${TRAIN_HOUR:-3}
|
||||
LIBRARY_ROOT: /library
|
||||
LIBRARY_HOST_ROOT: /library-host
|
||||
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
|
||||
volumes:
|
||||
- *library-ro
|
||||
- *library-host-ro
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
[
|
||||
{
|
||||
"key": "MANAGEMENT_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/management",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "HTTP_ADDR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": ":8080",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "DATABASE_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "Compose: POSTGRES_PASSWORD → postgres:5432/pipu",
|
||||
"secret": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "REDIS_URL",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "LOG_LEVEL",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "info",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MIGRATE_ON_START",
|
||||
"group": "api",
|
||||
"type": "boolean",
|
||||
"default_value": "true",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "SHUTDOWN_TIMEOUT",
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "15s",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "SUPPORTED_LOCALES",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "ru,en",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MIN_ANDROID_VERSION_CODE",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "0",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "JWT_PRIVATE_KEY",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "JWT_PREVIOUS_KEYS",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "ACCESS_TOKEN_TTL",
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "15m",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "COOKIE_SECURE",
|
||||
"group": "api",
|
||||
"type": "boolean",
|
||||
"default_value": "true",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "BOOTSTRAP_ADMIN_USERNAME",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "admin",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "BOOTSTRAP_ADMIN_PASSWORD",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_HOST_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library-host",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "STORAGE_AGENT_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "http://storage-agent:8090",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "INTERNAL_TOKEN",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "FFPROBE_PATH",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "ffprobe",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "FPCALC_PATH",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "fpcalc",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "STREAM_SIGNING_KEY",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "CACHE_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/cache",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "FFMPEG_PATH",
|
||||
"group": "shared",
|
||||
"type": "string",
|
||||
"default_value": "ffmpeg",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "X_ACCEL",
|
||||
"group": "api",
|
||||
"type": "boolean",
|
||||
"default_value": "true",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "TRANSCODE_CONCURRENCY",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "0",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "TRANSCODE_CACHE_LIMIT_GB",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "50",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "RECOMMENDER_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "http://recommender:8000",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "GENIUS_ACCESS_TOKEN",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GENIUS_REQUEST_INTERVAL",
|
||||
"group": "api",
|
||||
"type": "duration",
|
||||
"default_value": "2s",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MEILI_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "http://meilisearch:7700",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "MEILI_MASTER_KEY",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GITEA_URL",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GITEA_REPO",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "GITEA_TOKEN",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "UPDATES_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/updates",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "UPLOADS_DIR",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/uploads",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_RW_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library-rw",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_HOST_RW_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/library-host-rw",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "UPLOADS_ROOT",
|
||||
"group": "api",
|
||||
"type": "string",
|
||||
"default_value": "/uploads",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "PIPU_FILE_UID",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "-1",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "PIPU_FILE_GID",
|
||||
"group": "api",
|
||||
"type": "integer",
|
||||
"default_value": "-1",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "PIPU_VERSION",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "dev",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "PIPU_DATA",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "/srv/pipu",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "LIBRARY_HOST",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "COMPOSE_FILE",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "compose.yaml:compose.tls.yaml",
|
||||
"secret": false,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "DOMAIN",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "music.example.com",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "ACME_EMAIL",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "admin@example.com",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "HTTP_PORT",
|
||||
"group": "deployment",
|
||||
"type": "integer",
|
||||
"default_value": "80",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "POSTGRES_PASSWORD",
|
||||
"group": "deployment",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": true,
|
||||
"readonly": true
|
||||
},
|
||||
{
|
||||
"key": "CONTEXTS_PATH",
|
||||
"group": "recommender",
|
||||
"type": "string",
|
||||
"default_value": "",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"key": "TRAIN_HOUR",
|
||||
"group": "recommender",
|
||||
"type": "integer",
|
||||
"default_value": "3",
|
||||
"secret": false,
|
||||
"readonly": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Called explicitly by install.sh. Also usable once on existing installations.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
DEPLOY="$(pwd -P)"
|
||||
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
|
||||
DATA="${DATA:-/srv/pipu}"
|
||||
command -v python3 >/dev/null || { echo 'Install python3 (>=3.10) first' >&2; exit 1; }
|
||||
python3 -c 'import sys; assert sys.version_info >= (3, 10), "Python >=3.10 required"'
|
||||
[ -d /etc/systemd/system ] || { echo 'systemd is required for server management' >&2; exit 1; }
|
||||
mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu
|
||||
chown 65532:65532 "$DATA/management"
|
||||
chmod 700 "$DATA/management" /var/lib/pipu-management
|
||||
# Copy outside the checkout: switching revisions must not replace a running worker.
|
||||
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py
|
||||
touch config.local
|
||||
chmod 600 config.local
|
||||
if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
|
||||
# systemd quoted arguments escape backslashes, quotes and specifiers.
|
||||
quote_unit() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g'; }
|
||||
DEPLOY_UNIT="$(quote_unit "$DEPLOY")"
|
||||
MAILBOX_UNIT="$(quote_unit "$DATA/management")"
|
||||
cat > /etc/systemd/system/pipu-management.service <<EOF
|
||||
[Unit]
|
||||
Description=Pipu Music server management agent
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/local/lib/pipu/server-agent.py --deploy-dir "$DEPLOY_UNIT" --mailbox "$MAILBOX_UNIT" --state-dir /var/lib/pipu-management
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
UMask=0077
|
||||
KillMode=control-group
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable pipu-management.service
|
||||
systemctl restart pipu-management.service
|
||||
echo '→ управление сервером: pipu-management.service'
|
||||
@@ -118,4 +118,6 @@ if [ -n "$LIBRARY_HOST" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
bash ./install-management.sh
|
||||
|
||||
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
||||
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Host-side management worker. No network listener or Docker socket in the API.
|
||||
|
||||
Only fixed operations cross the shared mailbox. Private state stays outside it.
|
||||
Run with Python >= 3.10; installed by install-management.sh.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class ManagementError(Exception):
|
||||
def __init__(self, code, status=400):
|
||||
super().__init__(code)
|
||||
self.code, self.status = code, status
|
||||
|
||||
|
||||
def atomic(path, value, mode=0o600, preserve_owner=False):
|
||||
fd, name = tempfile.mkstemp(dir=path.parent, prefix=".management-")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as out:
|
||||
os.chmod(name, mode)
|
||||
if preserve_owner and path.exists() and hasattr(os, "chown"):
|
||||
previous = path.stat()
|
||||
os.chown(name, previous.st_uid, previous.st_gid)
|
||||
out.write(value)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(name, path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def read_env(path):
|
||||
"""Read literal dotenv values, including the quoted format we write.
|
||||
|
||||
Compose performs interpolation; this reader never executes dotenv contents.
|
||||
"""
|
||||
values = {}
|
||||
if not path.exists():
|
||||
return values
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
match = re.match(r"^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$", line.strip())
|
||||
if not match:
|
||||
continue
|
||||
key, value = match.groups()
|
||||
value = value.strip()
|
||||
if value.startswith("'") and value.endswith("'"):
|
||||
value = value[1:-1].replace("\\'", "'")
|
||||
elif value.startswith('"') and value.endswith('"'):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
value = value[1:-1]
|
||||
else:
|
||||
value = value.split(" #", 1)[0].strip()
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def validate_url(url):
|
||||
if not isinstance(url, str) or len(url) > 2048 or re.search(r"[\s\x00-\x1f]", url):
|
||||
raise ManagementError("management.source_invalid")
|
||||
if re.fullmatch(r"git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+", url):
|
||||
return
|
||||
parsed = urlsplit(url)
|
||||
if (parsed.scheme not in ("https", "http", "ssh") or not parsed.hostname
|
||||
or parsed.password or parsed.query or parsed.fragment
|
||||
or (parsed.username and not (parsed.scheme == "ssh" and parsed.username == "git"))
|
||||
or not parsed.path.strip("/")):
|
||||
raise ManagementError("management.source_invalid")
|
||||
|
||||
|
||||
def version_key(tag):
|
||||
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?", tag)
|
||||
if not match:
|
||||
return None
|
||||
major, minor, patch, rc = match.groups()
|
||||
return int(major), int(minor), int(patch), 1 if rc is None else 0, int(rc or 0)
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Agent:
|
||||
def __init__(self, deploy, mailbox, private):
|
||||
self.deploy, self.mailbox, self.private = deploy, mailbox, private
|
||||
self.lock = threading.Lock()
|
||||
self.cache = {}
|
||||
self.job = self.load("job.json", None)
|
||||
# systemd KillMode=control-group stops descendants too. Never claim success
|
||||
# after a reboot or interrupted deployment.
|
||||
if self.job and self.job["state"] == "running":
|
||||
self.job.update(state="failed", finished_at=now(), log="Agent interrupted; inspect deployment before retrying.")
|
||||
self.save("job.json", self.job)
|
||||
|
||||
def load(self, name, default):
|
||||
path = self.private / name
|
||||
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
|
||||
|
||||
def save(self, name, value):
|
||||
atomic(self.private / name, json.dumps(value, ensure_ascii=False))
|
||||
|
||||
def git_environment(self):
|
||||
return {"GIT_TERMINAL_PROMPT": "0", "GIT_SSH_COMMAND": "ssh -oBatchMode=yes",
|
||||
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "safe.directory", "GIT_CONFIG_VALUE_0": str(self.deploy.parent)}
|
||||
|
||||
def command(self, args, timeout=30, **kwargs):
|
||||
return subprocess.run(args, cwd=self.deploy, timeout=timeout, check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
env={**os.environ, **self.git_environment()}, **kwargs).stdout
|
||||
|
||||
def sources(self):
|
||||
sources = self.load("sources.json", None)
|
||||
if sources is None:
|
||||
url = self.command(["git", "remote", "get-url", "origin"]).strip()
|
||||
validate_url(url)
|
||||
sources = [{"id": "origin", "name": "origin", "url": url}]
|
||||
self.save("sources.json", sources)
|
||||
return sources
|
||||
|
||||
def refs(self, url, fresh=False):
|
||||
cached = self.cache.get(url)
|
||||
if cached and not fresh and time.monotonic() - cached[0] < 60:
|
||||
return cached[1]
|
||||
output = self.command(["git", "-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never",
|
||||
"ls-remote", "--heads", "--tags", "--", url], timeout=20)
|
||||
refs = dict(line.split()[::-1] for line in output.splitlines() if len(line.split()) == 2)
|
||||
self.cache[url] = time.monotonic(), refs
|
||||
return refs
|
||||
|
||||
def schema(self):
|
||||
return json.loads((self.deploy / "config.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
def revision(self):
|
||||
return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest()
|
||||
|
||||
def config(self):
|
||||
env = read_env(self.deploy / ".env")
|
||||
local = read_env(self.deploy / "config.local")
|
||||
entries = []
|
||||
for item in self.schema():
|
||||
key = item["key"]
|
||||
source = "env" if key in env else "local" if key in local else "default"
|
||||
effective = env.get(key, local.get(key, item["default_value"]))
|
||||
def display(value):
|
||||
return "••••••••" if item["secret"] and value else value
|
||||
entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)),
|
||||
"effective": display(effective), "source": source})
|
||||
return {"entries": entries, "revision": self.revision(),
|
||||
"restart_required": self.load("applied.json", "") != self.revision()}
|
||||
|
||||
def save_config(self, body):
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
if body.get("revision") != self.revision():
|
||||
raise ManagementError("management.config_conflict", 409)
|
||||
values = body.get("values")
|
||||
if not isinstance(values, dict) or len(values) > 200:
|
||||
raise ManagementError("management.config_invalid")
|
||||
schema = {v["key"]: v for v in self.schema()}
|
||||
local = read_env(self.deploy / "config.local")
|
||||
for key, value in values.items():
|
||||
item = schema.get(key)
|
||||
if not item or item["readonly"]:
|
||||
raise ManagementError("management.config_readonly")
|
||||
if value is None:
|
||||
local.pop(key, None)
|
||||
continue
|
||||
if not isinstance(value, str) or len(value) > 8192 or any(c in value for c in "\r\n\x00"):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "boolean" and value not in ("true", "false", "1", "0"):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "integer" and not re.fullmatch(r"-?\d+", value):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if key == "LOG_LEVEL" and value not in ("debug", "info", "warn", "error"):
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "integer" and key != "TRAIN_HOUR":
|
||||
minimum = -1 if key in ("PIPU_FILE_UID", "PIPU_FILE_GID") else 0
|
||||
if int(value) < minimum:
|
||||
raise ManagementError("management.config_invalid")
|
||||
if key == "TRAIN_HOUR" and not -1 <= int(value) <= 23:
|
||||
raise ManagementError("management.config_invalid")
|
||||
if key.endswith("_PORT") and not 1 <= int(value) <= 65535:
|
||||
raise ManagementError("management.config_invalid")
|
||||
if item["type"] == "duration" and not re.fullmatch(r"(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+", value):
|
||||
raise ManagementError("management.config_invalid")
|
||||
local[key] = value
|
||||
# Single quotes make dollar signs literal to Compose. Newlines are rejected.
|
||||
content = "# Managed by Pipu. Environment and .env override these values.\n"
|
||||
content += "".join(k + "='" + v.replace("'", "\\'") + "'\n" for k, v in sorted(local.items()))
|
||||
path = self.deploy / "config.local"
|
||||
previous = path.read_text(encoding="utf-8")
|
||||
atomic(path, content, preserve_owner=True)
|
||||
try:
|
||||
self.command([*self.compose(), "config", "--quiet"])
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
atomic(path, previous, preserve_owner=True)
|
||||
raise ManagementError("management.config_invalid") from None
|
||||
self.audit("config.save", list(values))
|
||||
return self.config()
|
||||
|
||||
def compose(self):
|
||||
return ["docker", "compose", "--env-file", "config.local", "--env-file", ".env"]
|
||||
|
||||
def audit(self, action, details):
|
||||
# Deliberately no configuration values or credentials in this log.
|
||||
with (self.private / "audit.jsonl").open("a", encoding="utf-8") as out:
|
||||
out.write(json.dumps({"at": now(), "actor": getattr(self, "actor", ""), "action": action, "details": details}) + "\n")
|
||||
|
||||
def start_job(self, kind, target, command, extra_env=None):
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
job = {"id": str(uuid.uuid4()), "kind": kind, "state": "running", "target": target,
|
||||
"started_at": now(), "finished_at": "", "log": ""}
|
||||
self.job = job
|
||||
self.save("job.json", job)
|
||||
revision = self.revision()
|
||||
self.audit(kind + ".start", {"id": job["id"], "target": target})
|
||||
threading.Thread(target=self.run_job, args=(job, command, extra_env or {}, revision), daemon=True).start()
|
||||
return dict(job)
|
||||
|
||||
def run_job(self, job, command, extra_env, revision):
|
||||
# Let the API deliver the accepted job before recreating its container.
|
||||
time.sleep(2)
|
||||
# Capture full output privately, expose only a bounded redacted tail.
|
||||
secrets = [v for k, v in {**read_env(self.deploy / "config.local"), **read_env(self.deploy / ".env")}.items()
|
||||
if re.search(r"TOKEN|PASSWORD|KEY|SECRET|DATABASE_URL|REDIS_URL", k) and len(v) >= 4]
|
||||
def redact(text):
|
||||
for value in secrets:
|
||||
text = text.replace(value, "[redacted]")
|
||||
return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[redacted]@", text)[-64000:]
|
||||
logpath = self.private / (job["id"] + ".log")
|
||||
result = 1
|
||||
try:
|
||||
with logpath.open("w", encoding="utf-8") as output:
|
||||
process = subprocess.Popen(command, cwd=self.deploy, stdout=output, stderr=subprocess.STDOUT,
|
||||
env={**os.environ, **self.git_environment(), **extra_env}, start_new_session=True)
|
||||
deadline = time.monotonic() + 7200
|
||||
while process.poll() is None:
|
||||
if time.monotonic() > deadline:
|
||||
import signal
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
process.wait()
|
||||
break
|
||||
with self.lock:
|
||||
with logpath.open("rb") as output:
|
||||
output.seek(max(0, logpath.stat().st_size - 128000))
|
||||
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
|
||||
self.save("job.json", job)
|
||||
time.sleep(1)
|
||||
result = process.returncode
|
||||
with logpath.open("rb") as output:
|
||||
output.seek(max(0, logpath.stat().st_size - 128000))
|
||||
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
|
||||
if result == 0:
|
||||
self.save("applied.json", revision)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
result = 1
|
||||
job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management."
|
||||
finally:
|
||||
with self.lock:
|
||||
job.update(state="succeeded" if result == 0 else "failed", finished_at=now())
|
||||
self.save("job.json", job)
|
||||
|
||||
def handle(self, op, body):
|
||||
if op == "config.get":
|
||||
return self.config()
|
||||
if op == "config.save":
|
||||
return self.save_config(body)
|
||||
if op == "config.apply":
|
||||
return self.start_job("config", "config.local", ["bash", str(self.deploy / "apply-config.sh")])
|
||||
if op == "sources.save":
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
sources = body.get("sources")
|
||||
if not isinstance(sources, list) or not 1 <= len(sources) <= 20:
|
||||
raise ManagementError("management.source_invalid")
|
||||
ids = set()
|
||||
for source in sources:
|
||||
if (not isinstance(source, dict) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", source.get("id", ""))
|
||||
or not isinstance(source.get("name"), str) or not 1 <= len(source["name"].strip()) <= 100
|
||||
or source["id"] in ids):
|
||||
raise ManagementError("management.source_invalid")
|
||||
validate_url(source.get("url"))
|
||||
ids.add(source["id"])
|
||||
self.save("sources.json", sources)
|
||||
self.audit("sources.save", list(ids))
|
||||
return {"sources": sources}
|
||||
sources = self.sources()
|
||||
selected = next((s for s in sources if s["id"] == (body.get("source_id") or sources[0]["id"])), None)
|
||||
if not selected:
|
||||
raise ManagementError("management.source_invalid")
|
||||
if op == "updates.get":
|
||||
versions, error = [], ""
|
||||
try:
|
||||
refs = self.refs(selected["url"])
|
||||
versions = sorted([r.removeprefix("refs/tags/") for r in refs if r.startswith("refs/tags/") and version_key(r[10:])], key=version_key, reverse=True)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
error = "management.source_unavailable"
|
||||
return {"sources": sources, "versions": versions, "source_error": error,
|
||||
"current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job}
|
||||
if op == "updates.start":
|
||||
if self.job and self.job["state"] == "running":
|
||||
raise ManagementError("management.busy", 409)
|
||||
channel = body.get("channel")
|
||||
if channel not in ("latest", "version", "main"):
|
||||
raise ManagementError("management.target_invalid")
|
||||
refs = self.refs(selected["url"], fresh=True)
|
||||
if channel == "latest":
|
||||
tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r]
|
||||
if not tags:
|
||||
raise ManagementError("management.no_releases")
|
||||
target = max(tags, key=version_key)
|
||||
else:
|
||||
target = "main" if channel == "main" else body.get("version", "")
|
||||
if target != "main" and not version_key(target):
|
||||
raise ManagementError("management.target_invalid")
|
||||
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
|
||||
commit = refs.get(ref + "^{}", refs.get(ref))
|
||||
if not commit or not re.fullmatch(r"[a-f0-9]{40,64}", commit):
|
||||
raise ManagementError("management.target_invalid")
|
||||
return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target],
|
||||
{"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit,
|
||||
"PIPU_UPDATE_REF": ref})
|
||||
raise ManagementError("management.operation_invalid")
|
||||
|
||||
def tick(self):
|
||||
atomic(self.mailbox / "heartbeat", str(time.time()), 0o644)
|
||||
for path in sorted(self.mailbox.glob("*.request.json")):
|
||||
if path.is_symlink() or not re.fullmatch(r"[a-f0-9-]{36}\.request\.json", path.name):
|
||||
continue
|
||||
response = self.mailbox / path.name.replace(".request.json", ".response.json")
|
||||
if response.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
continue
|
||||
try:
|
||||
if path.stat().st_size > 131072:
|
||||
raise ManagementError("management.request_invalid")
|
||||
request = json.loads(path.read_text(encoding="utf-8"))
|
||||
if time.time() - request["created_at"] > 45:
|
||||
raise ManagementError("management.request_expired", 409)
|
||||
# Claim before executing: a worker crash must never replay a mutation.
|
||||
path.unlink(missing_ok=True)
|
||||
with self.lock:
|
||||
self.actor = request.get("actor", "")
|
||||
result = {"data": self.handle(request["operation"], request.get("body") or {})}
|
||||
except ManagementError as error:
|
||||
result = {"code": error.code, "status": error.status}
|
||||
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
|
||||
result = {"code": "management.agent_error", "status": 502}
|
||||
atomic(response, json.dumps(result, ensure_ascii=False), 0o644)
|
||||
path.unlink(missing_ok=True)
|
||||
for path in self.mailbox.glob("*.response.json"):
|
||||
if not path.is_symlink() and time.time() - path.stat().st_mtime > 120:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main():
|
||||
import fcntl # Linux host only; tests import this module on Windows too.
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--deploy-dir", type=Path, required=True)
|
||||
parser.add_argument("--mailbox", type=Path, required=True)
|
||||
parser.add_argument("--state-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
args.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
with (args.state_dir / "lock").open("w") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
agent = Agent(args.deploy_dir.resolve(), args.mailbox.resolve(), args.state_dir.resolve())
|
||||
while True:
|
||||
# Refresh the installed worker only after a successful deployment has
|
||||
# persisted its final status. The running worker survives checkout/build.
|
||||
with agent.lock:
|
||||
installed = Path(__file__).resolve()
|
||||
candidate = agent.deploy / "server-agent.py"
|
||||
if (agent.job and agent.job["kind"] == "update" and agent.job["state"] == "succeeded"
|
||||
and installed == Path("/usr/local/lib/pipu/server-agent.py") and candidate.is_file()
|
||||
and candidate.read_bytes() != installed.read_bytes()):
|
||||
atomic(installed, candidate.read_text(encoding="utf-8"), 0o700)
|
||||
# The singleton lock is non-inheritable and released by exec.
|
||||
import sys
|
||||
os.execv(sys.executable, [sys.executable, str(installed), *sys.argv[1:]])
|
||||
agent.tick()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Host worker tests: python3 -m unittest discover -s deploy -p 'test_*.py'."""
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("server_agent", Path(__file__).with_name("server-agent.py"))
|
||||
agent_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(agent_module)
|
||||
Agent, ManagementError = agent_module.Agent, agent_module.ManagementError
|
||||
|
||||
|
||||
class AgentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.deploy, self.mailbox, self.private = [self.root / n for n in ("deploy", "mailbox", "private")]
|
||||
for directory in (self.deploy, self.mailbox, self.private):
|
||||
directory.mkdir()
|
||||
self.schema = [
|
||||
{"key": "LOG_LEVEL", "group": "api", "type": "string", "default_value": "info", "secret": False, "readonly": False},
|
||||
{"key": "GITEA_TOKEN", "group": "api", "type": "string", "default_value": "", "secret": True, "readonly": False},
|
||||
{"key": "TRAIN_HOUR", "group": "recommender", "type": "integer", "default_value": "3", "secret": False, "readonly": False},
|
||||
{"key": "PIPU_DATA", "group": "deployment", "type": "string", "default_value": "/srv/pipu", "secret": False, "readonly": True},
|
||||
]
|
||||
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
|
||||
(self.deploy / "config.local").write_text("", encoding="utf-8")
|
||||
(self.deploy / ".env").write_text("LOG_LEVEL=warn\nGITEA_TOKEN=top-secret\n", encoding="utf-8")
|
||||
self.agent = Agent(self.deploy, self.mailbox, self.private)
|
||||
self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
|
||||
|
||||
def test_env_priority_and_secrets_never_returned(self):
|
||||
(self.deploy / "config.local").write_text("LOG_LEVEL=debug\nTRAIN_HOUR=5\n", encoding="utf-8")
|
||||
config = self.agent.config()
|
||||
entries = {v["key"]: v for v in config["entries"]}
|
||||
self.assertEqual(("warn", "env", "debug"), tuple(entries["LOG_LEVEL"][k] for k in ("effective", "source", "local")))
|
||||
self.assertEqual(("5", "local"), tuple(entries["TRAIN_HOUR"][k] for k in ("effective", "source")))
|
||||
self.assertNotIn("top-secret", json.dumps(config))
|
||||
|
||||
def test_save_roundtrip_reset_and_conflict(self):
|
||||
with patch.object(self.agent, "command", return_value=""):
|
||||
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "7", "GITEA_TOKEN": "a$'\\b#c"}})
|
||||
self.assertEqual("a$'\\b#c", agent_module.read_env(self.deploy / "config.local")["GITEA_TOKEN"])
|
||||
self.assertTrue(result["restart_required"])
|
||||
self.assertNotIn("a$", json.dumps(result))
|
||||
self.assertNotIn("a$", (self.private / "audit.jsonl").read_text())
|
||||
with self.assertRaises(ManagementError) as raised:
|
||||
self.agent.save_config({"revision": "stale", "values": {"TRAIN_HOUR": "8"}})
|
||||
self.assertEqual(409, raised.exception.status)
|
||||
self.agent.save_config({"revision": result["revision"], "values": {"TRAIN_HOUR": None}})
|
||||
self.assertNotIn("TRAIN_HOUR", agent_module.read_env(self.deploy / "config.local"))
|
||||
|
||||
def test_invalid_values_and_compose_failure_preserve_original(self):
|
||||
original = (self.deploy / "config.local").read_bytes()
|
||||
for values in ({"TRAIN_HOUR": "24"}, {"TRAIN_HOUR": "bad"}, {"UNKNOWN": "x"}, {"PIPU_DATA": "/tmp"}, {"LOG_LEVEL": "x\nOTHER=y"}):
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.save_config({"revision": self.agent.revision(), "values": values})
|
||||
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
|
||||
with patch.object(self.agent, "command", side_effect=subprocess.CalledProcessError(1, "docker")):
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
|
||||
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
|
||||
|
||||
def test_source_validation_rejects_shell_local_and_credentials(self):
|
||||
for url in ("/tmp/repo", "file:///tmp/repo", "ext::sh -c evil", "--upload-pack=evil", "https://user:secret@example.org/o/r", "https://secret@example.org/o/r", "https://example.org/o/r\narg", "ssh://root@example.org/o/r"):
|
||||
with self.subTest(url=url), self.assertRaises(ManagementError):
|
||||
agent_module.validate_url(url)
|
||||
for url in ("https://example.org/o/r", "http://10.0.0.1:3000/o/r", "git@example.org:o/r.git", "ssh://git@example.org:2222/o/r.git"):
|
||||
agent_module.validate_url(url)
|
||||
|
||||
def test_latest_is_semver_stable_and_pins_peeled_commit(self):
|
||||
refs = {"refs/tags/v1.9.0": "a" * 40, "refs/tags/v1.10.0": "b" * 40, "refs/tags/v1.10.0^{}": "c" * 40, "refs/tags/v2.0.0-rc.1": "d" * 40}
|
||||
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job", return_value={}) as start:
|
||||
self.agent.handle("updates.start", {"source_id": "origin", "channel": "latest"})
|
||||
self.assertEqual("v1.10.0", start.call_args.args[1])
|
||||
self.assertEqual("c" * 40, start.call_args.args[3]["PIPU_UPDATE_COMMIT"])
|
||||
self.assertEqual("refs/tags/v1.10.0", start.call_args.args[3]["PIPU_UPDATE_REF"])
|
||||
with self.assertRaises(ManagementError):
|
||||
self.agent.handle("updates.start", {"source_id": "origin", "channel": "version", "version": "--evil"})
|
||||
|
||||
def test_main_and_default_source(self):
|
||||
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job", return_value={}) as start:
|
||||
self.agent.handle("updates.start", {"source_id": "origin", "channel": "main"})
|
||||
self.assertEqual("main", start.call_args.args[1])
|
||||
result = self.agent.handle("updates.get", {"source_id": None})
|
||||
self.assertEqual("origin", result["sources"][0]["id"])
|
||||
|
||||
def test_running_job_blocks_mutations_and_survives_restart_as_failure(self):
|
||||
self.agent.job = {"state": "running"}
|
||||
for operation, body in (("updates.start", {"channel": "main"}), ("config.apply", {}), ("sources.save", {}), ("config.save", {})):
|
||||
with self.subTest(operation=operation), self.assertRaises(ManagementError) as raised:
|
||||
self.agent.handle(operation, body)
|
||||
self.assertEqual(409, raised.exception.status)
|
||||
self.agent.save("job.json", {"state": "running", "id": "old"})
|
||||
restored = Agent(self.deploy, self.mailbox, self.private)
|
||||
self.assertEqual("failed", restored.job["state"])
|
||||
|
||||
def test_completed_job_redacts_secrets_and_marks_applied_revision(self):
|
||||
job = {"id": "test", "kind": "config", "state": "running", "started_at": "", "finished_at": "", "target": "config.local", "log": ""}
|
||||
self.agent.job = job
|
||||
revision = self.agent.revision()
|
||||
self.agent.run_job(job, [sys.executable, "-c", "print('top-secret')"], {}, revision)
|
||||
self.assertEqual("succeeded", job["state"])
|
||||
self.assertNotIn("top-secret", job["log"])
|
||||
self.assertIn("[redacted]", job["log"])
|
||||
self.assertFalse(self.agent.config()["restart_required"])
|
||||
self.agent.run_job(job, [sys.executable, "-c", "raise SystemExit(2)"], {}, revision)
|
||||
self.assertEqual("failed", job["state"])
|
||||
|
||||
def test_mailbox_discards_stale_request_and_never_executes_it(self):
|
||||
path = self.mailbox / "00000000-0000-0000-0000-000000000001.request.json"
|
||||
path.write_text(json.dumps({"operation": "config.apply", "created_at": 1}), encoding="utf-8")
|
||||
with patch.object(self.agent, "handle") as handle:
|
||||
self.agent.tick()
|
||||
handle.assert_not_called()
|
||||
response = json.loads(path.with_name(path.name.replace("request", "response")).read_text(encoding="utf-8"))
|
||||
self.assertEqual("management.request_expired", response["code"])
|
||||
|
||||
|
||||
class ComposeTests(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('docker'), 'Docker Compose CLI unavailable')
|
||||
def test_real_compose_priorities_and_literal_secret_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / '.env').write_text('POSTGRES_PASSWORD=test-db\nINTERNAL_TOKEN=test-internal\nMEILI_MASTER_KEY=test-meili\nLOG_LEVEL=warn\n', encoding='utf-8')
|
||||
secret = "literal$VALUE'with\\slash#hash"
|
||||
(root / 'config.local').write_text("LOG_LEVEL='debug'\nTRAIN_HOUR='7'\nGITEA_TOKEN='" + secret.replace("'", "\\'") + "'\n", encoding='utf-8')
|
||||
compose = Path(__file__).with_name('compose.yaml').resolve()
|
||||
result = subprocess.run(['docker', 'compose', '--project-directory', str(root), '-f', str(compose), '--env-file', str(root / 'config.local'), '--env-file', str(root / '.env'), 'config', '--format', 'json'], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(0, result.returncode, 'Docker Compose configuration failed: ' + result.stderr)
|
||||
services = json.loads(result.stdout)['services']
|
||||
self.assertEqual('warn', services['api']['environment']['LOG_LEVEL'])
|
||||
self.assertEqual('7', services['recommender']['environment']['TRAIN_HOUR'])
|
||||
self.assertEqual('7', services['analyzer']['environment']['TRAIN_HOUR'])
|
||||
# Canonical Compose output escapes dollars for reuse as YAML.
|
||||
self.assertEqual(secret.replace('$', '$$'), services['api']['environment']['GITEA_TOKEN'])
|
||||
self.assertNotIn('GITEA_TOKEN', services['recommender']['environment'])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+35
-4
@@ -14,6 +14,15 @@ fi
|
||||
trap 'rm -f "$0"' EXIT
|
||||
cd "$PIPU_DEPLOY_DIR"
|
||||
|
||||
# Share a lock with manual deployments and config application.
|
||||
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
|
||||
exec 9<.management.lock
|
||||
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
|
||||
|
||||
touch config.local
|
||||
chmod 600 config.local
|
||||
compose() { docker compose --env-file config.local --env-file .env "$@"; }
|
||||
|
||||
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
|
||||
|
||||
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
|
||||
@@ -69,7 +78,15 @@ if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [
|
||||
fi
|
||||
|
||||
REF="${1:-}"
|
||||
if [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
|
||||
[[ "${PIPU_UPDATE_COMMIT:-}" =~ ^[a-f0-9]{40,64}$ ]] || { echo 'Invalid commit' >&2; exit 1; }
|
||||
[[ "${PIPU_UPDATE_REF:-}" =~ ^refs/(heads/main|tags/v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] || { echo 'Invalid ref' >&2; exit 1; }
|
||||
git -c protocol.file.allow=never -c protocol.ext.allow=never fetch --quiet -- "$PIPU_UPDATE_SOURCE" "$PIPU_UPDATE_REF"
|
||||
FETCHED="$(git rev-parse 'FETCH_HEAD^{commit}')"
|
||||
[ "$FETCHED" = "$PIPU_UPDATE_COMMIT" ] || { echo 'Source changed; select update again' >&2; exit 1; }
|
||||
else
|
||||
git fetch --tags --prune --quiet origin
|
||||
fi
|
||||
|
||||
if [ -z "$REF" ]; then
|
||||
REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)"
|
||||
@@ -96,26 +113,40 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
|
||||
fi
|
||||
|
||||
echo "→ переключаемся на $REF"
|
||||
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
# Refuse to overwrite local source changes.
|
||||
git diff --quiet && git diff --cached --quiet || { echo 'Repository has local changes' >&2; exit 1; }
|
||||
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
|
||||
git checkout --quiet --detach "$PIPU_UPDATE_COMMIT"
|
||||
elif git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||
git checkout --quiet "$REF"
|
||||
git merge --ff-only --quiet "origin/$REF"
|
||||
else
|
||||
git checkout --quiet --detach "$REF"
|
||||
fi
|
||||
|
||||
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
|
||||
if [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi
|
||||
else
|
||||
VERSION="$(git describe --tags --always --dirty)"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
|
||||
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
|
||||
|
||||
docker compose build --pull
|
||||
docker compose up -d --remove-orphans
|
||||
compose config --quiet
|
||||
if ! compose build --pull; then
|
||||
# A build failure leaves running containers untouched; keep their version in .env.
|
||||
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env
|
||||
echo 'Build failed; running containers were not replaced' >&2
|
||||
exit 1
|
||||
fi
|
||||
compose up -d --remove-orphans
|
||||
docker image prune -f >/dev/null
|
||||
|
||||
echo "→ ждём готовности api"
|
||||
for _ in $(seq 1 60); do
|
||||
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
|
||||
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
|
||||
echo "pipu $VERSION запущен"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -24,6 +24,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
|
||||
| [server-management.md](server-management.md) | Управление обновлениями и config.local из админки, установка агента |
|
||||
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
|
||||
|
||||
## Стек в одном абзаце
|
||||
|
||||
@@ -133,3 +133,8 @@ APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релиз
|
||||
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
|
||||
|
||||
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
|
||||
|
||||
|
||||
## Управление обновлениями и конфигурацией
|
||||
|
||||
Разделы **Обновления** и **Конфигурация** используют агент на хосте. Установка для существующего сервера, права, приоритет `.env` над `config.local` и диагностика описаны в [server-management.md](server-management.md).
|
||||
@@ -97,3 +97,6 @@
|
||||
делает **единственный** параллельный refresh (single-flight) и повторяет запросы.
|
||||
- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`;
|
||||
админские разделы — по `role`.
|
||||
|
||||
|
||||
Управление обновлениями сервера — право `manages_updates`: у admin всегда, другим его выдаёт и отзывает только admin. Оно даёт доступ к `/admin/updates`, управлению источниками и запуску обновления, но не к конфигурации сервера. `/admin/config` доступен только admin. Подробности: [server-management.md](server-management.md).
|
||||
@@ -0,0 +1,127 @@
|
||||
# Управление сервером из веб-интерфейса
|
||||
|
||||
В меню админки доступны **Обновления** (`/admin/updates`) и **Конфигурация**
|
||||
(`/admin/config`). Клиентские APK и desktop-сборки по-прежнему раздаются через
|
||||
существующий механизм `appupdate`; источники обновления сервера от него независимы.
|
||||
|
||||
## Установка на существующий сервер
|
||||
|
||||
После получения версии с управлением сервером выполните на Linux-хосте:
|
||||
|
||||
```bash
|
||||
cd /opt/pipu-music/deploy
|
||||
sudo bash ./install-management.sh
|
||||
./update.sh main # либо тег версии, содержащей эту возможность
|
||||
```
|
||||
|
||||
Новая установка через `sudo ./install.sh` устанавливает агент автоматически.
|
||||
Нужны systemd, Python **3.10+**, Git, Bash, `flock` (util-linux) и Docker Compose
|
||||
**2.24+**. Compose должен поддерживать несколько `--env-file` и тег `!reset`
|
||||
из существующего TLS-overlay. Агент выполняется от root, чтобы запускать Compose
|
||||
и обращаться к данным развёртывания; API продолжает работать без root и без Docker socket.
|
||||
|
||||
Скрипт создаёт `pipu-management.service`, копирует агент вне Git-checkout в
|
||||
`/usr/local/lib/pipu/server-agent.py`, создаёт `deploy/config.local` и общий каталог
|
||||
`${PIPU_DATA}/management` с владельцем UID 65532. Последний монтируется только в API.
|
||||
Приватные настройки источников, состояние, аудит и журналы лежат в
|
||||
`/var/lib/pipu-management`, доступном только root. Нужен доступ этого пользователя
|
||||
к origin; SSH-ключи и known_hosts для приватных репозиториев настройте на хосте.
|
||||
Пароли и токены в URL источников не принимаются.
|
||||
|
||||
После успешного обновления агент автоматически заменяет свою установленную копию
|
||||
из нового checkout и перезапускается, сохранив итоговый статус. Для ручной установки
|
||||
нового кода можно повторно запустить `install-management.sh`.
|
||||
Во время выполняющейся операции не перезапускайте его: systemd остановит дочерние
|
||||
процессы. После прерывания/перезагрузки задача помечается ошибкой, а не успехом.
|
||||
|
||||
## Права
|
||||
|
||||
Администратор всегда может управлять обновлениями. В меню действий пользователя
|
||||
на странице **Пользователи** он может выдать и забрать право **Управление
|
||||
обновлениями** (`manages_updates`). Право действует независимо от роли и проверяется
|
||||
на сервере при каждом запросе. Оно позволяет изменять источники, видеть журнал
|
||||
и запускать обновление. Пользователь с этим правом получает ссылку на обновления,
|
||||
но не получает доступ к пользователям, конфигурации или остальным действиям админа.
|
||||
|
||||
Выдавайте это право доверенным операторам: обновление запускает код выбранного
|
||||
совместимого репозитория на хосте. Только администраторы могут просматривать,
|
||||
сохранять и применять конфигурацию. Выдача/отзыв права записываются в `audit_log`;
|
||||
операции агента с UUID инициатора — в его приватный `audit.jsonl` без значений секретов.
|
||||
|
||||
## Обновления
|
||||
|
||||
При первом обращении автоматически добавляется текущий `git remote origin`.
|
||||
Можно создавать, редактировать и удалять источники; хотя бы один должен остаться.
|
||||
Поддерживаются совместимые Git-репозитории Pipu через HTTP(S) и SSH.
|
||||
|
||||
* **Последний релиз** — значение по умолчанию: максимальный стабильный тег SemVer
|
||||
`vMAJOR.MINOR.PATCH` (также допускается без `v`), без `-rc.N`.
|
||||
* **Выбрать версию** — конкретный релизный тег, включая release candidate.
|
||||
* **main** — текущий commit ветки main.
|
||||
|
||||
Обновления устанавливаются только по нажатию кнопки и после подтверждения.
|
||||
Перед запуском агент повторно читает refs и фиксирует SHA; update.sh проверяет,
|
||||
что полученный commit совпадает. Выбранный источник не перезаписывает origin
|
||||
и не смешивает его теги с тегами другого репозитория. Git checkout с локальными
|
||||
изменениями отклоняется. Защита update.sh от downgrade сохраняется: откат
|
||||
выполняется вручную с `PIPU_ALLOW_DOWNGRADE=1` после проверки совместимости БД.
|
||||
|
||||
Выполнение, ограниченный хвост журнала и итоговый статус доступны на странице.
|
||||
Можно закрыть браузер или переждать перезапуск API: работа продолжается на хосте.
|
||||
Полные логи остаются приватными; для интерфейса известные секреты маскируются.
|
||||
Одновременно агент допускает только одну операцию обновления/применения.
|
||||
Резервная копия и откат схемы БД автоматически не выполняются.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Реестр поддерживаемых параметров находится в `deploy/config.schema.json`:
|
||||
API, общие параметры сервисов, рекомендатель и развёртывание. Добавляя параметр
|
||||
в конфигурацию сервиса, обновите реестр; для рекомендателя/агента хранилища также
|
||||
проверьте передачу через Compose. Пути томов, сгенерированный DATABASE_URL и
|
||||
некоторые служебные параметры показаны для просмотра и меняются на хосте.
|
||||
Страница не является произвольным редактором nginx/Caddy/YAML-файлов.
|
||||
|
||||
Приоритет при запуске через скрипты:
|
||||
|
||||
1. Явные переменные окружения Compose и значения из `deploy/.env`.
|
||||
2. `deploy/config.local`.
|
||||
3. Встроенные значения по умолчанию.
|
||||
|
||||
Для интерполяции Compose скрипты передают `--env-file config.local --env-file .env`.
|
||||
У API порядок `env_file: [config.local, .env]`; рекомендатель и storage-agent получают
|
||||
только необходимые им переменные через `environment`. При ручном запуске Compose
|
||||
сохраняйте эти флаги, иначе параметры интерполяции из config.local не подхватятся.
|
||||
Страница показывает ожидаемое значение после применения, а не утверждает, что
|
||||
работающий процесс уже перечитал сохранённый файл. Окружение shell запуска Compose
|
||||
тоже может перекрывать интерполяцию; для агента используется окружение systemd.
|
||||
|
||||
В интерфейсе видны локальное значение, значение из `.env` и источник приоритета.
|
||||
Если ключ уже есть в `.env`, изменение его в config.local сохраняется, но не
|
||||
перекрывает `.env` — интерфейс прямо сообщает об этом. Чтобы управлять таким ключом
|
||||
через страницу, удалите его строку из `.env` на хосте. Секреты не возвращаются в API
|
||||
открытым текстом: поле без изменений сохраняет прежний секрет, ввод заменяет,
|
||||
**Сбросить локальное** удаляет только значение из config.local.
|
||||
|
||||
Сохранение атомарно, с правами 0600 и проверкой ревизии: устаревшая форма не затрёт
|
||||
правки другого администратора. Перед сохранением проверяются типы и Compose;
|
||||
при ошибке прежний файл восстанавливается. Произвольные ключи, переводы строк
|
||||
и изменение служебных параметров отклоняются. Сохраняемые значения трактуются
|
||||
буквально: подстановки shell не выполняются.
|
||||
|
||||
Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров.
|
||||
Агент запускает `apply-config.sh`, ждёт `/readyz` и сохраняет результат. Если сервисы
|
||||
не стали готовы, состояние — ошибка; исправьте config.local или восстановите его
|
||||
на хосте. Автоматического отката конфигурации после неудачного применения нет.
|
||||
|
||||
Диагностика:
|
||||
|
||||
```bash
|
||||
sudo systemctl status pipu-management.service
|
||||
sudo journalctl -u pipu-management.service -n 100
|
||||
cd /opt/pipu-music/deploy
|
||||
docker compose --env-file config.local --env-file .env logs api
|
||||
```
|
||||
|
||||
Проверки разработки: `python3 -m unittest discover -s deploy -p 'test_*.py'`,
|
||||
`go test ./internal/auth ./internal/management` из `services/api`, а также
|
||||
`pnpm --filter @pipu/web test:e2e e2e/server-management.spec.ts`.
|
||||
@@ -4,6 +4,70 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/admin/server/updates": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getServerUpdates"];
|
||||
put?: never;
|
||||
post: operations["startServerUpdate"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/server/update-sources": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["saveServerUpdateSources"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/server/config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getServerConfig"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["saveServerConfig"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/server/config/apply": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["applyServerConfig"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/genius": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1499,6 +1563,62 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
ServerSource: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
ServerSources: {
|
||||
sources: components["schemas"]["ServerSource"][];
|
||||
};
|
||||
ServerJob: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
kind: "update" | "config";
|
||||
/** @enum {string} */
|
||||
state: "running" | "succeeded" | "failed";
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
target: string;
|
||||
log: string;
|
||||
};
|
||||
ServerUpdates: {
|
||||
sources: components["schemas"]["ServerSource"][];
|
||||
versions: string[];
|
||||
current_version: string;
|
||||
source_error: string;
|
||||
job: components["schemas"]["ServerJob"] | null;
|
||||
};
|
||||
ServerUpdateRequest: {
|
||||
source_id: string;
|
||||
/** @enum {string} */
|
||||
channel: "latest" | "version" | "main";
|
||||
version?: string;
|
||||
};
|
||||
ServerConfigEntry: {
|
||||
key: string;
|
||||
group: string;
|
||||
type: string;
|
||||
secret: boolean;
|
||||
readonly: boolean;
|
||||
effective: string;
|
||||
env: string | null;
|
||||
local: string | null;
|
||||
default_value: string;
|
||||
/** @enum {string} */
|
||||
source: "env" | "local" | "default";
|
||||
};
|
||||
ServerConfig: {
|
||||
revision: string;
|
||||
restart_required: boolean;
|
||||
entries: components["schemas"]["ServerConfigEntry"][];
|
||||
};
|
||||
ServerConfigPatch: {
|
||||
revision: string;
|
||||
values: {
|
||||
[key: string]: string | null;
|
||||
};
|
||||
};
|
||||
PlaylistShare: {
|
||||
/** Format: uuid */
|
||||
share_id: string;
|
||||
@@ -1859,6 +1979,8 @@ export interface components {
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: components["schemas"]["Role"];
|
||||
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
|
||||
manages_updates?: boolean;
|
||||
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
|
||||
reviews_uploads: boolean;
|
||||
locale: string;
|
||||
@@ -1888,6 +2010,8 @@ export interface components {
|
||||
username: string;
|
||||
display_name: string | null;
|
||||
role: components["schemas"]["Role"];
|
||||
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
|
||||
manages_updates?: boolean;
|
||||
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
|
||||
reviews_uploads: boolean;
|
||||
locale: string;
|
||||
@@ -1907,6 +2031,8 @@ export interface components {
|
||||
username: string;
|
||||
display_name?: string;
|
||||
role: components["schemas"]["Role"];
|
||||
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
|
||||
manages_updates?: boolean;
|
||||
/** @description Сразу выдать аудит загрузок. Может задать только админ */
|
||||
reviews_uploads?: boolean;
|
||||
};
|
||||
@@ -1922,6 +2048,8 @@ export interface components {
|
||||
display_name?: string;
|
||||
role?: components["schemas"]["Role"];
|
||||
is_blocked?: boolean;
|
||||
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
|
||||
manages_updates?: boolean;
|
||||
/** @description Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя */
|
||||
reviews_uploads?: boolean;
|
||||
};
|
||||
@@ -2563,6 +2691,146 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getServerUpdates: {
|
||||
parameters: {
|
||||
query?: {
|
||||
source_id?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Server management result */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerUpdates"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
startServerUpdate: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Server management result */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerJob"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
saveServerUpdateSources: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerSources"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Server management result */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerSources"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getServerConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Server management result */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerConfig"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
saveServerConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerConfigPatch"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Server management result */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerConfig"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
applyServerConfig: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Server management result */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ServerJob"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getGeniusStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"recs_confirm": "Reset {{username}}'s recommendations? The wave will start learning their taste from scratch and ask for favourite artists again. Likes and history stay.",
|
||||
"recs_done": "{{username}}'s recommendations were reset",
|
||||
"confirm": "Confirm",
|
||||
"you": "you"
|
||||
"you": "you",
|
||||
"manage_updates": "Server updates",
|
||||
"grant_updates": "Grant update management",
|
||||
"revoke_updates": "Revoke update management"
|
||||
},
|
||||
"create": {
|
||||
"title": "New user",
|
||||
@@ -71,7 +74,8 @@
|
||||
"user.unblock": "User unblocked",
|
||||
"user.password_reset": "Password reset",
|
||||
"user.recommendations_reset": "Recommendations reset",
|
||||
"user.delete": "User deleted"
|
||||
"user.delete": "User deleted",
|
||||
"user.manages_updates": "Server update permission changed"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -378,5 +382,85 @@
|
||||
"server_settings": {
|
||||
"title": "Server settings",
|
||||
"subtitle": "Settings that apply to all users"
|
||||
},
|
||||
"management": {
|
||||
"server": "Server management",
|
||||
"updates": "Updates",
|
||||
"config": "Configuration",
|
||||
"updates_intro": "Choose a source and version. The server will build and report the result.",
|
||||
"refresh": "Refresh status",
|
||||
"current": "Installed version",
|
||||
"manual": "Updates start manually. The latest stable release is resolved when you start the update.",
|
||||
"update_target": "Next update",
|
||||
"sources": "Update sources",
|
||||
"source": "Source",
|
||||
"channel": "Update target",
|
||||
"channel_latest": "Latest release",
|
||||
"channel_version": "Choose version",
|
||||
"channel_main": "main",
|
||||
"hint_latest": "Install the latest stable tag, excluding release candidates.",
|
||||
"hint_version": "Choose a published tag. Downgrading to an older version requires a manual rollback.",
|
||||
"hint_main": "The current main branch. May include unreleased changes.",
|
||||
"version": "Version",
|
||||
"choose_version": "Choose a version",
|
||||
"source_unavailable": "Could not read the repository. Check its address, server access and SSH keys.",
|
||||
"install": "Install update",
|
||||
"confirm_update": "Update the server?",
|
||||
"confirm_update_text": "Source: {{source}}. Target: {{target}}.",
|
||||
"update_warning": "The service will be briefly unavailable during restart. Back up the database first. Database migrations are not rolled back.",
|
||||
"cancel": "Cancel",
|
||||
"sources_hint": "Git repositories compatible with Pipu. HTTP(S) or SSH; configure private repository keys on the agent host. Do not include tokens in the URL.",
|
||||
"source_name": "Name",
|
||||
"repository": "Git repository URL",
|
||||
"remove_source": "Remove source",
|
||||
"add_source": "Add source",
|
||||
"save": "Save",
|
||||
"job": "Last operation",
|
||||
"started": "Started",
|
||||
"finished": "Finished",
|
||||
"log": "Execution log",
|
||||
"waiting": "Waiting for command output…",
|
||||
"running_hint": "The task runs on the server. You can close this page without interrupting it.",
|
||||
"reconnecting": "Services are restarting. Waiting to reconnect to the server…",
|
||||
"state": {
|
||||
"running": "Running",
|
||||
"succeeded": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"config_intro": "API, recommender and deployment settings in deploy/config.local.",
|
||||
"priority": "Priority order",
|
||||
"defaults": "defaults",
|
||||
"priority_hint": ".env values override config.local. Save and apply are separate actions.",
|
||||
"search": "Search settings",
|
||||
"group": "Section",
|
||||
"all": "All sections",
|
||||
"groups": {
|
||||
"api": "API",
|
||||
"deployment": "Deployment",
|
||||
"shared": "Shared",
|
||||
"recommender": "Recommender"
|
||||
},
|
||||
"config_files_hint": "Supported settings are listed here. “After applying” is the value from .env, config.local or a default. Fixed paths and service parameters are read-only. Secrets can be replaced; their values stay hidden.",
|
||||
"value_source": {
|
||||
"env": "From .env",
|
||||
"local": "From config.local",
|
||||
"default": "Default"
|
||||
},
|
||||
"effective": "After applying",
|
||||
"readonly": "Service parameter. Change on the host together with its Compose configuration.",
|
||||
"secret_saved": "Secret saved",
|
||||
"will_reset": "The local value will be removed when saved.",
|
||||
"overridden": "A value in .env takes priority. This local change will not take effect yet.",
|
||||
"secret_hint": "Leave unchanged to keep the stored secret. Enter a new value to replace it.",
|
||||
"default": "Default",
|
||||
"reset": "Reset local value",
|
||||
"no_results": "No settings found",
|
||||
"saved": "Changes saved to config.local",
|
||||
"unsaved": "Unsaved settings: {{count}}",
|
||||
"pending_restart": "Saved changes are waiting to be applied",
|
||||
"no_changes": "No changes to apply",
|
||||
"discard": "Discard edits",
|
||||
"apply": "Apply and restart",
|
||||
"apply_warning": "Containers will be recreated with the saved configuration. The service will be briefly unavailable; persistent volume data is preserved. Values from .env will keep taking priority."
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,9 @@
|
||||
"suggest": "Suggest tracks",
|
||||
"my_uploads": "My uploads",
|
||||
"upload_queue": "Queue",
|
||||
"admin_settings": "Settings"
|
||||
"admin_settings": "Settings",
|
||||
"server_updates": "Updates",
|
||||
"server_config": "Configuration"
|
||||
},
|
||||
"profile": {
|
||||
"menu": "Profile menu",
|
||||
|
||||
@@ -101,5 +101,21 @@
|
||||
"invalid_tracks": "Tracks must be unique and exist in the library",
|
||||
"too_large": "Maximum 2000 tracks and 2000 description characters",
|
||||
"conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data."
|
||||
},
|
||||
"management": {
|
||||
"unavailable": "Server management agent is unavailable. Run sudo bash deploy/install-management.sh on the server and check pipu-management.service.",
|
||||
"timeout": "The agent timed out. Check operation status before retrying.",
|
||||
"busy": "A server operation is already running. Wait for it to finish.",
|
||||
"source_invalid": "Check source names and URLs. Embedded passwords and tokens are not supported.",
|
||||
"source_unavailable": "The repository is unavailable from the server.",
|
||||
"target_invalid": "The selected version is unavailable or changed. Refresh the list and try again.",
|
||||
"no_releases": "No stable release tags were found.",
|
||||
"config_conflict": "Another administrator changed the configuration. Reload the page and repeat your edits.",
|
||||
"config_invalid": "Invalid value or Compose configuration. Changes were not saved.",
|
||||
"config_readonly": "This setting cannot be changed in the interface.",
|
||||
"agent_error": "The agent could not complete the operation. Check journalctl -u pipu-management.",
|
||||
"request_expired": "The request expired. Check operation status before retrying.",
|
||||
"request_invalid": "Invalid server management request.",
|
||||
"operation_invalid": "Unsupported server management operation."
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,10 @@
|
||||
"recs_confirm": "Сбросить рекомендации {{username}}? Волна начнёт подбирать музыку с нуля и снова спросит любимых исполнителей. Лайки и история останутся.",
|
||||
"recs_done": "Рекомендации {{username}} сброшены",
|
||||
"confirm": "Подтвердить",
|
||||
"you": "вы"
|
||||
"you": "вы",
|
||||
"manage_updates": "Обновление сервера",
|
||||
"grant_updates": "Дать управление обновлениями",
|
||||
"revoke_updates": "Забрать управление обновлениями"
|
||||
},
|
||||
"create": {
|
||||
"title": "Новый пользователь",
|
||||
@@ -71,7 +74,8 @@
|
||||
"user.unblock": "Пользователь разблокирован",
|
||||
"user.password_reset": "Сброшен пароль",
|
||||
"user.recommendations_reset": "Сброшены рекомендации",
|
||||
"user.delete": "Пользователь удалён"
|
||||
"user.delete": "Пользователь удалён",
|
||||
"user.manages_updates": "Изменены права обновления сервера"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -378,5 +382,85 @@
|
||||
"server_settings": {
|
||||
"title": "Настройки сервера",
|
||||
"subtitle": "Параметры, которые действуют для всех пользователей"
|
||||
},
|
||||
"management": {
|
||||
"server": "Управление сервером",
|
||||
"updates": "Обновления",
|
||||
"config": "Конфигурация",
|
||||
"updates_intro": "Выберите источник и версию. Сервер выполнит сборку и покажет результат.",
|
||||
"refresh": "Обновить статус",
|
||||
"current": "Установленная версия",
|
||||
"manual": "Обновления запускаются вручную. Последний стабильный релиз выбирается в момент запуска.",
|
||||
"update_target": "Следующее обновление",
|
||||
"sources": "Источники обновлений",
|
||||
"source": "Источник",
|
||||
"channel": "Тип обновления",
|
||||
"channel_latest": "Последний релиз",
|
||||
"channel_version": "Выбрать версию",
|
||||
"channel_main": "main",
|
||||
"hint_latest": "Будет установлен последний стабильный тег без release candidate.",
|
||||
"hint_version": "Можно выбрать опубликованный тег. Переход на старую версию требует ручного отката.",
|
||||
"hint_main": "Текущее состояние ветки main. Может содержать ещё не выпущенные изменения.",
|
||||
"version": "Версия",
|
||||
"choose_version": "Выберите версию",
|
||||
"source_unavailable": "Не удалось прочитать репозиторий. Проверьте адрес, доступ сервера и SSH-ключи.",
|
||||
"install": "Установить обновление",
|
||||
"confirm_update": "Обновить сервер?",
|
||||
"confirm_update_text": "Источник: {{source}}. Цель: {{target}}.",
|
||||
"update_warning": "Во время перезапуска сервис временно недоступен. Перед обновлением сделайте резервную копию базы. Миграции назад не откатываются.",
|
||||
"cancel": "Отмена",
|
||||
"sources_hint": "Git-репозитории с совместимой структурой Pipu. HTTP(S) или SSH; для приватных репозиториев настройте ключи на хосте агента. Не вставляйте токены в адрес.",
|
||||
"source_name": "Название",
|
||||
"repository": "URL Git-репозитория",
|
||||
"remove_source": "Удалить источник",
|
||||
"add_source": "Добавить источник",
|
||||
"save": "Сохранить",
|
||||
"job": "Последняя операция",
|
||||
"started": "Начало",
|
||||
"finished": "Завершение",
|
||||
"log": "Журнал выполнения",
|
||||
"waiting": "Ожидаем вывод команды…",
|
||||
"running_hint": "Задача выполняется на сервере. Страницу можно закрыть — выполнение продолжится.",
|
||||
"reconnecting": "Сервисы перезапускаются. Ждём восстановления связи с сервером…",
|
||||
"state": {
|
||||
"running": "Выполняется",
|
||||
"succeeded": "Готово",
|
||||
"failed": "Ошибка"
|
||||
},
|
||||
"config_intro": "Настройки API, рекомендателя и развёртывания в deploy/config.local.",
|
||||
"priority": "Порядок приоритета",
|
||||
"defaults": "по умолчанию",
|
||||
"priority_hint": "Значения из .env перекрывают config.local. Сохранение и применение — отдельные действия.",
|
||||
"search": "Поиск по названию настройки",
|
||||
"group": "Раздел",
|
||||
"all": "Все разделы",
|
||||
"groups": {
|
||||
"api": "API",
|
||||
"deployment": "Развёртывание",
|
||||
"shared": "Общие",
|
||||
"recommender": "Рекомендатель"
|
||||
},
|
||||
"config_files_hint": "Показаны поддерживаемые параметры. «После применения» — значение из .env, config.local или значение по умолчанию. Фиксированные пути и служебные параметры доступны для просмотра. Секреты можно заменить, их значения скрыты.",
|
||||
"value_source": {
|
||||
"env": "Из .env",
|
||||
"local": "Из config.local",
|
||||
"default": "По умолчанию"
|
||||
},
|
||||
"effective": "После применения",
|
||||
"readonly": "Служебный параметр. Меняется на хосте с учётом конфигурации Compose.",
|
||||
"secret_saved": "Секрет сохранён",
|
||||
"will_reset": "Локальное значение будет удалено при сохранении.",
|
||||
"overridden": "В .env есть значение: оно имеет приоритет. Локальное изменение пока не вступит в силу.",
|
||||
"secret_hint": "Оставьте поле без изменений, чтобы сохранить секрет. Введите новое значение для замены.",
|
||||
"default": "По умолчанию",
|
||||
"reset": "Сбросить локальное",
|
||||
"no_results": "Настройки не найдены",
|
||||
"saved": "Изменения сохранены в config.local",
|
||||
"unsaved": "Несохранённых параметров: {{count}}",
|
||||
"pending_restart": "Сохранённые изменения ожидают применения",
|
||||
"no_changes": "Нет изменений для применения",
|
||||
"discard": "Отменить правки",
|
||||
"apply": "Применить и перезапустить",
|
||||
"apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет."
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,9 @@
|
||||
"suggest": "Предложить треки",
|
||||
"my_uploads": "Мои предложения",
|
||||
"upload_queue": "Очередь",
|
||||
"admin_settings": "Настройки"
|
||||
"admin_settings": "Настройки",
|
||||
"server_updates": "Обновления",
|
||||
"server_config": "Конфигурация"
|
||||
},
|
||||
"profile": {
|
||||
"menu": "Меню профиля",
|
||||
|
||||
@@ -101,5 +101,21 @@
|
||||
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
|
||||
"too_large": "Максимум 2000 треков и 2000 символов описания",
|
||||
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
|
||||
},
|
||||
"management": {
|
||||
"unavailable": "Агент управления сервером недоступен. Выполните sudo bash deploy/install-management.sh на сервере и проверьте pipu-management.service.",
|
||||
"timeout": "Агент не ответил вовремя. Проверьте статус операции перед повторным запуском.",
|
||||
"busy": "На сервере уже выполняется операция. Дождитесь завершения.",
|
||||
"source_invalid": "Проверьте названия и адреса источников. Токены и пароли в URL не поддерживаются.",
|
||||
"source_unavailable": "Репозиторий недоступен с сервера.",
|
||||
"target_invalid": "Выбранная версия недоступна или изменилась. Обновите список и повторите.",
|
||||
"no_releases": "В источнике нет стабильных релизных тегов.",
|
||||
"config_conflict": "Конфигурация изменена другим администратором. Обновите страницу и повторите правки.",
|
||||
"config_invalid": "Недопустимое значение или конфигурация Compose. Изменения не сохранены.",
|
||||
"config_readonly": "Этот параметр нельзя менять через интерфейс.",
|
||||
"agent_error": "Агент не смог выполнить операцию. Проверьте journalctl -u pipu-management.",
|
||||
"request_expired": "Запрос устарел. Проверьте статус перед повтором.",
|
||||
"request_invalid": "Недопустимый запрос управления сервером.",
|
||||
"operation_invalid": "Операция управления сервером не поддерживается."
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/genius"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/platform/config"
|
||||
"pipu/api/internal/platform/db"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
@@ -172,6 +173,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
Client: &http.Client{}}, libStore, dto, log)
|
||||
wh.Warm = pb.Prewarm
|
||||
return server.NewRouter(server.Deps{
|
||||
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
|
||||
Genius: genius.NewHandler(geniusSvc),
|
||||
Log: log,
|
||||
System: sys,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,6 +78,7 @@ type CreateUserInput struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
Role Role
|
||||
ManagesUpdates bool
|
||||
ReviewsUploads bool
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
|
||||
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
|
||||
return User{}, "", forbidden()
|
||||
}
|
||||
if in.ReviewsUploads && p.Role != RoleAdmin {
|
||||
if (in.ReviewsUploads || in.ManagesUpdates) && p.Role != RoleAdmin {
|
||||
return User{}, "", forbidden()
|
||||
}
|
||||
username := NormalizeUsername(in.Username)
|
||||
@@ -114,7 +115,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
|
||||
}
|
||||
u := User{
|
||||
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role,
|
||||
ReviewsUploads: in.ReviewsUploads, Locale: locale,
|
||||
ReviewsUploads: in.ReviewsUploads, ManagesUpdates: in.ManagesUpdates, Locale: locale,
|
||||
MustChangePassword: true, CreatedAt: s.clock.Now(),
|
||||
}
|
||||
err = s.store.WithTx(ctx, func(tx Store) error {
|
||||
@@ -123,7 +124,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
|
||||
}
|
||||
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
|
||||
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
|
||||
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads},
|
||||
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads, "manages_updates": in.ManagesUpdates},
|
||||
})
|
||||
})
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
@@ -154,6 +155,7 @@ type UserPatch struct {
|
||||
DisplayName *string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
ManagesUpdates *bool
|
||||
ReviewsUploads *bool
|
||||
}
|
||||
|
||||
@@ -218,6 +220,15 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
|
||||
upd.ReviewsUploads = patch.ReviewsUploads
|
||||
audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads})
|
||||
}
|
||||
if patch.ManagesUpdates != nil {
|
||||
if p.Role != RoleAdmin {
|
||||
return forbidden()
|
||||
}
|
||||
if *patch.ManagesUpdates != target.ManagesUpdates {
|
||||
upd.ManagesUpdates = patch.ManagesUpdates
|
||||
audit("user.manages_updates", map[string]any{"manages_updates": *patch.ManagesUpdates})
|
||||
}
|
||||
}
|
||||
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
|
||||
if blockChange {
|
||||
if !CanManageUser(actor, tgt, UserBlock) {
|
||||
|
||||
@@ -44,6 +44,9 @@ func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObjec
|
||||
return nil, err
|
||||
}
|
||||
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
|
||||
if req.Body.ManagesUpdates != nil {
|
||||
in.ManagesUpdates = *req.Body.ManagesUpdates
|
||||
}
|
||||
if req.Body.ReviewsUploads != nil {
|
||||
in.ReviewsUploads = *req.Body.ReviewsUploads
|
||||
}
|
||||
@@ -63,7 +66,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads}
|
||||
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads, ManagesUpdates: req.Body.ManagesUpdates}
|
||||
if req.Body.Role != nil {
|
||||
r := Role(*req.Body.Role)
|
||||
patch.Role = &r
|
||||
@@ -151,6 +154,7 @@ func AdminUserDTO(u User) gen.AdminUser {
|
||||
DisplayName: u.DisplayName,
|
||||
Role: gen.Role(u.Role),
|
||||
ReviewsUploads: u.ReviewsUploads,
|
||||
ManagesUpdates: updatePermission(u),
|
||||
Locale: u.Locale,
|
||||
IsBlocked: u.IsBlocked,
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
|
||||
@@ -448,3 +448,25 @@ func TestReviewUploadsGrant(t *testing.T) {
|
||||
me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod))
|
||||
assert.Equal(t, true, me.body["reviews_uploads"])
|
||||
}
|
||||
|
||||
func TestManageUpdatesGrantAndRevoke(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
target := e.addUser("mod", "mod-password-1", auth.RoleModerator)
|
||||
mod := e.signIn("mod", "mod-password-1")
|
||||
path := "/api/v1/admin/users/" + target.ID.String()
|
||||
grant := map[string]any{"manages_updates": true}
|
||||
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(admin)).body["manages_updates"])
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, path, grant, bearer(mod)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(mod)).code())
|
||||
r := e.do(http.MethodPatch, path, grant, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
assert.Equal(t, true, r.body["manages_updates"])
|
||||
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
|
||||
r = e.do(http.MethodPatch, path, map[string]any{"manages_updates": false}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
|
||||
r = e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(admin))
|
||||
require.Equal(t, http.StatusCreated, r.Code, r.Body.String())
|
||||
}
|
||||
@@ -317,6 +317,9 @@ func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUp
|
||||
if upd.ReviewsUploads != nil {
|
||||
u.ReviewsUploads = *upd.ReviewsUploads
|
||||
}
|
||||
if upd.ManagesUpdates != nil {
|
||||
u.ManagesUpdates = *upd.ManagesUpdates
|
||||
}
|
||||
if upd.RevokeTokens {
|
||||
u.TokenVersion++
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@ func MeDTO(u User) gen.Me {
|
||||
DisplayName: u.DisplayName,
|
||||
Role: gen.Role(u.Role),
|
||||
ReviewsUploads: u.ReviewsUploads,
|
||||
ManagesUpdates: updatePermission(u),
|
||||
Locale: u.Locale,
|
||||
UiPrefs: uiPrefsDTO(u.UIPrefs),
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
@@ -271,3 +272,5 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
|
||||
Normalize: p.Normalize,
|
||||
}
|
||||
}
|
||||
|
||||
func updatePermission(u User) *bool { v := u.ManagesUpdates || u.Role == RoleAdmin; return &v }
|
||||
@@ -55,6 +55,7 @@ type User struct {
|
||||
DisplayName *string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
ManagesUpdates bool
|
||||
ReviewsUploads bool // аудит очереди на добавление; не следует из роли
|
||||
Locale string
|
||||
UIPrefs UIPrefs
|
||||
@@ -96,6 +97,7 @@ type Session struct {
|
||||
type Principal struct {
|
||||
UserID uuid.UUID
|
||||
Role Role
|
||||
ManagesUpdates bool
|
||||
ReviewsUploads bool
|
||||
SessionID uuid.UUID
|
||||
MustChangePassword bool
|
||||
|
||||
@@ -40,7 +40,7 @@ func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
|
||||
|
||||
const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password,
|
||||
must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at,
|
||||
reviews_uploads`
|
||||
reviews_uploads, manages_updates`
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var (
|
||||
@@ -50,7 +50,7 @@ func scanUser(row pgx.Row) (User, error) {
|
||||
)
|
||||
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs,
|
||||
&u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount,
|
||||
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads)
|
||||
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads, &u.ManagesUpdates)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
@@ -82,10 +82,10 @@ func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID)
|
||||
}
|
||||
_, err = s.q.Exec(ctx, `
|
||||
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs,
|
||||
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,
|
||||
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads, manages_updates)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`,
|
||||
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
|
||||
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads)
|
||||
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads, u.ManagesUpdates)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
@@ -347,10 +347,11 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
|
||||
role = COALESCE($4::user_role, role),
|
||||
is_blocked = COALESCE($5, is_blocked),
|
||||
reviews_uploads = COALESCE($7, reviews_uploads),
|
||||
manages_updates = COALESCE($9, manages_updates),
|
||||
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username))
|
||||
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username, upd.ManagesUpdates))
|
||||
if isUniqueViolation(err) {
|
||||
return User{}, ErrUsernameTaken
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
PermSuggestTracks // предлагать треки, видеть свои предложения
|
||||
PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль
|
||||
PermSystemStatus // статус рекомендательной системы, аудит
|
||||
PermManageUpdates // обновление сервера; admin или выданное право
|
||||
)
|
||||
|
||||
var permissions = map[Permission][]Role{
|
||||
@@ -25,6 +26,7 @@ var permissions = map[Permission][]Role{
|
||||
PermViewLibraryStatus: {RoleModerator, RoleAdmin},
|
||||
PermSuggestTracks: {RoleModerator, RoleAdmin},
|
||||
PermSystemStatus: {RoleAdmin},
|
||||
PermManageUpdates: {RoleAdmin},
|
||||
}
|
||||
|
||||
// Can сообщает, есть ли у роли глобальное право.
|
||||
@@ -40,6 +42,12 @@ func Can(r Role, p Permission) bool {
|
||||
|
||||
// Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin.
|
||||
func (p Principal) Allowed(perm Permission) bool {
|
||||
if perm == PermManageUpdates {
|
||||
return p.Role == RoleAdmin || p.ManagesUpdates
|
||||
}
|
||||
if perm == PermAdminPanel && p.ManagesUpdates {
|
||||
return true
|
||||
}
|
||||
if perm == PermReviewUploads {
|
||||
return p.ReviewsUploads
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestCan(t *testing.T) {
|
||||
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
|
||||
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
|
||||
{PermSystemStatus, "system status", [3]bool{false, false, true}},
|
||||
{PermManageUpdates, "manage updates", [3]bool{false, false, true}},
|
||||
}
|
||||
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
|
||||
for _, row := range matrix {
|
||||
|
||||
@@ -369,6 +369,7 @@ func (s *Service) Authenticate(ctx context.Context, access string) (Principal, e
|
||||
UserID: u.ID,
|
||||
Role: u.Role,
|
||||
ReviewsUploads: u.ReviewsUploads,
|
||||
ManagesUpdates: u.ManagesUpdates || u.Role == RoleAdmin,
|
||||
SessionID: claims.SessionID,
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
MustChangeCredentials: u.MustChangeCredentials,
|
||||
|
||||
@@ -35,6 +35,7 @@ type UserUpdate struct {
|
||||
DisplayName **string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
ManagesUpdates *bool
|
||||
ReviewsUploads *bool
|
||||
RevokeTokens bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Package management authorizes server administration and bridges fixed operations
|
||||
// to the host worker. The API never receives a Docker socket or executes a shell.
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/buildinfo"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Bridge interface {
|
||||
Call(context.Context, string, string, any, any) error
|
||||
}
|
||||
|
||||
type Mailbox struct{ Dir string }
|
||||
|
||||
func (m Mailbox) Call(ctx context.Context, actor, operation string, body, out any) error {
|
||||
heartbeat, err := os.ReadFile(filepath.Join(m.Dir, "heartbeat"))
|
||||
if err != nil {
|
||||
return apperr.New(503, "management.unavailable", "host agent is not installed or running")
|
||||
}
|
||||
at, err := strconv.ParseFloat(strings.TrimSpace(string(heartbeat)), 64)
|
||||
if err != nil || time.Since(time.Unix(int64(at), 0)) > 60*time.Second {
|
||||
return apperr.New(503, "management.unavailable", "host agent heartbeat expired")
|
||||
}
|
||||
id := uuid.NewString()
|
||||
request := filepath.Join(m.Dir, id+".request.json")
|
||||
response := filepath.Join(m.Dir, id+".response.json")
|
||||
raw, err := json.Marshal(map[string]any{"operation": operation, "actor": actor, "body": body, "created_at": time.Now().Unix()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(m.Dir, ".request-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
if _, err = tmp.Write(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err = tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Rename(tmp.Name(), request); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(response)
|
||||
defer os.Remove(request)
|
||||
ctx, cancel := context.WithTimeout(ctx, 40*time.Second)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return apperr.New(504, "management.timeout", "host agent did not respond; check status before retrying")
|
||||
case <-ticker.C:
|
||||
raw, err := os.ReadFile(response)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var result struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
Code string `json:"code"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Code != "" {
|
||||
status := result.Status
|
||||
if status < 400 || status > 599 {
|
||||
status = 502
|
||||
}
|
||||
return apperr.New(status, result.Code, "host management operation failed")
|
||||
}
|
||||
return json.Unmarshal(result.Data, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Handler struct{ bridge Bridge }
|
||||
|
||||
func NewHandler(bridge Bridge) *Handler { return &Handler{bridge: bridge} }
|
||||
|
||||
func (h *Handler) call(ctx context.Context, admin bool, operation string, body, out any) error {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
return apperr.Unauthorized(auth.CodeUnauthorized, "authentication required")
|
||||
}
|
||||
if (admin && p.Role != auth.RoleAdmin) || (!admin && !p.Allowed(auth.PermManageUpdates)) {
|
||||
return apperr.Forbidden(auth.CodeForbidden, "server management permission required")
|
||||
}
|
||||
return h.bridge.Call(ctx, p.UserID.String(), operation, body, out)
|
||||
}
|
||||
|
||||
func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) {
|
||||
var out gen.ServerUpdates
|
||||
err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId}, &out)
|
||||
out.CurrentVersion = buildinfo.Version
|
||||
return gen.GetServerUpdates200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServerUpdateSourcesRequestObject) (gen.SaveServerUpdateSourcesResponseObject, error) {
|
||||
var out gen.ServerSources
|
||||
err := h.call(ctx, false, "sources.save", req.Body, &out)
|
||||
return gen.SaveServerUpdateSources200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) {
|
||||
var out gen.ServerJob
|
||||
err := h.call(ctx, false, "updates.start", req.Body, &out)
|
||||
return gen.StartServerUpdate202JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) GetServerConfig(ctx context.Context, _ gen.GetServerConfigRequestObject) (gen.GetServerConfigResponseObject, error) {
|
||||
var out gen.ServerConfig
|
||||
err := h.call(ctx, true, "config.get", nil, &out)
|
||||
return gen.GetServerConfig200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) SaveServerConfig(ctx context.Context, req gen.SaveServerConfigRequestObject) (gen.SaveServerConfigResponseObject, error) {
|
||||
var out gen.ServerConfig
|
||||
err := h.call(ctx, true, "config.save", req.Body, &out)
|
||||
return gen.SaveServerConfig200JSONResponse(out), err
|
||||
}
|
||||
|
||||
func (h *Handler) ApplyServerConfig(ctx context.Context, _ gen.ApplyServerConfigRequestObject) (gen.ApplyServerConfigResponseObject, error) {
|
||||
var out gen.ServerJob
|
||||
err := h.call(ctx, true, "config.apply", nil, &out)
|
||||
return gen.ApplyServerConfig202JSONResponse(out), err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package management_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/server"
|
||||
)
|
||||
|
||||
type bridge struct{ calls int }
|
||||
|
||||
func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error {
|
||||
b.calls++
|
||||
return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out)
|
||||
}
|
||||
|
||||
func TestServerManagementAuthorization(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
principal auth.Principal
|
||||
updates, config int
|
||||
}{
|
||||
{"user", auth.Principal{Role: auth.RoleUser}, 403, 403},
|
||||
{"moderator", auth.Principal{Role: auth.RoleModerator}, 403, 403},
|
||||
{"delegate", auth.Principal{Role: auth.RoleUser, ManagesUpdates: true}, 200, 403},
|
||||
{"admin", auth.Principal{Role: auth.RoleAdmin}, 200, 200},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := &bridge{}
|
||||
router := server.NewRouter(server.Deps{Management: management.NewHandler(b)})
|
||||
for _, op := range []struct {
|
||||
method, path, body string
|
||||
want int
|
||||
}{
|
||||
{"GET", "updates", "", tc.updates},
|
||||
{"POST", "updates", `{"source_id":"origin","channel":"main"}`, tc.updates},
|
||||
{"PUT", "update-sources", `{"sources":[]}`, tc.updates},
|
||||
{"GET", "config", "", tc.config},
|
||||
{"PATCH", "config", `{"revision":"r","values":{}}`, tc.config},
|
||||
{"POST", "config/apply", "", tc.config},
|
||||
} {
|
||||
request := httptest.NewRequest(op.method, "/api/v1/admin/server/"+op.path, strings.NewReader(op.body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request = request.WithContext(auth.WithPrincipal(request.Context(), tc.principal))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, request)
|
||||
want := op.want
|
||||
if op.method == "POST" && want == 200 {
|
||||
want = 202
|
||||
}
|
||||
require.Equal(t, want, rec.Code, rec.Body.String())
|
||||
}
|
||||
if tc.updates == 403 && tc.config == 403 {
|
||||
require.Zero(t, b.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("anonymous", func(t *testing.T) {
|
||||
router := server.NewRouter(server.Deps{Management: management.NewHandler(&bridge{})})
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/server/config", nil))
|
||||
require.Equal(t, 401, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMailboxRoundTripAndUnavailable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := management.Mailbox{Dir: dir}
|
||||
var out map[string]any
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("1"), 0600))
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte(time.Now().Format("invalid")), 0600))
|
||||
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
|
||||
raw, _ := json.Marshal(time.Now().Unix())
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), raw, 0600))
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- m.Call(context.Background(), "actor", "config.get", nil, &out) }()
|
||||
var requests []string
|
||||
require.Eventually(t, func() bool {
|
||||
requests, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
|
||||
return len(requests) == 1
|
||||
}, time.Second, 10*time.Millisecond)
|
||||
request, err := os.ReadFile(requests[0])
|
||||
require.NoError(t, err)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(request, &payload))
|
||||
require.Equal(t, "config.get", payload["operation"])
|
||||
require.Equal(t, "actor", payload["actor"])
|
||||
response := strings.Replace(requests[0], ".request.json", ".response.json", 1)
|
||||
require.NoError(t, os.WriteFile(response, []byte(`{"data":{"revision":"ok"}}`), 0600))
|
||||
require.NoError(t, <-done)
|
||||
require.Equal(t, "ok", out["revision"])
|
||||
remaining, _ := filepath.Glob(filepath.Join(dir, "*.json"))
|
||||
require.Empty(t, remaining)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
// API — конфигурация публичного сервиса api.
|
||||
type API struct {
|
||||
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
|
||||
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
|
||||
DatabaseURL string `env:"DATABASE_URL,required"`
|
||||
RedisURL string `env:"REDIS_URL,required"`
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/genius"
|
||||
"pipu/api/internal/library"
|
||||
"pipu/api/internal/management"
|
||||
"pipu/api/internal/platform/httpx"
|
||||
"pipu/api/internal/playback"
|
||||
"pipu/api/internal/search"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
|
||||
// Deps — модули, реализующие части контракта API.
|
||||
type Deps struct {
|
||||
Management *management.Handler
|
||||
Genius *genius.Handler
|
||||
Log *slog.Logger
|
||||
System *system.Handler
|
||||
@@ -46,6 +48,7 @@ type Deps struct {
|
||||
// По мере появления модулей (catalog, ...) они встраиваются сюда.
|
||||
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
|
||||
type (
|
||||
managementHandler = management.Handler
|
||||
geniusHandler = genius.Handler
|
||||
systemHandler = system.Handler
|
||||
authHandler = auth.Handler
|
||||
@@ -58,6 +61,7 @@ type (
|
||||
)
|
||||
|
||||
type handlers struct {
|
||||
*managementHandler
|
||||
*geniusHandler
|
||||
*systemHandler
|
||||
*authHandler
|
||||
@@ -89,7 +93,7 @@ func NewRouter(d Deps) http.Handler {
|
||||
d.TUS.Mount(r)
|
||||
}
|
||||
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
|
||||
strict := gen.NewStrictHandlerWithOptions(handlers{managementHandler: d.Management, geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
|
||||
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
|
||||
RequestErrorHandlerFunc: httpx.RequestError,
|
||||
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE users ADD COLUMN manages_updates boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE users DROP COLUMN manages_updates;
|
||||
Reference in new issue
Block a user