feat(cluster): Rolling Update — Secondary-first upgrade orchestration (v1.2.3)

POST /cluster/rolling-update startet den gestaffelten Upgrade-Prozess:
1. Secondary via mTLS /agent/cluster/trigger-update anstoßen
2. /agent/cluster/version pollen bis Secondary Version-Flip zeigt (max 10 min)
3. Primary self-upgrade via systemd-run (identisch zu /system/upgrade)

State wird in /var/lib/edgeguard/rolling-update-state.json persistiert:
Phasen: updating-secondary → waiting-secondary → updating-primary.
"done" wird nicht geschrieben — Prozess stirbt beim Upgrade. UI erkennt
Abschluss via /system/health version-flip (analog Single-Node-Upgrade).

UI: UpdateBanner erkennt Cluster-Modus (/cluster/status mode="cluster")
und tauscht den "Install now"-Button gegen "Rolling Update (Cluster)" aus.
Multi-Step-Modal zeigt die drei Phasen; ab updating-primary wechselt der
Client auf /system/health polling.

Aggregator.PostPeer: neuer einzel-POST-Helper für mTLS-trigger-update.
WithVersion(): ClusterHandler bekommt Binary-Version für /agent/cluster/version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 23:40:49 +02:00
parent 25c7cd0cb5
commit bc6db1fc2b
9 changed files with 824 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,6 +20,19 @@ interface SystemHealth { status: string; version: string }
interface PendingUpdate { pkg: string; installed: string; available: string }
interface ClusterStatus {
mode: string // "single-node" | "cluster"
peers: Array<{ id: string; fqdn: string }>
}
interface RollingUpdateState {
phase: string // idle | updating-secondary | waiting-secondary | updating-primary | failed
secondary_fqdn: string
secondary_id: string
error?: string
updated_at: string
}
// allUpdates parsed das flache map-Format ({pkg_installed,pkg_available})
// das /system/package-versions zurückliefert. Eines davon ist meist
// das meta-Paket "edgeguard" → die "Ziel-Version".
@@ -53,6 +66,41 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
gcTime: 0,
})
const clusterStatus = useQuery({
queryKey: ['cluster', 'status-update-banner'],
queryFn: async () => {
try {
const r = await apiClient.get('/cluster/status')
return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null
} catch {
return null
}
},
refetchInterval: 60_000,
staleTime: 30_000,
})
const rollingStatus = useQuery({
queryKey: ['cluster', 'rolling-update-status'],
queryFn: async () => {
try {
const r = await apiClient.get('/cluster/rolling-update/status')
return isEnvelope(r.data) ? (r.data.data as RollingUpdateState) : null
} catch {
return null
}
},
refetchInterval: 5_000,
staleTime: 0,
gcTime: 0,
})
const isCluster = clusterStatus.data?.mode === 'cluster'
const rollingPhase = rollingStatus.data?.phase ?? 'idle'
const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed'
const secondaryFQDN = rollingStatus.data?.secondary_fqdn ?? ''
// Normal single-node upgrade state
const [upgrading, setUpgrading] = useState(false)
const [upgradeElapsed, setUpgradeElapsed] = useState(0)
const [forceChecking, setForceChecking] = useState(false)
@@ -61,11 +109,61 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
const installedRef = useRef<string>('')
const targetRef = useRef<string>('')
// Rolling update elapsed counter
const [rollingElapsed, setRollingElapsed] = useState(0)
const rollingTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
const rollingPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => () => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
}, [])
// Start rolling elapsed timer when rolling becomes active
useEffect(() => {
if (rollingActive && !rollingTickRef.current) {
setRollingElapsed(0)
rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000)
} else if (!rollingActive && rollingTickRef.current) {
clearInterval(rollingTickRef.current)
rollingTickRef.current = null
}
}, [rollingActive])
// When phase reaches "updating-primary": switch to health polling
// (primary will restart, state file can't be updated after that)
useEffect(() => {
if (rollingPhase === 'updating-primary' && !rollingPollRef.current) {
const primaryInstalled = installedRef.current
let sawDown = false
rollingPollRef.current = setInterval(async () => {
try {
const res = await apiClient.get('/system/health')
const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : ''
const flipped = newV && primaryInstalled && newV !== primaryInstalled
if (flipped || sawDown) {
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
rollingPollRef.current = null
rollingTickRef.current = null
msg.success(t('update.success', { version: targetRef.current }))
setTimeout(() => window.location.reload(), 1500)
}
} catch {
sawDown = true
}
}, 3000)
// Safety timeout
setTimeout(() => {
if (rollingPollRef.current) clearInterval(rollingPollRef.current)
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
window.location.reload()
}, 120_000)
}
}, [rollingPhase, msg, t])
const data = pkgVersions.data ?? {}
const updates = allUpdates(data)
const updateAvailable = updates.length > 0
@@ -76,14 +174,8 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
const forceCheck = async () => {
setForceChecking(true)
try {
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
// apt-get update. Ohne den Force-Hint würde der Endpoint
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
const r = await apiClient.get('/system/package-versions?force=1')
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
void pkgVersions.refetch()
const found = allUpdates(fresh).length > 0
msg[found ? 'success' : 'info'](
@@ -105,8 +197,6 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
apiClient.post('/system/upgrade')
.then(() => {
// Poll /healthz (kein Auth, robust auch wenn die API gerade
// restartet und Cookie ihre Session nicht erkennt).
let sawDown = false
upgradePollRef.current = setInterval(async () => {
try {
@@ -121,14 +211,9 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
setTimeout(() => window.location.reload(), 1500)
}
} catch {
// Connection refused / 502 → API restartet. Beim nächsten
// erfolgreichen Poll erkennen wir den Version-Flip.
sawDown = true
}
}, 3000)
// Sicherheits-Timeout: nach 2 Min einfach reload — falls der
// Restart länger braucht als erwartet, kommt die UI in jedem
// Fall wieder hoch.
setTimeout(() => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
@@ -144,13 +229,19 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
})
}
const startRollingUpdate = () => {
installedRef.current = installedVersion
targetRef.current = targetVersion
apiClient.post('/cluster/rolling-update')
.then(() => {
void rollingStatus.refetch()
})
.catch((e: Error) => {
msg.error(t('update.failed') + ': ' + e.message)
})
}
if (compact) {
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
// "die roten Banner können weg, der gelbe Banner reicht").
if (updateAvailable) {
return <>{msgCtx}</>
}
@@ -171,7 +262,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
)
}
if (!updateAvailable && !upgrading) {
if (!updateAvailable && !upgrading && !rollingActive) {
return <>{msgCtx}</>
}
@@ -179,7 +270,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
<>
{msgCtx}
{updateAvailable && !upgrading && (
{updateAvailable && !upgrading && !rollingActive && (
<Alert
type="warning"
banner
@@ -199,17 +290,33 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
>
{t('update.checkNow')}
</Button>
<Popconfirm
title={t('update.confirmTitle')}
description={t('update.confirmDesc', { version: targetVersion })}
okText={t('update.applyNow')}
cancelText={t('common.cancel')}
onConfirm={startUpgrade}
>
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
{t('update.applyNow')}
</Button>
</Popconfirm>
{isCluster ? (
<Popconfirm
title={t('update.rollingConfirmTitle')}
description={t('update.rollingConfirmDesc', {
secondary: clusterStatus.data?.peers?.[0]?.fqdn ?? 'secondary',
})}
okText={t('update.rollingUpdate')}
cancelText={t('common.cancel')}
onConfirm={startRollingUpdate}
>
<Button size="small" type="primary" icon={<ClusterOutlined />}>
{t('update.rollingUpdate')}
</Button>
</Popconfirm>
) : (
<Popconfirm
title={t('update.confirmTitle')}
description={t('update.confirmDesc', { version: targetVersion })}
okText={t('update.applyNow')}
cancelText={t('common.cancel')}
onConfirm={startUpgrade}
>
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
{t('update.applyNow')}
</Button>
</Popconfirm>
)}
</Space>
}
/>
@@ -234,26 +341,10 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
</div>
<div className="update-modal__steps">
<Step
done={upgradeElapsed >= 5}
active={upgradeElapsed < 5}
label={t('update.stepDownload')}
/>
<Step
done={upgradeElapsed >= 15}
active={upgradeElapsed >= 5 && upgradeElapsed < 15}
label={t('update.stepInstall')}
/>
<Step
done={upgradeElapsed >= 25}
active={upgradeElapsed >= 15 && upgradeElapsed < 25}
label={t('update.stepRestart')}
/>
<Step
done={false}
active={upgradeElapsed >= 25}
label={t('update.stepVerify')}
/>
<Step done={upgradeElapsed >= 5} active={upgradeElapsed < 5} label={t('update.stepDownload')} />
<Step done={upgradeElapsed >= 15} active={upgradeElapsed >= 5 && upgradeElapsed < 15} label={t('update.stepInstall')} />
<Step done={upgradeElapsed >= 25} active={upgradeElapsed >= 15 && upgradeElapsed < 25} label={t('update.stepRestart')} />
<Step done={false} active={upgradeElapsed >= 25} label={t('update.stepVerify')} />
</div>
<div className="update-modal__timer">{upgradeElapsed}s</div>
@@ -261,6 +352,54 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
</div>
</div>
)}
{rollingActive && (
<div className="update-modal-overlay">
<div className="update-modal">
<div className="update-modal__orbit">
<div className="update-modal__ring" />
<div className="update-modal__ring update-modal__ring--2" />
<div className="update-modal__dot" />
<div className="update-modal__dot update-modal__dot--2" />
<div className="update-modal__center">
<ClusterOutlined className="update-modal__icon" />
</div>
</div>
<div className="update-modal__title">{t('update.rollingRunning')}</div>
<div className="update-modal__version">
v{installedRef.current || '…'} v{targetRef.current || '…'}
</div>
<div className="update-modal__steps">
<Step
done={rollingPhase === 'waiting-secondary' || rollingPhase === 'updating-primary'}
active={rollingPhase === 'updating-secondary'}
label={t('update.rollingStepSecondary', { fqdn: secondaryFQDN })}
/>
<Step
done={rollingPhase === 'updating-primary'}
active={rollingPhase === 'waiting-secondary'}
label={t('update.rollingStepWaiting')}
/>
<Step
done={false}
active={rollingPhase === 'updating-primary'}
label={t('update.rollingStepPrimary')}
/>
</div>
<div className="update-modal__timer">{rollingElapsed}s</div>
<div className="update-modal__hint">{t('update.waitHint')}</div>
{rollingStatus.data?.error && (
<div className="update-modal__hint" style={{ color: '#ff4d4f' }}>
{rollingStatus.data.error}
</div>
)}
</div>
</div>
)}
</>
)
}