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

@@ -925,6 +925,17 @@
"autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
"autoUpdateToggled": "Auto-Update-Einstellung gespeichert.",
"autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen",
"updateChannelCardTitle": "Update-Kanal",
"updateChannelStable": "Stable",
"updateChannelTesting": "Testing",
"updateChannelApply": "Anwenden",
"updateChannelConfirmTitle": "Update-Kanal wechseln?",
"updateChannelSwitchTestingWarn": "Testing kann instabile Zwischenstände enthalten. Beide Nodes werden umgestellt.",
"updateChannelSwitchStableWarn": "Wechsel zurück nach Stable kann ein Downgrade auf beiden Nodes auslösen (Testing-Versionen sind neuer datiert).",
"updateChannelSaved": "Update-Kanal gespeichert (beide Nodes).",
"updateChannelFailed": "Update-Kanal-Wechsel fehlgeschlagen",
"updateChannelDrift": "Kanal-Drift zum Peer-Node erkannt (Peer: {{peer}}) — beim nächsten Wechsel wird synchronisiert.",
"updateChannelHint": "Testing zieht datumsbasierte Zwischenversionen aus dem Testing-Repo, Stable die kuratierten Releases. Kanal gilt für beide HA-Nodes synchron.",
"ipv6CardTitle": "IPv6",
"ipv6On": "Aktiviert — HAProxy bindet zusätzlich zu IPv4 auf [::]:80, [::]:443 und [::]:3443.",
"ipv6Off": "Deaktiviert — HAProxy lauscht nur auf IPv4.",

View File

@@ -925,6 +925,17 @@
"autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
"autoUpdateToggled": "Auto-update setting saved.",
"autoUpdateFailed": "Auto-update toggle failed",
"updateChannelCardTitle": "Update channel",
"updateChannelStable": "Stable",
"updateChannelTesting": "Testing",
"updateChannelApply": "Apply",
"updateChannelConfirmTitle": "Switch update channel?",
"updateChannelSwitchTestingWarn": "Testing may contain unstable interim builds. Both nodes will be switched.",
"updateChannelSwitchStableWarn": "Switching back to stable may trigger a downgrade on both nodes (testing versions are dated newer).",
"updateChannelSaved": "Update channel saved (both nodes).",
"updateChannelFailed": "Update channel switch failed",
"updateChannelDrift": "Channel drift detected against the peer node (peer: {{peer}}) — will sync on the next switch.",
"updateChannelHint": "Testing pulls date-stamped interim builds from the testing repo, stable pulls curated releases. The channel applies to both HA nodes in sync.",
"ipv6CardTitle": "IPv6",
"ipv6On": "Enabled — HAProxy binds on [::]:80, [::]:443 and [::]:3443 in addition to IPv4.",
"ipv6Off": "Disabled — HAProxy listens on IPv4 only.",

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"