fix(ui): reflect update availability and config changes; expand mobile player metadata
ci / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / db / db (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / ts / ts (packages + web) (push) Successful in 2m40s

This commit is contained in:
mrleo1nid committed 2026-10-23 00:20:59 +03:00
1 parent bc55528afd
commit a6c9fd8a00
30 files changed
+833 -395

No files matched your search

+2 -2
View File
@@ -47,8 +47,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Установка агента при обновлении (изолированные тесты) - name: Обновление сервера, агент и конфигурация
run: python3 -m unittest discover -s deploy -p 'test_deployment_scripts.py' -v run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и # Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги # --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`. # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
+15 -1
View File
@@ -30,6 +30,15 @@ paths:
- in: query - in: query
name: source_id name: source_id
schema: {type: string} schema: {type: string}
- in: query
name: channel
schema: {type: string, enum: [latest, version, main], default: latest}
- in: query
name: version
schema: {type: string}
- in: query
name: fresh
schema: {type: boolean, default: false}
responses: responses:
"200": "200":
description: Server management result description: Server management result
@@ -1958,7 +1967,7 @@ components:
log: {type: string} log: {type: string}
ServerUpdates: ServerUpdates:
type: object type: object
required: [sources, versions, current_version, job, source_error] required: [sources, versions, current_version, job, source_error, current_commit, target_version, target_commit, update_state]
properties: properties:
sources: sources:
type: array type: array
@@ -1967,6 +1976,10 @@ components:
type: array type: array
items: {type: string} items: {type: string}
current_version: {type: string} current_version: {type: string}
current_commit: {type: string}
target_version: {type: string}
target_commit: {type: string}
update_state: {type: string, enum: [available, current, ahead, unknown, unavailable, select_version]}
source_error: {type: string} source_error: {type: string}
job: job:
allOf: [{$ref: "#/components/schemas/ServerJob"}] allOf: [{$ref: "#/components/schemas/ServerJob"}]
@@ -1978,6 +1991,7 @@ components:
source_id: {type: string} source_id: {type: string}
channel: {type: string, enum: [latest, version, main]} channel: {type: string, enum: [latest, version, main]}
version: {type: string} version: {type: string}
expected_commit: {type: string, pattern: '^[a-f0-9]{40,64}$'}
ServerConfigEntry: ServerConfigEntry:
type: object type: object
required: [key, group, type, secret, readonly, effective, env, local, default_value, source] required: [key, group, type, secret, readonly, effective, env, local, default_value, source]
+9 -1
View File
@@ -102,7 +102,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
await page.getByRole('listitem').first().click(); await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' }); const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible(); await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40); expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
const artist = player.getByRole('link', { name: 'Кино', exact: true });
await expect(artist).toBeVisible();
const artistArea = await artist.locator('..').boundingBox();
expect(artistArea!.width).toBeGreaterThan(220);
const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
await page.screenshot({ path: info.outputPath('player-readable.png') });
await fits(page); await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click(); await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click(); await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
+3 -1
View File
@@ -34,7 +34,9 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click(); await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована'); await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText()); const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/); const shared = new URL(sharedUrl);
expect(shared.origin).toBe(new URL(page.url()).origin);
expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
+64 -3
View File
@@ -27,9 +27,9 @@ test('admin selects repository and version, confirms and sees persistent job log
await expect(dialog).toContainText('Тестовая репа'); await expect(dialog).toContainText('Тестовая репа');
await expect(dialog).toContainText('v1.10.0'); await expect(dialog).toContainText('v1.10.0');
await dialog.getByRole('button', { name: 'Установить обновление' }).click(); await dialog.getByRole('button', { name: 'Установить обновление' }).click();
await expect(page.getByRole('status').filter({ hasText: 'Выполняется' })).toBeVisible(); await expect(page.getByRole('status').filter({ hasText: /^Выполняется$/ })).toBeVisible();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart'); await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true }); await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
}); });
@@ -37,12 +37,15 @@ test('config keeps secret unchanged, saves local override and applies separately
await login(page, 'mobile-admin', '/admin/config'); await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue(''); await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug'); await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible(); await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6'); await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).toBeDisabled(); await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Сохранить', exact: true }).click(); await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible(); await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён'); await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible(); await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; }); await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
@@ -64,3 +67,61 @@ test('ordinary user is redirected away from server management', async ({ page })
await login(page, 'alice', '/admin/updates'); await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/'); await expect(page).toHaveURL('/');
}); });
test('installed main and release show no install action; another version can be installed', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'main', exact: true }).click();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
});
test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const request = new Request(input, init);
if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await original(input, init);
const data = await response.json();
return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
});
test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
const save = page.getByRole('button', { name: 'Сохранить', exact: true });
const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
await field.fill('6');
await expect(save).toBeVisible();
await field.clear();
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
await field.fill('7');
await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
await expect(save).not.toBeVisible();
await expect(field).toHaveValue('');
});
@@ -46,8 +46,13 @@ export function ConfigPage() {
}, },
}); });
const change = (key: string, value: string | null) => { const change = (key: string, value: string | null) => {
setBaseRevision((v) => v ?? query.data!.revision); const entry = query.data!.entries.find((v) => v.key === key)!;
setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset(); const unchanged = value === null ? entry.local === null
: entry.secret ? value === '' : value === (entry.local ?? '');
const next = { ...draft };
if (unchanged) delete next[key]; else next[key] = value;
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
setDraft(next); setSaved(false); save.reset();
}; };
if (query.isPending) return <PageSkeleton />; if (query.isPending) return <PageSkeleton />;
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? []; const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
@@ -71,10 +76,15 @@ export function ConfigPage() {
</section>; </section>;
})}</div> })}</div>
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>} {!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
<section className={s.saveBar}> {saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
<div role="status">{saved ? t('management.saved') : dirty ? t('management.unsaved', { count: Object.keys(draft).length }) : query.data.restart_required ? t('management.pending_restart') : t('management.no_changes')}</div> {dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
<div className={s.actions}><Button disabled={!dirty || save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={!dirty || running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button><Button disabled={dirty || running || !query.data.restart_required} busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button></div> <div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
</section> <div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
</section>}
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
<div role="status">{t('management.pending_restart')}</div>
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
</section>}
{save.error && <FormError>{errorText(save.error)}</FormError>} {save.error && <FormError>{errorText(save.error)}</FormError>}
{apply.error && <FormError>{errorText(apply.error)}</FormError>} {apply.error && <FormError>{errorText(apply.error)}</FormError>}
<JobPanel job={status.data?.job} reconnecting={status.isError} /> <JobPanel job={status.data?.job} reconnecting={status.isError} />
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
@@ -22,16 +22,26 @@ export function UpdatesPage() {
const [version, setVersion] = useState(''); const [version, setVersion] = useState('');
const [confirm, setConfirm] = useState(false); const [confirm, setConfirm] = useState(false);
const [editing, setEditing] = useState<Source[] | null>(null); const [editing, setEditing] = useState<Source[] | null>(null);
const fresh = useRef(false);
const queryKey = ['server-updates', sourceId, channel, version];
const query = useQuery({ const query = useQuery({
queryKey: ['server-updates', sourceId], queryKey,
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', { queryFn: async () => {
params: { query: { source_id: sourceId || undefined } }, const force = fresh.current;
})), fresh.current = false;
refetchInterval: 3000, return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
}));
},
placeholderData: keepPreviousData,
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
retry: false, retry: false,
}); });
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0]; const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === 'running'; const running = query.data?.job?.state === 'running';
const checking = query.isFetching;
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !running;
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] }); const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
const save = useMutation({ const save = useMutation({
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })), mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
@@ -39,18 +49,19 @@ export function UpdatesPage() {
}); });
const start = useMutation({ const start = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', { mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined }, body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined, expected_commit: query.data!.target_commit },
})), })),
onSuccess: (job) => { onSuccess: (job) => {
setConfirm(false); setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', sourceId], (old) => old ? { ...old, job } : old); qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
void refresh(); void refresh();
}, },
onError: () => { void refresh(); },
}); });
if (query.isPending) return <PageSkeleton />; if (query.isPending) return <PageSkeleton />;
return <div className={s.page}> return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div> <header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
<Button onClick={() => void query.refetch()} busy={query.isFetching}>{t('management.refresh')}</Button> <Button onClick={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</Button>
</header> </header>
{query.error && <FormError>{errorText(query.error)}</FormError>} {query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <> {query.data && <>
@@ -68,15 +79,21 @@ export function UpdatesPage() {
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>} {channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>} {query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
{start.error && <FormError>{errorText(start.error)}</FormError>} {start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button variant="primary" disabled={!source || running || !!query.data.source_error || (channel === 'version' && !version)} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button></div> <div className={s.updateAction}>
<div role="status" aria-live="polite">
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
</div>
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button>}
</div>
</section> </section>
<JobPanel job={query.data.job} reconnecting={query.isError} /> <JobPanel job={query.data.job} reconnecting={query.isError} />
</>} </>}
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}> {confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
<p>{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}</p> <p>{t('management.confirm_update_text', { source: source?.name, target: query.data?.target_version || version })}</p>
<p className={s.note}>{t('management.update_warning')}</p> <p className={s.note}>{t('management.update_warning')}</p>
{start.error && <FormError>{errorText(start.error)}</FormError>} {start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div> <div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" disabled={!canInstall} busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
</Dialog>} </Dialog>}
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}> {editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
<p className={s.note}>{t('management.sources_hint')}</p> <p className={s.note}>{t('management.sources_hint')}</p>
@@ -32,3 +32,5 @@
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; } .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; } .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; } } @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; } }
.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; }
+9 -1
View File
@@ -126,7 +126,14 @@ export function fakeApi(initial: FakeUser[]) {
if (!user) return problem(401, 'auth.unauthorized'); if (!user) return problem(401, 'auth.unauthorized');
const configs = path.includes('/config'); const configs = path.includes('/config');
if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden'); 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 === 'GET /api/v1/admin/server/updates') {
const channel = url.searchParams.get('channel') ?? 'latest';
const selected = url.searchParams.get('version') ?? '';
const target = channel === 'version' ? selected : channel === 'main' ? 'main' : 'v1.10.0';
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,
current_commit: 'a'.repeat(40), target_version: target, target_commit: target === 'v1.9.0' || target === 'main' ? 'a'.repeat(40) : 'b'.repeat(40),
update_state: !target ? 'select_version' : target === 'v1.9.0' || target === 'main' ? 'current' : 'available' });
}
if (route === 'PUT /api/v1/admin/server/update-sources') { managementSources = body?.sources as typeof managementSources; return json({ sources: managementSources }); } 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 === 'GET /api/v1/admin/server/config') return json(serverConfig());
if (route === 'PATCH /api/v1/admin/server/config') { if (route === 'PATCH /api/v1/admin/server/config') {
@@ -138,6 +145,7 @@ export function fakeApi(initial: FakeUser[]) {
} }
if (route === 'POST /api/v1/admin/server/updates' || route === 'POST /api/v1/admin/server/config/apply') { 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' }; 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' };
if (configs) configPending = false;
return json(managementJob, 202); return json(managementJob, 202);
} }
} }
+1 -1
View File
@@ -298,7 +298,7 @@
.shell, .shell.island { .shell, .shell.island {
--side-w: 0px; --side-w: 0px;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px)); grid-template-rows: auto minmax(0, 1fr) auto calc(64px + env(safe-area-inset-bottom, 0px));
grid-template-areas: 'top' 'content' 'player' 'mobile'; grid-template-areas: 'top' 'content' 'player' 'mobile';
} }
+13 -12
View File
@@ -413,28 +413,29 @@ a.source:hover {
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; } .bar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: 'now now' 'controls actions';
gap: 4px 8px;
min-height: 72px;
padding: 8px 12px;
}
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto; .bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
transform: none; margin: 0 8px; border-radius: 16px; } transform: none; margin: 0 8px; border-radius: 16px; }
.empty { grid-column: 1 / -1; } .empty { grid-column: 1 / -1; grid-row: 1 / -1; }
.placeholder { flex: none; } .placeholder { flex: none; }
.now { gap: 8px; } .now { grid-area: now; gap: 10px; }
.center { grid-area: controls; justify-self: start; }
.now > :first-child { width: 44px !important; height: 44px !important; } .now > :first-child { width: 44px !important; height: 44px !important; }
.source { display: none; } .source { display: none; }
.controls { gap: 2px; } .controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; } .play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; } .right { grid-area: actions; display: flex; gap: 0; }
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; } .right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px; .error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; } padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px)); .drawer, .drawerIsland { bottom: calc(184px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); } max-height: calc(100dvh - 220px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
.shareButton { display: none; }
} }
.shareStatus { .shareStatus {
+3 -3
View File
@@ -111,15 +111,15 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
> >
{narrow ? ( {narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}> <button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} /> <Cover album={track.album} size={44} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>} {st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button> </button>
) : ( ) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} /> <Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)} )}
<div className={s.meta}> <div className={s.meta}>
<span className={s.title}>{track.title}</span> <span className={s.title} title={track.title}>{track.title}</span>
<span className={s.artists}> <span className={s.artists} title={track.artists.map((artist) => artist.name).join(', ')}>
<ArtistLinks artists={track.artists} /> <ArtistLinks artists={track.artists} />
</span> </span>
{source && {source &&
+3 -1
View File
@@ -54,7 +54,9 @@ services:
context: .. context: ..
dockerfile: services/api/Dockerfile dockerfile: services/api/Dockerfile
target: api target: api
args: *version args:
<<: *version
REVISION: ${PIPU_COMMIT:-}
env_file: [config.local, .env] env_file: [config.local, .env]
environment: &db-env environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
+8
View File
@@ -414,5 +414,13 @@
"default_value": "3", "default_value": "3",
"secret": false, "secret": false,
"readonly": false "readonly": false
},
{
"key": "PIPU_COMMIT",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": false,
"readonly": true
} }
] ]
+100 -17
View File
@@ -147,6 +147,13 @@ class Agent:
def revision(self): def revision(self):
return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest() return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest()
def effective_revision(self, ignore_local=False):
env = read_env(self.deploy / ".env")
local = {} if ignore_local else read_env(self.deploy / "config.local")
effective = {item["key"]: env.get(item["key"], local.get(item["key"], item["default_value"]))
for item in self.schema() if not item["readonly"]}
return hashlib.sha256(json.dumps(effective, sort_keys=True).encode()).hexdigest()
def config(self): def config(self):
env = read_env(self.deploy / ".env") env = read_env(self.deploy / ".env")
local = read_env(self.deploy / "config.local") local = read_env(self.deploy / "config.local")
@@ -159,8 +166,84 @@ class Agent:
return "••••••••" if item["secret"] and value else value return "••••••••" if item["secret"] and value else value
entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)), entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)),
"effective": display(effective), "source": source}) "effective": display(effective), "source": source})
effective = self.effective_revision()
applied = self.load("applied-effective.json", None)
if applied is None and (not local or self.load("applied.json", None) == self.revision()):
self.save("applied-effective.json", effective)
applied = effective
# Changes shadowed by .env do not change the running configuration.
empty = self.effective_revision(ignore_local=True)
return {"entries": entries, "revision": self.revision(), return {"entries": entries, "revision": self.revision(),
"restart_required": self.load("applied.json", "") != self.revision()} "restart_required": effective != (applied or empty)}
def installed_commit(self, body):
"""Trust the running API's build identity, never the checkout or .env.
The checkout can already point at a failed update. Legacy builds contain
git-describe/main hashes or a release tag, which we resolve locally.
"""
revision = body.get("_running_commit", "")
if re.fullmatch(r"[a-f0-9]{40,64}", revision):
return revision
version = body.get("_running_version", "")
match = re.fullmatch(r"(?:.*-g|main-)([a-f0-9]{7,64})", version)
candidates = [match[1]] if match else []
if version_key(version):
candidates = ["refs/tags/v" + version.removeprefix("v"), "refs/tags/" + version.removeprefix("v")]
for candidate in candidates:
try:
commit = self.command(["git", "rev-parse", "--verify", candidate + "^{commit}"]).strip()
if re.fullmatch(r"[a-f0-9]{40,64}", commit):
return commit
except (subprocess.SubprocessError, OSError):
pass
return ""
def update_target(self, refs, body):
channel = body.get("channel") or "latest"
if channel not in ("latest", "version", "main"):
raise ManagementError("management.target_invalid")
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:
return "", ""
target = max(tags, key=version_key)
else:
target = "main" if channel == "main" else body.get("version") or ""
if not target:
return "", ""
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 re.fullmatch(r"[a-f0-9]{40,64}", commit):
return target, ""
return target, commit
def update_state(self, target, commit, current, body):
if body.get("channel") == "version" and not body.get("version"):
return "select_version"
if not commit:
return "unavailable"
if not current:
return "unknown"
if current == commit:
return "current"
try:
self.command(["git", "merge-base", "--is-ancestor", commit, current])
return "ahead"
except (subprocess.SubprocessError, OSError):
# A selected source's commit may not have been fetched locally yet.
pass
# A stable tag behind a git-describe build is not a newer release.
running = body.get("_running_version", "")
base = re.match(r"v?(\d+\.\d+\.\d+)(?:-rc\.\d+)?", running)
target_key = version_key(target)
if target_key and base:
running_key = version_key(base[0])
if target_key[:3] < running_key[:3] or (target_key[:3] == running_key[:3] and re.search(r"-\d+-g[0-9a-f]+$", running)):
return "ahead"
return "available"
def save_config(self, body): def save_config(self, body):
if self.job and self.job["state"] == "running": if self.job and self.job["state"] == "running":
@@ -233,6 +316,7 @@ class Agent:
return dict(job) return dict(job)
def run_job(self, job, command, extra_env, revision): def run_job(self, job, command, extra_env, revision):
effective_revision = self.effective_revision()
# Let the API deliver the accepted job before recreating its container. # Let the API deliver the accepted job before recreating its container.
time.sleep(2) time.sleep(2)
# Capture full output privately, expose only a bounded redacted tail. # Capture full output privately, expose only a bounded redacted tail.
@@ -267,6 +351,7 @@ class Agent:
job["log"] = redact(output.read().decode("utf-8", errors="replace")) job["log"] = redact(output.read().decode("utf-8", errors="replace"))
if result == 0: if result == 0:
self.save("applied.json", revision) self.save("applied.json", revision)
self.save("applied-effective.json", effective_revision)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
result = 1 result = 1
job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management." job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management."
@@ -304,34 +389,32 @@ class Agent:
if not selected: if not selected:
raise ManagementError("management.source_invalid") raise ManagementError("management.source_invalid")
if op == "updates.get": if op == "updates.get":
versions, error = [], "" versions, error, refs = [], "", {}
try: try:
refs = self.refs(selected["url"]) refs = self.refs(selected["url"], fresh=bool(body.get("fresh")))
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) 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): except (subprocess.SubprocessError, OSError):
error = "management.source_unavailable" error = "management.source_unavailable"
current = self.installed_commit(body)
target, commit = self.update_target(refs, body)
return {"sources": sources, "versions": versions, "source_error": error, return {"sources": sources, "versions": versions, "source_error": error,
"current_commit": current, "target_version": target, "target_commit": commit,
"update_state": self.update_state(target, commit, current, body),
"current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job} "current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job}
if op == "updates.start": if op == "updates.start":
if self.job and self.job["state"] == "running": if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409) 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) refs = self.refs(selected["url"], fresh=True)
if channel == "latest": target, commit = self.update_target(refs, body)
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 ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
commit = refs.get(ref + "^{}", refs.get(ref)) if not commit:
if not commit or not re.fullmatch(r"[a-f0-9]{40,64}", commit):
raise ManagementError("management.target_invalid") raise ManagementError("management.target_invalid")
state = self.update_state(target, commit, self.installed_commit(body), body)
if state != "available":
code = {"current": "management.already_installed", "ahead": "management.downgrade", "unknown": "management.version_unknown"}.get(state, "management.target_invalid")
raise ManagementError(code, 409)
if body.get("expected_commit") and body["expected_commit"] != commit:
raise ManagementError("management.target_changed", 409)
return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target], return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target],
{"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit, {"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit,
"PIPU_UPDATE_REF": ref}) "PIPU_UPDATE_REF": ref})
+2 -1
View File
@@ -116,7 +116,7 @@ class DeploymentTests(unittest.TestCase):
target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n") target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n")
self.env["TARGET_INSTALLER"] = target.as_posix() self.env["TARGET_INSTALLER"] = target.as_posix()
(self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8") (self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8")
self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; esac\n') self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; rev-parse) echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;; esac\n')
self.stub("docker", 'echo "docker $*" >> "$TRACE"\n') self.stub("docker", 'echo "docker $*" >> "$TRACE"\n')
def test_update_installs_target_revision_worker_before_container_operations(self): def test_update_installs_target_revision_worker_before_container_operations(self):
@@ -127,6 +127,7 @@ class DeploymentTests(unittest.TestCase):
self.assertLess(trace.index("git checkout"), trace.index("setup --ensure")) self.assertLess(trace.index("git checkout"), trace.index("setup --ensure"))
self.assertLess(trace.index("setup --ensure"), trace.index("docker compose")) self.assertLess(trace.index("setup --ensure"), trace.index("docker compose"))
self.assertIn("up -d --remove-orphans", trace) self.assertIn("up -d --remove-orphans", trace)
self.assertIn("PIPU_COMMIT=" + "a" * 40, (self.deploy / ".env").read_text())
def test_failed_host_setup_stops_update_before_container_operations(self): def test_failed_host_setup_stops_update_before_container_operations(self):
self.update_fixture(install_exit=1) self.update_fixture(install_exit=1)
+57
View File
@@ -33,8 +33,65 @@ class AgentTests(unittest.TestCase):
(self.deploy / "config.local").write_text("", 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.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 = Agent(self.deploy, self.mailbox, self.private)
self.running_commit = "0" * 40
self.agent.installed_commit = lambda body: body.get("_running_commit", self.running_commit)
self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}]) self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
def test_unchanged_configuration_needs_no_restart_even_without_saved_baseline(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"LOG_LEVEL": "debug"}})
self.assertFalse(result["restart_required"], "LOG_LEVEL is overridden by .env")
def test_returning_to_applied_values_removes_pending_restart(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
changed = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
self.assertTrue(changed["restart_required"])
restored = self.agent.save_config({"revision": changed["revision"], "values": {"TRAIN_HOUR": None}})
self.assertFalse(restored["restart_required"])
def test_explicit_default_value_does_not_require_a_restart(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "3"}})
self.assertFalse(result["restart_required"])
def test_update_check_and_start_both_reject_installed_main_commit(self):
refs = {"refs/heads/main": self.running_commit}
body = {"source_id": "origin", "channel": "main"}
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job") as start:
self.assertEqual("current", self.agent.handle("updates.get", body)["update_state"])
with self.assertRaises(ManagementError) as error:
self.agent.handle("updates.start", body)
self.assertEqual("management.already_installed", error.exception.code)
start.assert_not_called()
def test_check_distinguishes_missing_selection_and_changed_target(self):
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job") as start:
self.assertEqual("select_version", self.agent.handle("updates.get", {"channel": "version"})["update_state"])
with self.assertRaises(ManagementError) as error:
self.agent.handle("updates.start", {"channel": "main", "expected_commit": "b" * 40})
self.assertEqual("management.target_changed", error.exception.code)
start.assert_not_called()
def test_unknown_installed_commit_and_older_release_cannot_be_installed(self):
with patch.object(self.agent, "refs", return_value={"refs/tags/v1.8.0": "a" * 40}), patch.object(self.agent, "start_job") as start:
for body, state in [({"_running_commit": ""}, "unknown"), ({"_running_version": "1.9.0-1-gbc55528"}, "ahead")]:
self.assertEqual(state, self.agent.handle("updates.get", body)["update_state"])
with self.assertRaises(ManagementError):
self.agent.handle("updates.start", body)
start.assert_not_called()
def test_installed_identity_uses_running_build_and_resolves_legacy_describe(self):
with patch.object(self.agent, "command", return_value="a" * 40) as command:
self.assertEqual("b" * 40, Agent.installed_commit(self.agent, {"_running_commit": "b" * 40}))
command.assert_not_called()
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "1.9.0-1-gbc55528"}))
self.assertEqual("bc55528^{commit}", command.call_args.args[0][-1])
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "main-bc55528"}))
self.assertEqual("", Agent.installed_commit(self.agent, {"_running_version": "0.0.0-dev"}))
def test_env_priority_and_secrets_never_returned(self): 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") (self.deploy / "config.local").write_text("LOG_LEVEL=debug\nTRAIN_HOUR=5\n", encoding="utf-8")
config = self.agent.config() config = self.agent.config()
+8
View File
@@ -97,6 +97,7 @@ fi
# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему. # Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему.
CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)" CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)"
CURRENT_COMMIT="$(sed -n 's/^PIPU_COMMIT=//p' .env)"
TARGET="${REF#v}" TARGET="${REF#v}"
core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; } core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; }
cur_core="$(core "$CURRENT" || true)" cur_core="$(core "$CURRENT" || true)"
@@ -136,6 +137,12 @@ else
VERSION="$(git describe --tags --always --dirty)" VERSION="$(git describe --tags --always --dirty)"
fi fi
VERSION="${VERSION#v}" VERSION="${VERSION#v}"
COMMIT="$(git rev-parse HEAD)"
if grep -q '^PIPU_COMMIT=' .env; then
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$COMMIT|" .env
else
echo "PIPU_COMMIT=$COMMIT" >> .env
fi
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)" echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
@@ -143,6 +150,7 @@ compose config --quiet
if ! compose build --pull; then if ! compose build --pull; then
# A build failure leaves running containers untouched; keep their version in .env. # A build failure leaves running containers untouched; keep their version in .env.
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$CURRENT_COMMIT|" .env
echo 'Build failed; running containers were not replaced' >&2 echo 'Build failed; running containers were not replaced' >&2
exit 1 exit 1
fi fi
+14
View File
@@ -76,6 +76,14 @@ cd /opt/pipu-music/deploy && sudo bash ./install-management.sh
* **Выбрать версию** — конкретный релизный тег, включая release candidate. * **Выбрать версию** — конкретный релизный тег, включая release candidate.
* **main** — текущий commit ветки main. * **main** — текущий commit ветки main.
Кнопка установки появляется только если выбранный commit отличается от работающего
и переход не является откатом. Для `main` сравнивается SHA, для релиза — SHA тега.
Одинаковая версия, более новая установленная сборка, ошибка проверки и неопределённый
commit не предлагают установку. «Проверить обновления» обходит кэш refs.
Работающий API передаёт свою встроенную revision; для прежних сборок агент разрешает
hash из git-describe или локальный релизный тег. HEAD checkout и PIPU_VERSION в .env
не считаются доказательством установленного commit, поскольку сборка могла не удаться.
Обновления устанавливаются только по нажатию кнопки и после подтверждения. Обновления устанавливаются только по нажатию кнопки и после подтверждения.
Перед запуском агент повторно читает refs и фиксирует SHA; update.sh проверяет, Перед запуском агент повторно читает refs и фиксирует SHA; update.sh проверяет,
что полученный commit совпадает. Выбранный источник не перезаписывает origin что полученный commit совпадает. Выбранный источник не перезаписывает origin
@@ -125,6 +133,12 @@ API, общие параметры сервисов, рекомендатель
и изменение служебных параметров отклоняются. Сохраняемые значения трактуются и изменение служебных параметров отклоняются. Сохраняемые значения трактуются
буквально: подстановки shell не выполняются. буквально: подстановки shell не выполняются.
Панель сохранения появляется только при отличиях от исходных локальных значений.
После возврата прежних значений она исчезает. Сохранённые изменения, влияющие на
итоговую конфигурацию, показывают отдельное действие применения. Пустой config.local,
комментарии, явное значение по умолчанию или значение, перекрытое .env, сами по себе
не требуют перезапуска.
Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров. Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров.
Агент запускает `apply-config.sh`, ждёт `/readyz` и сохраняет результат. Если сервисы Агент запускает `apply-config.sh`, ждёт `/readyz` и сохраняет результат. Если сервисы
не стали готовы, состояние — ошибка; исправьте config.local или восстановите его не стали готовы, состояние — ошибка; исправьте config.local или восстановите его
+5
View File
@@ -113,3 +113,8 @@ apps/web/src/
элементы); прогресс-бары подписаны через `aria-labelledby`; элементы); прогресс-бары подписаны через `aria-labelledby`;
- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку; - выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку;
- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена. - `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена.
На экранах уже 768 px веб-плеер использует два ряда: обложка, название и исполнители
занимают верхний ряд целиком, управление и дополнительные действия — нижний.
Высота строки плеера в каркасе адаптируется к содержимому для вариантов bar и island.
@@ -1586,6 +1586,11 @@ export interface components {
sources: components["schemas"]["ServerSource"][]; sources: components["schemas"]["ServerSource"][];
versions: string[]; versions: string[];
current_version: string; current_version: string;
current_commit: string;
target_version: string;
target_commit: string;
/** @enum {string} */
update_state: "available" | "current" | "ahead" | "unknown" | "unavailable" | "select_version";
source_error: string; source_error: string;
job: components["schemas"]["ServerJob"] | null; job: components["schemas"]["ServerJob"] | null;
}; };
@@ -1594,6 +1599,7 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
channel: "latest" | "version" | "main"; channel: "latest" | "version" | "main";
version?: string; version?: string;
expected_commit?: string;
}; };
ServerConfigEntry: { ServerConfigEntry: {
key: string; key: string;
@@ -2695,6 +2701,9 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
source_id?: string; source_id?: string;
channel?: "latest" | "version" | "main";
version?: string;
fresh?: boolean;
}; };
header?: never; header?: never;
path?: never; path?: never;
+13 -1
View File
@@ -461,6 +461,18 @@
"no_changes": "No changes to apply", "no_changes": "No changes to apply",
"discard": "Discard edits", "discard": "Discard edits",
"apply": "Apply and restart", "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." "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.",
"check_updates": "Check for updates",
"unsaved_panel": "Unsaved changes",
"availability": {
"checking": "Checking the selected version…",
"running": "Update in progress",
"available": "Update available",
"current": "This version is already installed — no updates",
"ahead": "A newer build is already installed",
"unknown": "Could not identify the installed commit. Update the server manually.",
"unavailable": "The selected version is unavailable",
"select_version": "Choose a version to check"
}
} }
} }
+5 -1
View File
@@ -116,6 +116,10 @@
"agent_error": "The agent could not complete the operation. Check journalctl -u pipu-management.", "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_expired": "The request expired. Check operation status before retrying.",
"request_invalid": "Invalid server management request.", "request_invalid": "Invalid server management request.",
"operation_invalid": "Unsupported server management operation." "operation_invalid": "Unsupported server management operation.",
"already_installed": "This version is already installed. No update is needed.",
"downgrade": "A newer build is installed. Downgrades require a manual rollback.",
"version_unknown": "Could not identify the running server commit. Update it manually.",
"target_changed": "The source changed after checking. Check for updates again."
} }
} }
+13 -1
View File
@@ -461,6 +461,18 @@
"no_changes": "Нет изменений для применения", "no_changes": "Нет изменений для применения",
"discard": "Отменить правки", "discard": "Отменить правки",
"apply": "Применить и перезапустить", "apply": "Применить и перезапустить",
"apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет." "apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет.",
"check_updates": "Проверить обновления",
"unsaved_panel": "Несохранённые изменения",
"availability": {
"checking": "Проверяем выбранную версию…",
"running": "Обновление выполняется",
"available": "Доступно обновление",
"current": "Эта версия уже установлена — обновлений нет",
"ahead": "Установлена более новая сборка",
"unknown": "Не удалось определить установленный commit. Обновите сервер вручную.",
"unavailable": "Выбранная версия недоступна",
"select_version": "Выберите версию для проверки"
}
} }
} }
+5 -1
View File
@@ -116,6 +116,10 @@
"agent_error": "Агент не смог выполнить операцию. Проверьте journalctl -u pipu-management.", "agent_error": "Агент не смог выполнить операцию. Проверьте journalctl -u pipu-management.",
"request_expired": "Запрос устарел. Проверьте статус перед повтором.", "request_expired": "Запрос устарел. Проверьте статус перед повтором.",
"request_invalid": "Недопустимый запрос управления сервером.", "request_invalid": "Недопустимый запрос управления сервером.",
"operation_invalid": "Операция управления сервером не поддерживается." "operation_invalid": "Операция управления сервером не поддерживается.",
"already_installed": "Эта версия уже установлена. Обновление не требуется.",
"downgrade": "Установлена более новая сборка. Откат выполняется вручную.",
"version_unknown": "Не удалось определить commit работающего сервера. Обновите его вручную.",
"target_changed": "Источник изменился после проверки. Проверьте обновления ещё раз."
} }
} }
+3 -2
View File
@@ -9,10 +9,11 @@ COPY services/api/go.mod services/api/go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY services/api/ ./ COPY services/api/ ./
ARG VERSION=0.0.0-dev ARG VERSION=0.0.0-dev
ARG REVISION=
ENV CGO_ENABLED=0 ENV CGO_ENABLED=0
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \ go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION} -X pipu/api/internal/buildinfo.Revision=${REVISION}" -o /out/api ./cmd/api && \
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION} -X pipu/api/internal/buildinfo.Revision=${REVISION}" -o /out/storage-agent ./cmd/storage-agent
# api вызывает ffprobe (сканер), ffmpeg (транскодинг, M3) и fpcalc (отпечатки, M5.5) — нужен не distroless, а alpine. # api вызывает ffprobe (сканер), ffmpeg (транскодинг, M3) и fpcalc (отпечатки, M5.5) — нужен не distroless, а alpine.
FROM alpine:3.22 AS api FROM alpine:3.22 AS api
+380 -322
View File
@@ -159,9 +159,19 @@ const (
// Defines values for ServerUpdateRequestChannel. // Defines values for ServerUpdateRequestChannel.
const ( const (
Latest ServerUpdateRequestChannel = "latest" ServerUpdateRequestChannelLatest ServerUpdateRequestChannel = "latest"
Main ServerUpdateRequestChannel = "main" ServerUpdateRequestChannelMain ServerUpdateRequestChannel = "main"
Version ServerUpdateRequestChannel = "version" ServerUpdateRequestChannelVersion ServerUpdateRequestChannel = "version"
)
// Defines values for ServerUpdatesUpdateState.
const (
Ahead ServerUpdatesUpdateState = "ahead"
Available ServerUpdatesUpdateState = "available"
Current ServerUpdatesUpdateState = "current"
SelectVersion ServerUpdatesUpdateState = "select_version"
Unavailable ServerUpdatesUpdateState = "unavailable"
Unknown ServerUpdatesUpdateState = "unknown"
) )
// Defines values for SourceStatus. // Defines values for SourceStatus.
@@ -285,6 +295,13 @@ const (
Start ControlGeniusJSONBodyAction = "start" Start ControlGeniusJSONBodyAction = "start"
) )
// Defines values for GetServerUpdatesParamsChannel.
const (
GetServerUpdatesParamsChannelLatest GetServerUpdatesParamsChannel = "latest"
GetServerUpdatesParamsChannelMain GetServerUpdatesParamsChannel = "main"
GetServerUpdatesParamsChannelVersion GetServerUpdatesParamsChannel = "version"
)
// Defines values for GetCoverParamsSize. // Defines values for GetCoverParamsSize.
const ( const (
N1200 GetCoverParamsSize = 1200 N1200 GetCoverParamsSize = 1200
@@ -1050,9 +1067,10 @@ type ServerSources struct {
// ServerUpdateRequest defines model for ServerUpdateRequest. // ServerUpdateRequest defines model for ServerUpdateRequest.
type ServerUpdateRequest struct { type ServerUpdateRequest struct {
Channel ServerUpdateRequestChannel `json:"channel"` Channel ServerUpdateRequestChannel `json:"channel"`
SourceId string `json:"source_id"` ExpectedCommit *string `json:"expected_commit,omitempty"`
Version *string `json:"version,omitempty"` SourceId string `json:"source_id"`
Version *string `json:"version,omitempty"`
} }
// ServerUpdateRequestChannel defines model for ServerUpdateRequest.Channel. // ServerUpdateRequestChannel defines model for ServerUpdateRequest.Channel.
@@ -1060,13 +1078,20 @@ type ServerUpdateRequestChannel string
// ServerUpdates defines model for ServerUpdates. // ServerUpdates defines model for ServerUpdates.
type ServerUpdates struct { type ServerUpdates struct {
CurrentVersion string `json:"current_version"` CurrentCommit string `json:"current_commit"`
Job *ServerJob `json:"job"` CurrentVersion string `json:"current_version"`
SourceError string `json:"source_error"` Job *ServerJob `json:"job"`
Sources []ServerSource `json:"sources"` SourceError string `json:"source_error"`
Versions []string `json:"versions"` Sources []ServerSource `json:"sources"`
TargetCommit string `json:"target_commit"`
TargetVersion string `json:"target_version"`
UpdateState ServerUpdatesUpdateState `json:"update_state"`
Versions []string `json:"versions"`
} }
// ServerUpdatesUpdateState defines model for ServerUpdates.UpdateState.
type ServerUpdatesUpdateState string
// Session defines model for Session. // Session defines model for Session.
type Session struct { type Session struct {
ClientType ClientType `json:"client_type"` ClientType ClientType `json:"client_type"`
@@ -1693,9 +1718,15 @@ type ListHostDirsParams struct {
// GetServerUpdatesParams defines parameters for GetServerUpdates. // GetServerUpdatesParams defines parameters for GetServerUpdates.
type GetServerUpdatesParams struct { type GetServerUpdatesParams struct {
SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"` SourceId *string `form:"source_id,omitempty" json:"source_id,omitempty"`
Channel *GetServerUpdatesParamsChannel `form:"channel,omitempty" json:"channel,omitempty"`
Version *string `form:"version,omitempty" json:"version,omitempty"`
Fresh *bool `form:"fresh,omitempty" json:"fresh,omitempty"`
} }
// GetServerUpdatesParamsChannel defines parameters for GetServerUpdates.
type GetServerUpdatesParamsChannel string
// ListUploadsParams defines parameters for ListUploads. // ListUploadsParams defines parameters for ListUploads.
type ListUploadsParams struct { type ListUploadsParams struct {
// Status Статусы через запятую // Status Статусы через запятую
@@ -3165,6 +3196,30 @@ func (siw *ServerInterfaceWrapper) GetServerUpdates(w http.ResponseWriter, r *ht
return return
} }
// ------------- Optional query parameter "channel" -------------
err = runtime.BindQueryParameter("form", true, false, "channel", r.URL.Query(), &params.Channel)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "channel", Err: err})
return
}
// ------------- Optional query parameter "version" -------------
err = runtime.BindQueryParameter("form", true, false, "version", r.URL.Query(), &params.Version)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "version", Err: err})
return
}
// ------------- Optional query parameter "fresh" -------------
err = runtime.BindQueryParameter("form", true, false, "fresh", r.URL.Query(), &params.Fresh)
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "fresh", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetServerUpdates(w, r, params) siw.Handler.GetServerUpdates(w, r, params)
})) }))
@@ -12076,316 +12131,319 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"EnykT0FYyFPodhv3bt/7XwEAAP//fKFn+ZSZAQA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
@@ -4,3 +4,6 @@ package buildinfo
// Version — версия продукта (SemVer). В релизной сборке: // Version — версия продукта (SemVer). В релизной сборке:
// go build -ldflags "-X pipu/api/internal/buildinfo.Version=1.2.3" // go build -ldflags "-X pipu/api/internal/buildinfo.Version=1.2.3"
var Version = "0.0.0-dev" var Version = "0.0.0-dev"
// Revision is the full source commit embedded by the deployment build.
var Revision = ""
+7 -2
View File
@@ -112,7 +112,9 @@ func (h *Handler) call(ctx context.Context, admin bool, operation string, body,
func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) { func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) {
var out gen.ServerUpdates var out gen.ServerUpdates
err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId}, &out) err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId,
"channel": req.Params.Channel, "version": req.Params.Version, "fresh": req.Params.Fresh,
"_running_version": buildinfo.Version, "_running_commit": buildinfo.Revision}, &out)
out.CurrentVersion = buildinfo.Version out.CurrentVersion = buildinfo.Version
return gen.GetServerUpdates200JSONResponse(out), err return gen.GetServerUpdates200JSONResponse(out), err
} }
@@ -125,7 +127,10 @@ func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServe
func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) { func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) {
var out gen.ServerJob var out gen.ServerJob
err := h.call(ctx, false, "updates.start", req.Body, &out) err := h.call(ctx, false, "updates.start", map[string]any{
"source_id": req.Body.SourceId, "channel": req.Body.Channel, "version": req.Body.Version,
"expected_commit": req.Body.ExpectedCommit, "_running_version": buildinfo.Version, "_running_commit": buildinfo.Revision,
}, &out)
return gen.StartServerUpdate202JSONResponse(out), err return gen.StartServerUpdate202JSONResponse(out), err
} }
@@ -13,17 +13,46 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"pipu/api/internal/auth" "pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/management" "pipu/api/internal/management"
"pipu/api/internal/server" "pipu/api/internal/server"
) )
type bridge struct{ calls int } type bridge struct {
calls int
body any
}
func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error { func (b *bridge) Call(_ context.Context, _, _ string, body, out any) error {
b.calls++ b.calls++
b.body = body
return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out) return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out)
} }
func TestUpdatesUseRunningBuildIdentity(t *testing.T) {
b := &bridge{}
router := server.NewRouter(server.Deps{Management: management.NewHandler(b)})
for _, method := range []string{"GET", "POST"} {
body := `{"source_id":"origin","channel":"main","_running_commit":"forged","_running_version":"forged"}`
request := httptest.NewRequest(method, "/api/v1/admin/server/updates?channel=main&fresh=true", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request = request.WithContext(auth.WithPrincipal(request.Context(), auth.Principal{Role: auth.RoleAdmin}))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, request)
require.Less(t, rec.Code, 300, rec.Body.String())
payload, err := json.Marshal(b.body)
require.NoError(t, err)
var values map[string]any
require.NoError(t, json.Unmarshal(payload, &values))
require.Equal(t, buildinfo.Version, values["_running_version"])
require.Equal(t, buildinfo.Revision, values["_running_commit"])
require.Equal(t, "main", values["channel"])
if method == "GET" {
require.Equal(t, true, values["fresh"])
}
}
}
func TestServerManagementAuthorization(t *testing.T) { func TestServerManagementAuthorization(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
name string name string