feat(update): Testing/Stable-Update-Kanäle wie enconf (Suite=trixie, Komponente=Kanal)

Installer (EDGEGUARD_CHANNEL), Kanal-Lesen/-Schreiben ohne DB-State
(sources.list ist Quelle der Wahrheit), Cluster-Endpoints für Kanalwechsel
mit mTLS-Peer-Propagation + Drift-Erkennung, --allow-downgrades für
testing→stable-Downgrades über den bestehenden sicheren Rolling-Update-
Flow, Settings-UI mit Bestätigung, neues scripts/release.sh (Testing-Push
datumsbasiert YYYY.MM.DD.NN, Stable-Promotion mit Verify-Gate + Git-Tag),
publish.sh/cleanup-old.sh kanalfähig mit Stable-Tag-Schutz.

Migriert Bestandsnodes automatisch von der alten "main"-Komponente auf
"stable" (postinst, idempotent) — ohne das würden vor diesem Release
installierte Nodes stillschweigend keine Updates mehr sehen, sobald
main nicht mehr bespielt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-02 16:31:52 +02:00
parent 7ff6575790
commit bab82f8d5b
13 changed files with 571 additions and 33 deletions

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
import { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { ApartmentOutlined, BranchesOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -342,6 +342,30 @@ export default function SettingsPage() {
},
})
const [channelDraft, setChannelDraft] = useState<string | null>(null)
const { data: updateChannel } = useQuery({
queryKey: ['cluster', 'update-channel'],
queryFn: async () => {
const r = await apiClient.get('/cluster/update-channel')
return isEnvelope(r.data)
? (r.data.data as { channel: string; peer_channel?: string; peer_reached: boolean; peer_drifted: boolean })
: { channel: 'stable', peer_reached: false, peer_drifted: false }
},
})
const setUpdateChannel = useMutation({
mutationFn: async (channel: string) => {
const r = await apiClient.post('/cluster/update-channel', { channel })
return r.data
},
onSuccess: () => {
msg.success(t('settings.updateChannelSaved'))
void qc.invalidateQueries({ queryKey: ['cluster', 'update-channel'] })
void qc.invalidateQueries({ queryKey: ['system', 'package-versions'] })
},
onError: (e: Error) => msg.error(t('settings.updateChannelFailed') + ': ' + e.message),
onSettled: () => setChannelDraft(null),
})
const { data: ipv6 } = useQuery({
queryKey: ['system', 'ipv6'],
queryFn: async () => {
@@ -765,6 +789,55 @@ export default function SettingsPage() {
</Space>
</Card>
<Card
title={<><BranchesOutlined /> {t('settings.updateChannelCardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<Select
value={channelDraft ?? updateChannel?.channel ?? 'stable'}
style={{ width: 160 }}
disabled={isViewer}
options={[
{ value: 'stable', label: t('settings.updateChannelStable') },
{ value: 'testing', label: t('settings.updateChannelTesting') },
]}
onChange={setChannelDraft}
/>
{channelDraft && channelDraft !== (updateChannel?.channel ?? 'stable') && (
<Popconfirm
title={t('settings.updateChannelConfirmTitle')}
description={
channelDraft === 'testing'
? t('settings.updateChannelSwitchTestingWarn')
: t('settings.updateChannelSwitchStableWarn')
}
okText={t('settings.updateChannelApply')}
cancelText={t('common.cancel')}
onConfirm={() => setUpdateChannel.mutate(channelDraft)}
onCancel={() => setChannelDraft(null)}
>
<Button size="small" type="primary" loading={setUpdateChannel.isPending}>
{t('settings.updateChannelApply')}
</Button>
</Popconfirm>
)}
</Space>
{updateChannel?.peer_drifted && (
<Alert
type="warning"
showIcon
message={t('settings.updateChannelDrift', { peer: updateChannel?.peer_channel ?? '?' })}
/>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.updateChannelHint')}
</Typography.Text>
</Space>
</Card>
<Card
title={<><GlobalOutlined /> {t('settings.ipv6CardTitle')}</>}
className="mb-12"