From bab82f8d5b007395eebf163baf80ba249efcb8f1 Mon Sep 17 00:00:00 2001 From: noroot Date: Wed, 2 Sep 2026 16:31:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(update):=20Testing/Stable-Update-Kan=C3=A4?= =?UTF-8?q?le=20wie=20enconf=20(Suite=3Dtrixie,=20Komponente=3DKanal)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 13 +- internal/handlers/cluster.go | 135 ++++++++++++- internal/handlers/cluster_rollingupdate.go | 5 +- internal/handlers/system.go | 5 +- internal/services/apt/apt.go | 58 ++++++ management-ui/src/i18n/locales/de/common.json | 11 ++ management-ui/src/i18n/locales/en/common.json | 11 ++ management-ui/src/pages/Settings/index.tsx | 75 +++++++- .../debian/edgeguard-api/DEBIAN/postinst | 16 ++ scripts/apt-repo/cleanup-old.sh | 65 +++++-- scripts/apt-repo/publish.sh | 20 +- scripts/install.sh | 9 +- scripts/release.sh | 181 ++++++++++++++++++ 13 files changed, 571 insertions(+), 33 deletions(-) create mode 100755 scripts/release.sh diff --git a/Makefile b/Makefile index 732d273..b0c33bd 100644 --- a/Makefile +++ b/Makefile @@ -114,17 +114,18 @@ deb-arm64: release-check build-linux-arm64 ui deb: deb-amd64 deb-arm64 -GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload - +# Direktes `make publish` bleibt als Handnotbremse erhalten, veröffentlicht +# aber immer nach stable — für Testing-Releases + das Stable-Promotion- +# Gate (verify_channel_debs, Version-Bump, Git-Tag) scripts/release.sh nutzen. publish-amd64: deb-amd64 - @./scripts/apt-repo/publish.sh $(VERSION) amd64 + @./scripts/apt-repo/publish.sh $(VERSION) amd64 stable @echo " -> cleanup-old (keep last $${KEEP:-10})" - @./scripts/apt-repo/cleanup-old.sh + @./scripts/apt-repo/cleanup-old.sh stable publish-arm64: deb-arm64 - @./scripts/apt-repo/publish.sh $(VERSION) arm64 + @./scripts/apt-repo/publish.sh $(VERSION) arm64 stable @echo " -> cleanup-old (keep last $${KEEP:-10})" - @./scripts/apt-repo/cleanup-old.sh + @./scripts/apt-repo/cleanup-old.sh stable publish: publish-amd64 publish-arm64 diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index d612322..300573e 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -21,6 +21,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/models" + aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" ) @@ -92,6 +93,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) { g.GET("/repair-replication/status", h.RepairReplicationStatus) g.GET("/vip-status", h.VIPStatus) g.POST("/vip-test", h.VIPTest) + g.GET("/update-channel", h.UpdateChannel) + g.POST("/update-channel", h.SetUpdateChannel) if h.TLSStore != nil { g.GET("/cert-status", h.CertStatus) g.POST("/renew-self", h.RenewSelf) @@ -247,6 +250,8 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) { g.GET("/master-key", h.AgentMasterKey) g.GET("/version", h.AgentVersion) g.POST("/trigger-update", h.AgentTriggerUpdate) + g.POST("/set-channel", h.AgentSetChannel) + g.GET("/channel", h.AgentChannel) g.GET("/active-ips", h.AgentActiveIPs) g.POST("/vip-cmd", h.AgentVIPCmd) g.GET("/tls-certs", h.AgentTLSCerts) @@ -758,7 +763,10 @@ retry_apt() { while [ $attempt -lt $max ]; do attempt=$((attempt + 1)) apt-get update -qq || true - if apt-get install -y -qq -o Dpkg::Options::=--force-confold \ + # --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel + # (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver). + # No-Op im Normalfall, da die Candidate sonst immer >= installed ist. + if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \ edgeguard-api edgeguard-ui edgeguard; then return 0; fi [ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2)) done @@ -789,6 +797,131 @@ rm -f /var/lib/edgeguard/upgrade.sh c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"}) } +// ── Update-Kanal (stable/testing) ────────────────────────────────────── +// +// Kanal-Modell wie enconf (Suite=Codename, Komponente=Kanal, siehe +// internal/services/apt.Channel/SetChannel) — an EdgeGuards fixes +// Primary/Standby-Paar angepasst statt generischer Server-Flotte: der +// Kanal wird auf beiden Nodes synchron gehalten (wie config_hash), +// kein Node-Override. Reines Umschreiben der sources.list + `apt-get +// update` ist risikofrei (kein Service-Restart, keine VIP-Auswirkung) +// — das eigentliche Downgrade/Upgrade auf die neue Kanal-Version läuft +// danach ganz normal über den bestehenden (sicheren, Standby-zuerst) +// Rolling-Update-Flow, der --allow-downgrades jetzt mit unterstützt. + +type updateChannelResponse struct { + Channel string `json:"channel"` + PeerChannel string `json:"peer_channel,omitempty"` + PeerReached bool `json:"peer_reached"` + PeerDrifted bool `json:"peer_drifted"` +} + +// UpdateChannel liefert den lokalen Kanal + (falls Cluster) den Kanal +// des Peers zur Drift-Erkennung — analog zum config_hash-Vergleich. +func (h *ClusterHandler) UpdateChannel(c *gin.Context) { + resp := updateChannelResponse{Channel: aptsvc.Channel()} + peer := h.peerNode(c.Request.Context()) + if peer != nil && h.Aggregator != nil { + results := h.Aggregator.FanOut(c.Request.Context(), []models.HANode{*peer}, "/agent/cluster/channel", h.LocalID) + if len(results) > 0 && results[0].OK { + var body struct { + Channel string `json:"channel"` + } + if json.Unmarshal(results[0].Data, &body) == nil { + resp.PeerReached = true + resp.PeerChannel = body.Channel + resp.PeerDrifted = body.Channel != resp.Channel + } + } + } + response.OK(c, resp) +} + +// SetUpdateChannel setzt den Kanal lokal und — falls ein Peer existiert +// — synchron auch auf dem Peer via mTLS. Löst KEIN Paket-Update aus; +// das übernimmt der Admin danach ganz normal über den Update-Banner / +// Rolling-Update, der die neue Candidate-Version dann bereits sieht. +func (h *ClusterHandler) SetUpdateChannel(c *gin.Context) { + var req struct { + Channel string `json:"channel"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.Channel != "stable" && req.Channel != "testing" { + response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'")) + return + } + if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil { + response.Internal(c, err) + return + } + + resp := updateChannelResponse{Channel: req.Channel} + if peer := h.peerNode(c.Request.Context()); peer != nil && h.Aggregator != nil { + body, _ := json.Marshal(req) + result := h.Aggregator.PostPeerWithBody(c.Request.Context(), *peer, "/agent/cluster/set-channel", body) + resp.PeerReached = result.OK + if !result.OK { + slog.Warn("cluster: set-channel on peer failed", "peer", peer.FQDN, "error", result.Err) + } + } + + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.update_channel.set", + "", gin.H{"channel": req.Channel}, h.NodeID) + } + response.OK(c, resp) +} + +// AgentChannel: mTLS-Peer-Read des lokalen Kanals (für Drift-Anzeige). +func (h *ClusterHandler) AgentChannel(c *gin.Context) { + response.OK(c, gin.H{"channel": aptsvc.Channel()}) +} + +// AgentSetChannel: mTLS-Peer-Write — wird vom Primary aufgerufen um den +// Kanal auf diesem (Standby-)Node synchron zu setzen. +func (h *ClusterHandler) AgentSetChannel(c *gin.Context) { + var req struct { + Channel string `json:"channel"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.Channel != "stable" && req.Channel != "testing" { + response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'")) + return + } + if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil { + response.Internal(c, err) + return + } + slog.Info("cluster: update channel set on this node by primary mTLS call", + "channel", req.Channel, "client", c.ClientIP()) + response.OK(c, gin.H{"channel": req.Channel}) +} + +// peerNode liefert die einzige andere ha_nodes-Row (best-effort, nil +// wenn Standalone oder Store fehlt) — gleiches Muster wie in +// RollingUpdate für die Secondary-Ermittlung. +func (h *ClusterHandler) peerNode(ctx context.Context) *models.HANode { + if h.Store == nil { + return nil + } + nodes, err := h.Store.List(ctx) + if err != nil { + return nil + } + for i := range nodes { + if nodes[i].ID != h.LocalID { + return &nodes[i] + } + } + return nil +} + var errInvalidJoinRequest = simpleError("missing token or csr") type simpleError string diff --git a/internal/handlers/cluster_rollingupdate.go b/internal/handlers/cluster_rollingupdate.go index 23d130e..7c5d335 100644 --- a/internal/handlers/cluster_rollingupdate.go +++ b/internal/handlers/cluster_rollingupdate.go @@ -273,7 +273,10 @@ retry_apt() { while [ $attempt -lt $max ]; do attempt=$((attempt + 1)) apt-get update -qq || true - if apt-get install -y -qq -o Dpkg::Options::=--force-confold \ + # --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel + # (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver). + # No-Op im Normalfall, da die Candidate sonst immer >= installed ist. + if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \ edgeguard-api edgeguard-ui edgeguard; then return 0; fi [ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2)) done diff --git a/internal/handlers/system.go b/internal/handlers/system.go index 94bbd1d..204f6d1 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -940,7 +940,10 @@ retry_apt() { attempt=$((attempt + 1)) echo "[upgrade] attempt $attempt/$max: apt-get update + install" apt-get update -qq || true - if apt-get install -y -qq -o Dpkg::Options::=--force-confold \ + # --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel + # (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver). + # No-Op im Normalfall, da die Candidate sonst immer >= installed ist. + if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \ edgeguard-api edgeguard-ui edgeguard; then return 0 fi diff --git a/internal/services/apt/apt.go b/internal/services/apt/apt.go index 3cfb609..4d87da4 100644 --- a/internal/services/apt/apt.go +++ b/internal/services/apt/apt.go @@ -237,3 +237,61 @@ func AutoUpdateEnabled() bool { _, err := os.Stat(AutoUpdateConfPath) return err == nil } + +// ── Update-Kanal (stable/testing) ────────────────────────────────────── +// +// Kanal-Modell 1:1 von enconf übernommen: Suite = OS-Codename (trixie), +// Komponente = Kanal. Kein eigenes Config-File — die sources.list-Zeile +// selbst ist die einzige Quelle der Wahrheit (siehe scripts/install.sh +// setup_repo(), das dieselbe Zeile beim Erstinstall schreibt). + +// SourcesListPath: vom Installer angelegte apt-Quelle. Root-owned wie +// AutoUpdateConfPath — Schreibzugriff nur via sudo tee (Sudoers-Pin im +// postinst). +const SourcesListPath = "/etc/apt/sources.list.d/edgeguard.list" + +const sourcesListTemplate = "deb [signed-by=/etc/apt/keyrings/nmg.asc] " + + "https://git.netcell-it.de/api/packages/projekte/debian trixie %s\n" + +// Channel liest den aktuell konfigurierten Update-Kanal aus dem letzten +// Feld der deb-Zeile. Default "stable" wenn die Datei fehlt oder das +// letzte Feld kein bekannter Kanal ist (Fail-safe — nie stillschweigend +// "testing" annehmen). +func Channel() string { + data, err := os.ReadFile(SourcesListPath) + if err != nil { + return "stable" + } + for _, raw := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, "deb ") { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + switch fields[len(fields)-1] { + case "stable", "testing": + return fields[len(fields)-1] + } + } + return "stable" +} + +// SetChannel schreibt die sources.list-Zeile mit dem neuen Kanal und +// refresht den apt-Cache sofort — sonst zeigt der Update-Banner bis zum +// nächsten 5-min-Throttle-Fenster noch den alten Kanal-Stand. +func SetChannel(ctx context.Context, channel string) error { + if channel != "stable" && channel != "testing" { + return fmt.Errorf("apt: unknown channel %q (expected stable|testing)", channel) + } + body := fmt.Sprintf(sourcesListTemplate, channel) + cmd := exec.Command("sudo", "-n", "/usr/bin/tee", SourcesListPath) + cmd.Stdin = strings.NewReader(body) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("sudo tee %s: %w: %s", SourcesListPath, err, strings.TrimSpace(string(out))) + } + RefreshNow(ctx) + return nil +} diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 2bda3b3..25af81c 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 9bfcf64..3d532d2 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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.", diff --git a/management-ui/src/pages/Settings/index.tsx b/management-ui/src/pages/Settings/index.tsx index daa595e..73f9c2d 100644 --- a/management-ui/src/pages/Settings/index.tsx +++ b/management-ui/src/pages/Settings/index.tsx @@ -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(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() { + {t('settings.updateChannelCardTitle')}} + className="mb-12" + size="small" + > + + +