Go-Quality-Baseline-Rollout ABGESCHLOSSEN. Code-Quality-Backlog (55 → 0): - errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf `_, _ =` (Zero-Value degradiert sauber). - unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline, acme.Service.user, strFold + ungenutzter Import). - noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx. - staticcheck: QF1001/S1009/ST1005/SA9003. - contextcheck: detached-by-design-Stellen mit begründetem //nolint. Zwei echte Bugs beim Aufräumen gefunden+gefixt: - backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt → unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet. - haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts → echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain). Bewusste Config-Entscheidungen (.golangci.yml): - noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/ wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client- Disconnect darf keinen laufenden Reload mitten in der Ausführung killen. net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst. - rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs). Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check` grün: vet, golangci-lint, govulncheck, build, test -race. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
274 lines
9.1 KiB
Go
274 lines
9.1 KiB
Go
// Package aggregator führt parallele Cluster-Reads gegen alle Peer-Nodes
|
||
// via mTLS aus.
|
||
//
|
||
// Pattern: ein Aggregator-Endpoint auf der Main-API (z.B.
|
||
// /api/v1/cluster/system/load) ruft Aggregator.FanOut() — das verteilt
|
||
// die Request parallel an alle Peers' Agent-Listener (:8443 mTLS) und
|
||
// sammelt die Antworten in einer Map[node_id]→Ergebnis. Timeouts pro
|
||
// Peer (3s default) verhindern dass ein hängender Peer die ganze Antwort
|
||
// blockt; partielle Ergebnisse + per-Peer-Fehler werden zurückgegeben.
|
||
//
|
||
// mTLS-Auth: ClientTLSConfig aus clustertls.Store. CA muss auf beiden
|
||
// Seiten dieselbe sein — sonst RequireAndVerifyClientCert lehnt ab.
|
||
package aggregator
|
||
|
||
import (
|
||
"context"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||
)
|
||
|
||
// DefaultAgentPort: alle Peers exposen ihren mTLS-Listener auf diesem
|
||
// Port. api_url in ha_nodes zeigt typischerweise auf den Public-3443-
|
||
// Port — wir derive'n den Agent-Port daraus, statt eine zweite Spalte
|
||
// in ha_nodes zu führen.
|
||
const DefaultAgentPort = 8443
|
||
|
||
// DefaultPeerTimeout: pro-Peer-Timeout. Aggregat-Caller sollten eine
|
||
// Obergrenze von max(N×PeerTimeout/parallel) im Kopf haben; in der
|
||
// Praxis ist alles parallel, also bestimmt der langsamste Peer die
|
||
// Latenz.
|
||
const DefaultPeerTimeout = 3 * time.Second
|
||
|
||
// Aggregator: dünner Wrapper mit ClientTLSConfig + http.Client.
|
||
type Aggregator struct {
|
||
HTTPClient *http.Client
|
||
AgentPort int
|
||
}
|
||
|
||
// New: liefert einen Aggregator der ClientTLSConfig verwendet. Wenn
|
||
// clientTLS == nil, geht der Client auf normales TLS-Verify zurück —
|
||
// für Tests nützlich, in Prod aber unsicher (würde Cert-Verify gegen
|
||
// System-Trust laufen, das den Cluster-CA nicht kennt).
|
||
func New(clientTLS *tls.Config) *Aggregator {
|
||
tr := &http.Transport{
|
||
TLSClientConfig: clientTLS,
|
||
MaxIdleConns: 16,
|
||
MaxIdleConnsPerHost: 2,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
TLSHandshakeTimeout: 3 * time.Second,
|
||
ExpectContinueTimeout: 1 * time.Second,
|
||
ResponseHeaderTimeout: 5 * time.Second,
|
||
}
|
||
return &Aggregator{
|
||
HTTPClient: &http.Client{
|
||
Transport: tr,
|
||
Timeout: DefaultPeerTimeout,
|
||
},
|
||
AgentPort: DefaultAgentPort,
|
||
}
|
||
}
|
||
|
||
// PeerResult kapselt das Ergebnis eines parallelen Fan-Out-Calls.
|
||
// Wenn Err != nil ist Data leer; sonst enthält Data den raw-JSON-Body
|
||
// (Aufrufer entscheidet ob es per-Peer typed-unmarshalled oder als
|
||
// map[string]any belassen wird).
|
||
type PeerResult struct {
|
||
NodeID string `json:"node_id"`
|
||
FQDN string `json:"fqdn"`
|
||
OK bool `json:"ok"`
|
||
Data json.RawMessage `json:"data,omitempty"`
|
||
Err string `json:"error,omitempty"`
|
||
Duration int64 `json:"duration_ms"`
|
||
}
|
||
|
||
// FanOut: ruft GET <agent-url>/<path> für jeden Peer in `peers` parallel
|
||
// und sammelt die Ergebnisse in einer slice (stabile Sortierung nach
|
||
// Peer-FQDN für deterministisches UI-Rendering).
|
||
//
|
||
// `path` ist relativ, z.B. "/agent/system/load". `localID` wird als
|
||
// Marker übergeben damit der Aufrufer den eigenen Node von der Map
|
||
// ausschließen kann.
|
||
func (a *Aggregator) FanOut(ctx context.Context, peers []models.HANode, path, localID string) []PeerResult {
|
||
if !strings.HasPrefix(path, "/") {
|
||
path = "/" + path
|
||
}
|
||
results := make([]PeerResult, len(peers))
|
||
var wg sync.WaitGroup
|
||
for i, p := range peers {
|
||
if p.ID == localID {
|
||
// Eigener Node nicht über mTLS dial'n — wäre teuer + im
|
||
// Aufrufer wahrscheinlich der lokale Path
|
||
results[i] = PeerResult{NodeID: p.ID, FQDN: p.FQDN, OK: false, Err: "skipped: local node"}
|
||
continue
|
||
}
|
||
wg.Add(1)
|
||
i := i
|
||
p := p
|
||
go func() {
|
||
defer wg.Done()
|
||
results[i] = a.callPeer(ctx, p, path)
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
return results
|
||
}
|
||
|
||
// callPeer macht den Einzel-Call. Wandelt p.APIURL in https://host:8443/
|
||
// um (Port übersteuert, Pfad ersetzt). Bei Connection-Fehler / Timeout
|
||
// liefert ein PeerResult mit OK=false zurück.
|
||
func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||
start := time.Now()
|
||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||
|
||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||
if err != nil {
|
||
res.Err = "bad api_url: " + err.Error()
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
reqCtx, cancel := context.WithTimeout(ctx, DefaultPeerTimeout)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||
if err != nil {
|
||
res.Err = err.Error()
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
resp, err := a.HTTPClient.Do(req)
|
||
if err != nil {
|
||
res.Err = err.Error()
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||
if resp.StatusCode != http.StatusOK {
|
||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
res.OK = true
|
||
// Agent-Endpoints liefern die Standard-API-Envelope zurück
|
||
// ({"data": {...}, "error": null, "message": "ok"}). Wir entpacken
|
||
// das `data`-Feld so dass der Aufrufer direkt die Nutzlast bekommt —
|
||
// konsistent mit dem Lokal-Pfad (der marshalt die Struct direkt ohne
|
||
// Envelope).
|
||
var env struct {
|
||
Data json.RawMessage `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(body, &env); err == nil && len(env.Data) > 0 {
|
||
res.Data = env.Data
|
||
} else {
|
||
res.Data = body
|
||
}
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
|
||
// agentURL: nimmt z.B. "https://node1.example.com:3443" + port=8443 +
|
||
// path="/agent/system/load" und liefert "https://node1.example.com:8443/agent/system/load".
|
||
// Wir tauschen den Port aus, behalten Schema + Host (nur).
|
||
func agentURL(apiURL string, agentPort int, path string) (string, error) {
|
||
if apiURL == "" {
|
||
return "", errors.New("empty api_url")
|
||
}
|
||
u, err := url.Parse(apiURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if u.Scheme == "" {
|
||
u.Scheme = "https"
|
||
}
|
||
host := u.Hostname()
|
||
if host == "" {
|
||
return "", errors.New("api_url has no host")
|
||
}
|
||
u.Host = net.JoinHostPort(host, fmt.Sprint(agentPort))
|
||
u.Path = path
|
||
u.RawQuery = ""
|
||
return u.String(), nil
|
||
}
|
||
|
||
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
|
||
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
|
||
// auf dem Secondary auszulösen.
|
||
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||
start := time.Now()
|
||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||
if err != nil {
|
||
res.Err = "bad api_url: " + err.Error()
|
||
return res
|
||
}
|
||
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, nil)
|
||
if err != nil {
|
||
res.Err = err.Error()
|
||
return res
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := a.HTTPClient.Do(req)
|
||
if err != nil {
|
||
res.Err = err.Error()
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
res.OK = true
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
|
||
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
|
||
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
|
||
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) PeerResult {
|
||
start := time.Now()
|
||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||
if err != nil {
|
||
res.Err = "bad api_url: " + err.Error()
|
||
return res
|
||
}
|
||
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
defer cancel()
|
||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
|
||
if err != nil {
|
||
res.Err = err.Error()
|
||
return res
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := a.HTTPClient.Do(req)
|
||
if err != nil {
|
||
res.Err = err.Error()
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
|
||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
res.OK = true
|
||
res.Data = respBody
|
||
res.Duration = time.Since(start).Milliseconds()
|
||
return res
|
||
}
|
||
|
||
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
||
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
||
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
||
// die Abhängigkeit.
|
||
var _ = cluster.ComputeConfigHash
|