diff --git a/.gitignore b/.gitignore index cfef653..8d3ae88 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ __pycache__/ .ruff_cache/ # env / secrets +deploy/config.local +deploy/.management.lock .env *.pem *.keystore diff --git a/api/openapi.yaml b/api/openapi.yaml index 059c8af..5725260 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя diff --git a/apps/web/e2e/server-management.spec.ts b/apps/web/e2e/server-management.spec.ts new file mode 100644 index 0000000..a521658 --- /dev/null +++ b/apps/web/e2e/server-management.spec.ts @@ -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('/'); +}); diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 4e114cd..676b3d4 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -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, ]), ]); diff --git a/apps/web/src/features/server-management/ConfigPage.tsx b/apps/web/src/features/server-management/ConfigPage.tsx new file mode 100644 index 0000000..55a040f --- /dev/null +++ b/apps/web/src/features/server-management/ConfigPage.tsx @@ -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>({}); + const [baseRevision, setBaseRevision] = useState(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(['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 ; + const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? []; + return
+
{t('management.server')}

{t('management.config')}

{t('management.config_intro')}

+ {query.error && {errorText(query.error)}} + {query.data && <> +
{t('management.priority')}ENV → config.local → {t('management.defaults')}

{t('management.priority_hint')}

+
+

{t('management.config_files_hint')}

+
{entries.map((entry) => { + const changed = Object.hasOwn(draft, entry.key); + const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? ''; + return
+
{entry.key}{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}{t(`management.value_source.${entry.source}`)}
+
{t('management.effective')}: {entry.effective || '—'}ENV: {entry.env ?? '—'}
+ {entry.readonly ?

{t('management.readonly')}

:
+ change(entry.key, v)} mono /> + +
} +
; + })}
+ {!entries.length &&

{t('management.no_results')}

} +
+
{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')}
+
+
+ {save.error && {errorText(save.error)}} + {apply.error && {errorText(apply.error)}} + + } + {confirm && !apply.isPending && setConfirm(false)}>

{t('management.apply_warning')}

{apply.error && {errorText(apply.error)}}
} +
; +} diff --git a/apps/web/src/features/server-management/JobPanel.tsx b/apps/web/src/features/server-management/JobPanel.tsx new file mode 100644 index 0000000..0149372 --- /dev/null +++ b/apps/web/src/features/server-management/JobPanel.tsx @@ -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
+
+
{t('management.job')}

{job.target}

+ {t(`management.state.${job.state}`)} +
+

{t('management.started')}: {new Date(job.started_at).toLocaleString()} + {job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}}

+ {job.state === 'running' &&

{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}

} +
{job.log || t('management.waiting')}
+
; +} diff --git a/apps/web/src/features/server-management/UpdatesPage.tsx b/apps/web/src/features/server-management/UpdatesPage.tsx new file mode 100644 index 0000000..51911d1 --- /dev/null +++ b/apps/web/src/features/server-management/UpdatesPage.tsx @@ -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('latest'); + const [version, setVersion] = useState(''); + const [confirm, setConfirm] = useState(false); + const [editing, setEditing] = useState(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(['server-updates', sourceId], (old) => old ? { ...old, job } : old); + void refresh(); + }, + }); + if (query.isPending) return ; + return
+
{t('management.server')}

{t('management.updates')}

{t('management.updates_intro')}

+ +
+ {query.error && {errorText(query.error)}} + {query.data && <> +
{t('management.current')}{query.data.current_version || '—'}

{t('management.manual')}

+
+

{t('management.update_target')}

+ +

{source?.url}

+ ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} /> +

{t(`management.hint_${channel}`)}

+ {channel === 'version' && } + {query.data.source_error && {t('management.source_unavailable')}} + {start.error && {errorText(start.error)}} +
+
+ + } + {confirm && !start.isPending && setConfirm(false)}> +

{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}

+

{t('management.update_warning')}

+ {start.error && {errorText(start.error)}} +
+
} + {editing && !save.isPending && setEditing(null)}> +

{t('management.sources_hint')}

+
{editing.map((item, index) =>
+ setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} /> + setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} /> + +
)}
+ {save.error && {errorText(save.error)}} +
+
} +
; +} diff --git a/apps/web/src/features/server-management/management.module.css b/apps/web/src/features/server-management/management.module.css new file mode 100644 index 0000000..de1b2be --- /dev/null +++ b/apps/web/src/features/server-management/management.module.css @@ -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; } } diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx index a2011a5..9011f38 100644 --- a/apps/web/src/pages/admin/UsersPage.tsx +++ b/apps/web/src/pages/admin/UsersPage.tsx @@ -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() { {t(`common:roles.${u.role}`)} + {u.manages_updates && {t('users.manage_updates')}} {u.reviews_uploads && {t('users.review_uploads')}} diff --git a/apps/web/src/test/e2eBridge.ts b/apps/web/src/test/e2eBridge.ts index 4e09ac7..2126805 100644 --- a/apps/web/src/test/e2eBridge.ts +++ b/apps/web/src/test/e2eBridge.ts @@ -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 }), ]); diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 0de4864..04bc5c3 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -79,12 +79,27 @@ export function fakeApi(initial: FakeUser[]) { const playlistOwners = new Map(); const playlists = new Map(); + 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 = {}; + 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)) { + 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; diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 2c7eb02..e1f5a80 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -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 && } {staff && ( @@ -215,8 +218,10 @@ function Shell() { badge={pending} /> )} + {(user.role === 'admin' || user.manages_updates) && } {user.role === 'admin' && ( <> + diff --git a/deploy/apply-config.sh b/deploy/apply-config.sh new file mode 100644 index 0000000..182a7c7 --- /dev/null +++ b/deploy/apply-config.sh @@ -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 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 41dfe6e..f61d7e8 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -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 diff --git a/deploy/config.schema.json b/deploy/config.schema.json new file mode 100644 index 0000000..ec4ce52 --- /dev/null +++ b/deploy/config.schema.json @@ -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 + } +] diff --git a/deploy/install-management.sh b/deploy/install-management.sh new file mode 100644 index 0000000..93e4559 --- /dev/null +++ b/deploy/install-management.sh @@ -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 <= 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() diff --git a/deploy/test_server_agent.py b/deploy/test_server_agent.py new file mode 100644 index 0000000..b91d195 --- /dev/null +++ b/deploy/test_server_agent.py @@ -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() diff --git a/deploy/update.sh b/deploy/update.sh index 0022d64..9277374 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -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:-}" -git fetch --tags --prune --quiet origin +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 -VERSION="$(git describe --tags --always --dirty)" +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 diff --git a/docs/README.md b/docs/README.md index 436b871..66242d4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | Справочник по развёртыванию: сервисы, каталоги, переменные | ## Стек в одном абзаце diff --git a/docs/admin-guide.md b/docs/admin-guide.md index ae6ee5f..7018c99 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.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). diff --git a/docs/auth-and-roles.md b/docs/auth-and-roles.md index 7f0d4c1..60a883d 100644 --- a/docs/auth-and-roles.md +++ b/docs/auth-and-roles.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). diff --git a/docs/server-management.md b/docs/server-management.md new file mode 100644 index 0000000..d287267 --- /dev/null +++ b/docs/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`. diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 80ba569..ee34b64 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.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; 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; 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; diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 543d9cf..86b2ed2 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -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." } } diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index fc5ebf8..7f9d7ef 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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", diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 1d918ac..2763c85 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -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." } } diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 090e00e..1da7a52 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -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 продолжат иметь приоритет." } } diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index cb88fc6..961e436 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -62,7 +62,9 @@ "suggest": "Предложить треки", "my_uploads": "Мои предложения", "upload_queue": "Очередь", - "admin_settings": "Настройки" + "admin_settings": "Настройки", + "server_updates": "Обновления", + "server_config": "Конфигурация" }, "profile": { "menu": "Меню профиля", diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 96dd476..dfc9f3a 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -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": "Операция управления сервером не поддерживается." } } diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 4984cd9..151607e 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -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, diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 9b608b4..160e807 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -137,6 +137,33 @@ const ( ScanProgressPhaseWalking ScanProgressPhase = "walking" ) +// Defines values for ServerConfigEntrySource. +const ( + ServerConfigEntrySourceDefault ServerConfigEntrySource = "default" + ServerConfigEntrySourceEnv ServerConfigEntrySource = "env" + ServerConfigEntrySourceLocal ServerConfigEntrySource = "local" +) + +// Defines values for ServerJobKind. +const ( + Config ServerJobKind = "config" + Update ServerJobKind = "update" +) + +// Defines values for ServerJobState. +const ( + Failed ServerJobState = "failed" + Running ServerJobState = "running" + Succeeded ServerJobState = "succeeded" +) + +// Defines values for ServerUpdateRequestChannel. +const ( + Latest ServerUpdateRequestChannel = "latest" + Main ServerUpdateRequestChannel = "main" + Version ServerUpdateRequestChannel = "version" +) + // Defines values for SourceStatus. const ( Disabled SourceStatus = "disabled" @@ -147,8 +174,8 @@ const ( // Defines values for SourceType. const ( - Local SourceType = "local" - Nfs SourceType = "nfs" + SourceTypeLocal SourceType = "local" + SourceTypeNfs SourceType = "nfs" ) // Defines values for StreamQuality. @@ -275,14 +302,17 @@ const ( // AdminUser defines model for AdminUser. type AdminUser struct { - CreatedAt time.Time `json:"created_at"` - DisplayName *string `json:"display_name"` - Id openapi_types.UUID `json:"id"` - IsBlocked bool `json:"is_blocked"` - LastLoginAt *time.Time `json:"last_login_at"` - Locale string `json:"locale"` - MustChangeCredentials bool `json:"must_change_credentials"` - MustChangePassword bool `json:"must_change_password"` + CreatedAt time.Time `json:"created_at"` + DisplayName *string `json:"display_name"` + Id openapi_types.UUID `json:"id"` + IsBlocked bool `json:"is_blocked"` + LastLoginAt *time.Time `json:"last_login_at"` + Locale string `json:"locale"` + + // ManagesUpdates Управление обновлениями сервера. У admin всегда; другим выдаёт только admin + ManagesUpdates *bool `json:"manages_updates,omitempty"` + MustChangeCredentials bool `json:"must_change_credentials"` + MustChangePassword bool `json:"must_change_password"` // ReviewsUploads Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу ReviewsUploads bool `json:"reviews_uploads"` @@ -484,6 +514,9 @@ type ClientType string type CreateUserRequest struct { DisplayName *string `json:"display_name,omitempty"` + // ManagesUpdates Управление обновлениями сервера. У admin всегда; другим выдаёт только admin + ManagesUpdates *bool `json:"manages_updates,omitempty"` + // ReviewsUploads Сразу выдать аудит загрузок. Может задать только админ ReviewsUploads *bool `json:"reviews_uploads,omitempty"` Role Role `json:"role"` @@ -625,12 +658,15 @@ type LoginRequest struct { // Me defines model for Me. type Me struct { - CreatedAt time.Time `json:"created_at"` - DisplayName *string `json:"display_name"` - Id openapi_types.UUID `json:"id"` - Locale string `json:"locale"` - MustChangeCredentials bool `json:"must_change_credentials"` - MustChangePassword bool `json:"must_change_password"` + CreatedAt time.Time `json:"created_at"` + DisplayName *string `json:"display_name"` + Id openapi_types.UUID `json:"id"` + Locale string `json:"locale"` + + // ManagesUpdates Управление обновлениями сервера. У admin всегда; другим выдаёт только admin + ManagesUpdates *bool `json:"manages_updates,omitempty"` + MustChangeCredentials bool `json:"must_change_credentials"` + MustChangePassword bool `json:"must_change_password"` // ReviewsUploads Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу ReviewsUploads bool `json:"reviews_uploads"` @@ -937,6 +973,36 @@ type SearchResults struct { Tracks []Track `json:"tracks"` } +// ServerConfig defines model for ServerConfig. +type ServerConfig struct { + Entries []ServerConfigEntry `json:"entries"` + RestartRequired bool `json:"restart_required"` + Revision string `json:"revision"` +} + +// ServerConfigEntry defines model for ServerConfigEntry. +type ServerConfigEntry struct { + DefaultValue string `json:"default_value"` + Effective string `json:"effective"` + Env *string `json:"env"` + Group string `json:"group"` + Key string `json:"key"` + Local *string `json:"local"` + Readonly bool `json:"readonly"` + Secret bool `json:"secret"` + Source ServerConfigEntrySource `json:"source"` + Type string `json:"type"` +} + +// ServerConfigEntrySource defines model for ServerConfigEntry.Source. +type ServerConfigEntrySource string + +// ServerConfigPatch defines model for ServerConfigPatch. +type ServerConfigPatch struct { + Revision string `json:"revision"` + Values map[string]*string `json:"values"` +} + // ServerInfo defines model for ServerInfo. type ServerInfo struct { ApiVersion string `json:"api_version"` @@ -953,6 +1019,54 @@ type ServerInfo struct { Version string `json:"version"` } +// ServerJob defines model for ServerJob. +type ServerJob struct { + FinishedAt string `json:"finished_at"` + Id string `json:"id"` + Kind ServerJobKind `json:"kind"` + Log string `json:"log"` + StartedAt string `json:"started_at"` + State ServerJobState `json:"state"` + Target string `json:"target"` +} + +// ServerJobKind defines model for ServerJob.Kind. +type ServerJobKind string + +// ServerJobState defines model for ServerJob.State. +type ServerJobState string + +// ServerSource defines model for ServerSource. +type ServerSource struct { + Id string `json:"id"` + Name string `json:"name"` + Url string `json:"url"` +} + +// ServerSources defines model for ServerSources. +type ServerSources struct { + Sources []ServerSource `json:"sources"` +} + +// ServerUpdateRequest defines model for ServerUpdateRequest. +type ServerUpdateRequest struct { + Channel ServerUpdateRequestChannel `json:"channel"` + SourceId string `json:"source_id"` + Version *string `json:"version,omitempty"` +} + +// ServerUpdateRequestChannel defines model for ServerUpdateRequest.Channel. +type ServerUpdateRequestChannel string + +// ServerUpdates defines model for ServerUpdates. +type ServerUpdates struct { + CurrentVersion string `json:"current_version"` + Job *ServerJob `json:"job"` + SourceError string `json:"source_error"` + Sources []ServerSource `json:"sources"` + Versions []string `json:"versions"` +} + // Session defines model for Session. type Session struct { ClientType ClientType `json:"client_type"` @@ -1191,6 +1305,9 @@ type UpdateUserRequest struct { DisplayName *string `json:"display_name,omitempty"` IsBlocked *bool `json:"is_blocked,omitempty"` + // ManagesUpdates Управление обновлениями сервера. У admin всегда; другим выдаёт только admin + ManagesUpdates *bool `json:"manages_updates,omitempty"` + // ReviewsUploads Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя ReviewsUploads *bool `json:"reviews_uploads,omitempty"` Role *Role `json:"role,omitempty"` @@ -1574,6 +1691,11 @@ type ListHostDirsParams struct { Path *string `form:"path,omitempty" json:"path,omitempty"` } +// GetServerUpdatesParams defines parameters for GetServerUpdates. +type GetServerUpdatesParams struct { + SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"` +} + // ListUploadsParams defines parameters for ListUploads. type ListUploadsParams struct { // Status Статусы через запятую @@ -1686,6 +1808,15 @@ type ListMyUploadsParams struct { // ControlGeniusJSONRequestBody defines body for ControlGenius for application/json ContentType. type ControlGeniusJSONRequestBody ControlGeniusJSONBody +// SaveServerConfigJSONRequestBody defines body for SaveServerConfig for application/json ContentType. +type SaveServerConfigJSONRequestBody = ServerConfigPatch + +// SaveServerUpdateSourcesJSONRequestBody defines body for SaveServerUpdateSources for application/json ContentType. +type SaveServerUpdateSourcesJSONRequestBody = ServerSources + +// StartServerUpdateJSONRequestBody defines body for StartServerUpdate for application/json ContentType. +type StartServerUpdateJSONRequestBody = ServerUpdateRequest + // UpdateUploadSettingsJSONRequestBody defines body for UpdateUploadSettings for application/json ContentType. type UpdateUploadSettingsJSONRequestBody = UploadSettingsPatch @@ -1802,6 +1933,24 @@ type ServerInterface interface { // Пересобрать поисковые индексы (admin) // (POST /api/v1/admin/search/reindex) ReindexSearch(w http.ResponseWriter, r *http.Request) + + // (GET /api/v1/admin/server/config) + GetServerConfig(w http.ResponseWriter, r *http.Request) + + // (PATCH /api/v1/admin/server/config) + SaveServerConfig(w http.ResponseWriter, r *http.Request) + + // (POST /api/v1/admin/server/config/apply) + ApplyServerConfig(w http.ResponseWriter, r *http.Request) + + // (PUT /api/v1/admin/server/update-sources) + SaveServerUpdateSources(w http.ResponseWriter, r *http.Request) + + // (GET /api/v1/admin/server/updates) + GetServerUpdates(w http.ResponseWriter, r *http.Request, params GetServerUpdatesParams) + + // (POST /api/v1/admin/server/updates) + StartServerUpdate(w http.ResponseWriter, r *http.Request) // Настройки очереди (admin) // (GET /api/v1/admin/settings/uploads) GetUploadSettings(w http.ResponseWriter, r *http.Request) @@ -2115,6 +2264,36 @@ func (_ Unimplemented) ReindexSearch(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// (GET /api/v1/admin/server/config) +func (_ Unimplemented) GetServerConfig(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (PATCH /api/v1/admin/server/config) +func (_ Unimplemented) SaveServerConfig(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (POST /api/v1/admin/server/config/apply) +func (_ Unimplemented) ApplyServerConfig(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (PUT /api/v1/admin/server/update-sources) +func (_ Unimplemented) SaveServerUpdateSources(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (GET /api/v1/admin/server/updates) +func (_ Unimplemented) GetServerUpdates(w http.ResponseWriter, r *http.Request, params GetServerUpdatesParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (POST /api/v1/admin/server/updates) +func (_ Unimplemented) StartServerUpdate(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Настройки очереди (admin) // (GET /api/v1/admin/settings/uploads) func (_ Unimplemented) GetUploadSettings(w http.ResponseWriter, r *http.Request) { @@ -2884,6 +3063,139 @@ func (siw *ServerInterfaceWrapper) ReindexSearch(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } +// GetServerConfig operation middleware +func (siw *ServerInterfaceWrapper) GetServerConfig(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetServerConfig(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SaveServerConfig operation middleware +func (siw *ServerInterfaceWrapper) SaveServerConfig(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SaveServerConfig(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ApplyServerConfig operation middleware +func (siw *ServerInterfaceWrapper) ApplyServerConfig(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ApplyServerConfig(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SaveServerUpdateSources operation middleware +func (siw *ServerInterfaceWrapper) SaveServerUpdateSources(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SaveServerUpdateSources(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetServerUpdates operation middleware +func (siw *ServerInterfaceWrapper) GetServerUpdates(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params GetServerUpdatesParams + + // ------------- Optional query parameter "source_id" ------------- + + err = runtime.BindQueryParameter("form", true, false, "source_id", r.URL.Query(), ¶ms.SourceId) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "source_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetServerUpdates(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StartServerUpdate operation middleware +func (siw *ServerInterfaceWrapper) StartServerUpdate(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StartServerUpdate(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetUploadSettings operation middleware func (siw *ServerInterfaceWrapper) GetUploadSettings(w http.ResponseWriter, r *http.Request) { @@ -5519,6 +5831,24 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/search/reindex", wrapper.ReindexSearch) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/admin/server/config", wrapper.GetServerConfig) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/admin/server/config", wrapper.SaveServerConfig) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/admin/server/config/apply", wrapper.ApplyServerConfig) + }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/api/v1/admin/server/update-sources", wrapper.SaveServerUpdateSources) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/admin/server/updates", wrapper.GetServerUpdates) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/admin/server/updates", wrapper.StartServerUpdate) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/admin/settings/uploads", wrapper.GetUploadSettings) }) @@ -6014,6 +6344,178 @@ func (response ReindexSearchdefaultApplicationProblemPlusJSONResponse) VisitRein return json.NewEncoder(w).Encode(response.Body) } +type GetServerConfigRequestObject struct { +} + +type GetServerConfigResponseObject interface { + VisitGetServerConfigResponse(w http.ResponseWriter) error +} + +type GetServerConfig200JSONResponse ServerConfig + +func (response GetServerConfig200JSONResponse) VisitGetServerConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetServerConfigdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetServerConfigdefaultApplicationProblemPlusJSONResponse) VisitGetServerConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type SaveServerConfigRequestObject struct { + Body *SaveServerConfigJSONRequestBody +} + +type SaveServerConfigResponseObject interface { + VisitSaveServerConfigResponse(w http.ResponseWriter) error +} + +type SaveServerConfig200JSONResponse ServerConfig + +func (response SaveServerConfig200JSONResponse) VisitSaveServerConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SaveServerConfigdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response SaveServerConfigdefaultApplicationProblemPlusJSONResponse) VisitSaveServerConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ApplyServerConfigRequestObject struct { +} + +type ApplyServerConfigResponseObject interface { + VisitApplyServerConfigResponse(w http.ResponseWriter) error +} + +type ApplyServerConfig202JSONResponse ServerJob + +func (response ApplyServerConfig202JSONResponse) VisitApplyServerConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(202) + + return json.NewEncoder(w).Encode(response) +} + +type ApplyServerConfigdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ApplyServerConfigdefaultApplicationProblemPlusJSONResponse) VisitApplyServerConfigResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type SaveServerUpdateSourcesRequestObject struct { + Body *SaveServerUpdateSourcesJSONRequestBody +} + +type SaveServerUpdateSourcesResponseObject interface { + VisitSaveServerUpdateSourcesResponse(w http.ResponseWriter) error +} + +type SaveServerUpdateSources200JSONResponse ServerSources + +func (response SaveServerUpdateSources200JSONResponse) VisitSaveServerUpdateSourcesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type SaveServerUpdateSourcesdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response SaveServerUpdateSourcesdefaultApplicationProblemPlusJSONResponse) VisitSaveServerUpdateSourcesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type GetServerUpdatesRequestObject struct { + Params GetServerUpdatesParams +} + +type GetServerUpdatesResponseObject interface { + VisitGetServerUpdatesResponse(w http.ResponseWriter) error +} + +type GetServerUpdates200JSONResponse ServerUpdates + +func (response GetServerUpdates200JSONResponse) VisitGetServerUpdatesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetServerUpdatesdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetServerUpdatesdefaultApplicationProblemPlusJSONResponse) VisitGetServerUpdatesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type StartServerUpdateRequestObject struct { + Body *StartServerUpdateJSONRequestBody +} + +type StartServerUpdateResponseObject interface { + VisitStartServerUpdateResponse(w http.ResponseWriter) error +} + +type StartServerUpdate202JSONResponse ServerJob + +func (response StartServerUpdate202JSONResponse) VisitStartServerUpdateResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(202) + + return json.NewEncoder(w).Encode(response) +} + +type StartServerUpdatedefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response StartServerUpdatedefaultApplicationProblemPlusJSONResponse) VisitStartServerUpdateResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type GetUploadSettingsRequestObject struct { } @@ -8521,6 +9023,24 @@ type StrictServerInterface interface { // Пересобрать поисковые индексы (admin) // (POST /api/v1/admin/search/reindex) ReindexSearch(ctx context.Context, request ReindexSearchRequestObject) (ReindexSearchResponseObject, error) + + // (GET /api/v1/admin/server/config) + GetServerConfig(ctx context.Context, request GetServerConfigRequestObject) (GetServerConfigResponseObject, error) + + // (PATCH /api/v1/admin/server/config) + SaveServerConfig(ctx context.Context, request SaveServerConfigRequestObject) (SaveServerConfigResponseObject, error) + + // (POST /api/v1/admin/server/config/apply) + ApplyServerConfig(ctx context.Context, request ApplyServerConfigRequestObject) (ApplyServerConfigResponseObject, error) + + // (PUT /api/v1/admin/server/update-sources) + SaveServerUpdateSources(ctx context.Context, request SaveServerUpdateSourcesRequestObject) (SaveServerUpdateSourcesResponseObject, error) + + // (GET /api/v1/admin/server/updates) + GetServerUpdates(ctx context.Context, request GetServerUpdatesRequestObject) (GetServerUpdatesResponseObject, error) + + // (POST /api/v1/admin/server/updates) + StartServerUpdate(ctx context.Context, request StartServerUpdateRequestObject) (StartServerUpdateResponseObject, error) // Настройки очереди (admin) // (GET /api/v1/admin/settings/uploads) GetUploadSettings(ctx context.Context, request GetUploadSettingsRequestObject) (GetUploadSettingsResponseObject, error) @@ -9038,6 +9558,173 @@ func (sh *strictHandler) ReindexSearch(w http.ResponseWriter, r *http.Request) { } } +// GetServerConfig operation middleware +func (sh *strictHandler) GetServerConfig(w http.ResponseWriter, r *http.Request) { + var request GetServerConfigRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetServerConfig(ctx, request.(GetServerConfigRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetServerConfig") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetServerConfigResponseObject); ok { + if err := validResponse.VisitGetServerConfigResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SaveServerConfig operation middleware +func (sh *strictHandler) SaveServerConfig(w http.ResponseWriter, r *http.Request) { + var request SaveServerConfigRequestObject + + var body SaveServerConfigJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SaveServerConfig(ctx, request.(SaveServerConfigRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SaveServerConfig") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SaveServerConfigResponseObject); ok { + if err := validResponse.VisitSaveServerConfigResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ApplyServerConfig operation middleware +func (sh *strictHandler) ApplyServerConfig(w http.ResponseWriter, r *http.Request) { + var request ApplyServerConfigRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ApplyServerConfig(ctx, request.(ApplyServerConfigRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ApplyServerConfig") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ApplyServerConfigResponseObject); ok { + if err := validResponse.VisitApplyServerConfigResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// SaveServerUpdateSources operation middleware +func (sh *strictHandler) SaveServerUpdateSources(w http.ResponseWriter, r *http.Request) { + var request SaveServerUpdateSourcesRequestObject + + var body SaveServerUpdateSourcesJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.SaveServerUpdateSources(ctx, request.(SaveServerUpdateSourcesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "SaveServerUpdateSources") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(SaveServerUpdateSourcesResponseObject); ok { + if err := validResponse.VisitSaveServerUpdateSourcesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetServerUpdates operation middleware +func (sh *strictHandler) GetServerUpdates(w http.ResponseWriter, r *http.Request, params GetServerUpdatesParams) { + var request GetServerUpdatesRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetServerUpdates(ctx, request.(GetServerUpdatesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetServerUpdates") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetServerUpdatesResponseObject); ok { + if err := validResponse.VisitGetServerUpdatesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// StartServerUpdate operation middleware +func (sh *strictHandler) StartServerUpdate(w http.ResponseWriter, r *http.Request) { + var request StartServerUpdateRequestObject + + var body StartServerUpdateJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.StartServerUpdate(ctx, request.(StartServerUpdateRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "StartServerUpdate") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(StartServerUpdateResponseObject); ok { + if err := validResponse.VisitStartServerUpdateResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // GetUploadSettings operation middleware func (sh *strictHandler) GetUploadSettings(w http.ResponseWriter, r *http.Request) { var request GetUploadSettingsRequestObject @@ -11389,306 +12076,316 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/auth/admin.go b/services/api/internal/auth/admin.go index 26e3819..392cf81 100644 --- a/services/api/internal/auth/admin.go +++ b/services/api/internal/auth/admin.go @@ -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) { diff --git a/services/api/internal/auth/admin_handler.go b/services/api/internal/auth/admin_handler.go index ccf413d..fc4e025 100644 --- a/services/api/internal/auth/admin_handler.go +++ b/services/api/internal/auth/admin_handler.go @@ -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, diff --git a/services/api/internal/auth/admin_test.go b/services/api/internal/auth/admin_test.go index 622b8ab..2bfca44 100644 --- a/services/api/internal/auth/admin_test.go +++ b/services/api/internal/auth/admin_test.go @@ -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()) +} diff --git a/services/api/internal/auth/authtest/memstore.go b/services/api/internal/auth/authtest/memstore.go index 0e9373a..e599e8d 100644 --- a/services/api/internal/auth/authtest/memstore.go +++ b/services/api/internal/auth/authtest/memstore.go @@ -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++ } diff --git a/services/api/internal/auth/handler.go b/services/api/internal/auth/handler.go index 425e1f4..9cd62c6 100644 --- a/services/api/internal/auth/handler.go +++ b/services/api/internal/auth/handler.go @@ -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 } diff --git a/services/api/internal/auth/model.go b/services/api/internal/auth/model.go index 78cb83a..32f3f3d 100644 --- a/services/api/internal/auth/model.go +++ b/services/api/internal/auth/model.go @@ -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 diff --git a/services/api/internal/auth/pgstore.go b/services/api/internal/auth/pgstore.go index 60628a8..88332dc 100644 --- a/services/api/internal/auth/pgstore.go +++ b/services/api/internal/auth/pgstore.go @@ -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 } diff --git a/services/api/internal/auth/policy.go b/services/api/internal/auth/policy.go index a368c7f..49a820f 100644 --- a/services/api/internal/auth/policy.go +++ b/services/api/internal/auth/policy.go @@ -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 } diff --git a/services/api/internal/auth/policy_test.go b/services/api/internal/auth/policy_test.go index a2f0a34..ec5bd30 100644 --- a/services/api/internal/auth/policy_test.go +++ b/services/api/internal/auth/policy_test.go @@ -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 { diff --git a/services/api/internal/auth/service.go b/services/api/internal/auth/service.go index bfaf84d..7e59733 100644 --- a/services/api/internal/auth/service.go +++ b/services/api/internal/auth/service.go @@ -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, diff --git a/services/api/internal/auth/store.go b/services/api/internal/auth/store.go index 75b79b8..b32323c 100644 --- a/services/api/internal/auth/store.go +++ b/services/api/internal/auth/store.go @@ -35,6 +35,7 @@ type UserUpdate struct { DisplayName **string Role *Role IsBlocked *bool + ManagesUpdates *bool ReviewsUploads *bool RevokeTokens bool } diff --git a/services/api/internal/management/handler.go b/services/api/internal/management/handler.go new file mode 100644 index 0000000..491aed7 --- /dev/null +++ b/services/api/internal/management/handler.go @@ -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 +} diff --git a/services/api/internal/management/handler_test.go b/services/api/internal/management/handler_test.go new file mode 100644 index 0000000..8b70cec --- /dev/null +++ b/services/api/internal/management/handler_test.go @@ -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) +} diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 4cde790..9f3ba46 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -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"` diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index 54e68ab..1f4e8bd 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -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,10 +24,11 @@ import ( // Deps — модули, реализующие части контракта API. type Deps struct { - Genius *genius.Handler - Log *slog.Logger - System *system.Handler - Auth *auth.Handler + Management *management.Handler + Genius *genius.Handler + Log *slog.Logger + System *system.Handler + Auth *auth.Handler // Library — хранилища, сканы и каталог. Library *library.Handler Search *search.Handler @@ -46,18 +48,20 @@ type Deps struct { // По мере появления модулей (catalog, ...) они встраиваются сюда. // Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler. type ( - geniusHandler = genius.Handler - systemHandler = system.Handler - authHandler = auth.Handler - libraryHandler = library.Handler - searchHandler = search.Handler - playbackHandler = playback.Handler - waveHandler = wave.Handler - appHandler = appupdate.Handler - uploadsHandler = uploads.Handler + managementHandler = management.Handler + geniusHandler = genius.Handler + systemHandler = system.Handler + authHandler = auth.Handler + libraryHandler = library.Handler + searchHandler = search.Handler + playbackHandler = playback.Handler + waveHandler = wave.Handler + appHandler = appupdate.Handler + uploadsHandler = uploads.Handler ) 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), diff --git a/services/api/migrations/00018_manage_updates.sql b/services/api/migrations/00018_manage_updates.sql new file mode 100644 index 0000000..82c313a --- /dev/null +++ b/services/api/migrations/00018_manage_updates.sql @@ -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;