software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
+56
-3
@@ -21,6 +21,7 @@ package policy
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -61,8 +62,23 @@ const (
|
||||
HeaderStage = "X-Sultan-Stage"
|
||||
HeaderTenant = "X-Sultan-Tenant"
|
||||
HeaderTier = "X-Sultan-Tier" // z. B. für den Pro-Hinweis auf Community-Wartungsseiten
|
||||
// HeaderUntil: voraussichtliches Wartungsende (RFC 3339), nur bei befristeter Wartung.
|
||||
HeaderUntil = "X-Sultan-Until"
|
||||
// HeaderPublicReason: öffentlicher Wartungsgrund, URL-kodiert (Header sind
|
||||
// einzeilig; Umlaute kommen so unverändert beim Lander an).
|
||||
HeaderPublicReason = "X-Sultan-Public-Reason"
|
||||
)
|
||||
|
||||
// Maintenance ist eine angemeldete Wartung (Knoten oder Dienst).
|
||||
type Maintenance struct {
|
||||
Until time.Time // Nullwert = unbefristet
|
||||
PublicReason string
|
||||
}
|
||||
|
||||
func (m *Maintenance) active(now time.Time) bool {
|
||||
return m != nil && (m.Until.IsZero() || now.Before(m.Until))
|
||||
}
|
||||
|
||||
// AckCookie setzt der Lander, nachdem er den Mahnhinweis gezeigt hat; der
|
||||
// Wert ist die Stufe. Ein bestätigter Hinweis gilt nicht für eine höhere
|
||||
// Stufe. Das Cookie ist bewusst kein Sicherheitsmerkmal – wer es selbst
|
||||
@@ -124,6 +140,14 @@ type Input struct {
|
||||
// DownServices: Services (Originalnamen), die der Healthcheck des
|
||||
// Agenten als ausgefallen meldet.
|
||||
DownServices map[string]bool
|
||||
// Maintenance: Wartung des ganzen Knotens (nil = keine).
|
||||
Maintenance *Maintenance
|
||||
// ServiceMaintenance: Wartung einzelner Services (Originalnamen), z. B.
|
||||
// per Label sultan.maintenance=true.
|
||||
ServiceMaintenance map[string]Maintenance
|
||||
// Disconnected: Der Knoten ist ungeplant getrennt, seine Lease gilt aber
|
||||
// noch – Besucher sehen die Wartungsseite (ohne Ende und Grund).
|
||||
Disconnected bool
|
||||
}
|
||||
|
||||
// ViolationKind klassifiziert einen Verstoß.
|
||||
@@ -202,7 +226,8 @@ func StageFor(t Tenant, l license.License, now time.Time) Stage {
|
||||
switch {
|
||||
case t.Status == store.TenantQuarantined:
|
||||
return StageBlocked
|
||||
case t.Status == store.TenantExpired || l.Expired(now):
|
||||
// Community wird automatisch neu lizenziert: nur der explizite Status zählt.
|
||||
case t.Status == store.TenantExpired || (l.Tier != license.TierCommunity && l.Expired(now)):
|
||||
return StageLanding
|
||||
case l.Tier == license.TierCommunity:
|
||||
return StageNormal // kein Mahnwesen im Community-Betrieb
|
||||
@@ -226,7 +251,8 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
if e.now != nil {
|
||||
now = e.now
|
||||
}
|
||||
stage := StageFor(in.Tenant, in.License, now())
|
||||
at := now()
|
||||
stage := StageFor(in.Tenant, in.License, at)
|
||||
res := Result{Config: &traefik.Configuration{}, Stage: stage}
|
||||
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
|
||||
return res, nil
|
||||
@@ -289,8 +315,11 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
out.Services[nr.Service] = copyService(src.Services[r.Service])
|
||||
}
|
||||
|
||||
// Vorrang: Ablauf/Paywall > Wartung (Knoten, Dienst, Healthcheck) >
|
||||
// Mahnhinweis > normal. (Sperre und drain liefern oben gar keine Routen.)
|
||||
routerStage := stage
|
||||
if routerStage == StageNormal && in.DownServices[r.Service] {
|
||||
maint, inMaint := maintenanceFor(in, r.Service, at)
|
||||
if inMaint && (stage == StageNormal || slices.Contains(ackStages, stage)) {
|
||||
routerStage = StageMaintenance
|
||||
}
|
||||
if routerStage != StageNormal {
|
||||
@@ -319,6 +348,14 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
HeaderTenant: in.Tenant.ID,
|
||||
HeaderTier: string(in.License.Tier),
|
||||
}
|
||||
if routerStage == StageMaintenance {
|
||||
if !maint.Until.IsZero() {
|
||||
headers[HeaderUntil] = maint.Until.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if maint.PublicReason != "" {
|
||||
headers[HeaderPublicReason] = url.QueryEscape(maint.PublicReason)
|
||||
}
|
||||
}
|
||||
mwName := "sultan__" + prefix + name
|
||||
out.Middlewares[mwName] = traefik.Middleware{"headers": map[string]any{"customRequestHeaders": headers}}
|
||||
nr.Middlewares = append(slices.Clone(nr.Middlewares), mwName)
|
||||
@@ -336,6 +373,22 @@ func (e *Engine) Apply(in Input) (Result, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// maintenanceFor liefert die geltende Wartung eines Services: zuerst die des
|
||||
// Knotens, dann die des Services, dann ein Healthcheck-Ausfall oder ein
|
||||
// getrennter Knoten (beides ohne Grund).
|
||||
func maintenanceFor(in Input, service string, now time.Time) (Maintenance, bool) {
|
||||
if in.Maintenance.active(now) {
|
||||
return *in.Maintenance, true
|
||||
}
|
||||
if m, ok := in.ServiceMaintenance[service]; ok && m.active(now) {
|
||||
return m, true
|
||||
}
|
||||
if in.DownServices[service] || in.Disconnected {
|
||||
return Maintenance{}, true
|
||||
}
|
||||
return Maintenance{}, false
|
||||
}
|
||||
|
||||
func validateInput(in Input) error {
|
||||
switch {
|
||||
case !ValidID(in.Tenant.ID) || in.Tenant.ID == "sultan":
|
||||
|
||||
+143
-1
@@ -2,6 +2,7 @@ package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -296,7 +297,10 @@ func TestStageFor(t *testing.T) {
|
||||
}{
|
||||
{"Community aktiv", license.TierCommunity, store.TenantActive, 0, false, StageNormal},
|
||||
{"Community ohne Mahnwesen", license.TierCommunity, store.TenantActive, 2, false, StageNormal},
|
||||
{"Community abgelaufen", license.TierCommunity, store.TenantActive, 0, true, StageLanding},
|
||||
// Community wird automatisch neu lizenziert (5c): kein Ablauf über valid_until …
|
||||
{"Community mit altem valid_until", license.TierCommunity, store.TenantActive, 0, true, StageNormal},
|
||||
// … nur der explizite Status expired führt zur Landingpage.
|
||||
{"Community explizit abgelaufen", license.TierCommunity, store.TenantExpired, 0, false, StageLanding},
|
||||
{"Pro aktiv", license.TierPro, store.TenantActive, 0, false, StageNormal},
|
||||
{"Mahnstufe 1", license.TierPro, store.TenantActive, 1, false, StageWatermark},
|
||||
{"Mahnstufe 2", license.TierPro, store.TenantActive, 2, false, StageFiremark},
|
||||
@@ -585,3 +589,141 @@ func TestValidID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headersOf(t *testing.T, res Result, router string) map[string]any {
|
||||
t.Helper()
|
||||
mw := res.Config.HTTP.Middlewares["sultan__kunde__node-1__"+router]
|
||||
if mw == nil {
|
||||
t.Fatalf("keine Interceptor-Middleware für %s", router)
|
||||
}
|
||||
return mw["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
|
||||
}
|
||||
|
||||
func TestApply_NodeMaintenance(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
until := now.Add(2 * time.Hour)
|
||||
in.Maintenance = &Maintenance{Until: until, PublicReason: "Sicherheitsupdate (CVE-1234) – Sicherheit geht vor."}
|
||||
res, err := engine().Apply(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != InterceptorService {
|
||||
t.Fatal("Knoten in Wartung nicht zum Interceptor umgeleitet")
|
||||
}
|
||||
h := headersOf(t, res, "wp")
|
||||
if h[HeaderStage] != string(StageMaintenance) || h[HeaderUntil] != until.Format(time.RFC3339) {
|
||||
t.Errorf("Header: %v", h)
|
||||
}
|
||||
// Öffentlicher Grund URL-kodiert (Header sind einzeilig, Traefik reicht Bytes nicht zuverlässig durch).
|
||||
if h[HeaderPublicReason] != url.QueryEscape("Sicherheitsupdate (CVE-1234) – Sicherheit geht vor.") {
|
||||
t.Errorf("öffentlicher Grund: %v", h[HeaderPublicReason])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_NodeMaintenanceUnlimitedAndExpired(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
in.Maintenance = &Maintenance{} // unbefristet, ohne Grund
|
||||
res, _ := engine().Apply(in)
|
||||
h := headersOf(t, res, "wp")
|
||||
if _, ok := h[HeaderUntil]; ok {
|
||||
t.Error("unbefristete Wartung mit Ende-Header")
|
||||
}
|
||||
if _, ok := h[HeaderPublicReason]; ok {
|
||||
t.Error("Wartung ohne öffentlichen Grund mit Grund-Header")
|
||||
}
|
||||
|
||||
in.Maintenance = &Maintenance{Until: now} // gerade abgelaufen
|
||||
res, _ = engine().Apply(in)
|
||||
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
|
||||
t.Error("abgelaufene Wartung leitet noch um")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_ServiceMaintenance(t *testing.T) {
|
||||
labels := wpLabels()
|
||||
labels["traefik.http.routers.wp.service"] = "wp"
|
||||
labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)"
|
||||
labels["traefik.http.routers.shop.service"] = "shop"
|
||||
labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080"
|
||||
in := input(t, labels)
|
||||
in.ServiceMaintenance = map[string]Maintenance{"shop": {PublicReason: "Inventur"}}
|
||||
|
||||
res, _ := engine().Apply(in)
|
||||
if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService {
|
||||
t.Error("Dienst in Wartung nicht umgeleitet")
|
||||
}
|
||||
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service == InterceptorService {
|
||||
t.Error("anderer Dienst wurde umgeleitet")
|
||||
}
|
||||
if h := headersOf(t, res, "shop"); h[HeaderPublicReason] != "Inventur" {
|
||||
t.Errorf("Header: %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
// Vorrang: Sperre/drain > Ablauf/Paywall > Wartung > Mahnhinweis > normal.
|
||||
func TestApply_MaintenancePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Input)
|
||||
stage Stage // Stufe am Router; "" = keine Routen
|
||||
ackSeen bool
|
||||
}{
|
||||
{"Wartung schlägt Mahnstufe 1", func(in *Input) { in.Tenant.DunningLevel = 1 }, StageMaintenance, false},
|
||||
{"Paywall schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, StagePaywall, false},
|
||||
{"Ablauf schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantExpired }, StageLanding, false},
|
||||
{"drain schlägt Wartung", func(in *Input) { in.NodeDrained = true }, "", false},
|
||||
{"Quarantäne schlägt Wartung", func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, "", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
in.Maintenance = &Maintenance{}
|
||||
tt.mutate(&in)
|
||||
res, err := engine().Apply(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.stage == "" {
|
||||
if res.Config.HTTP != nil {
|
||||
t.Errorf("erwartet keine Routen: %v", keys(res.Config.HTTP.Routers))
|
||||
}
|
||||
return
|
||||
}
|
||||
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(tt.stage) {
|
||||
t.Errorf("Stufe %v, erwartet %s", h[HeaderStage], tt.stage)
|
||||
}
|
||||
if _, ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]; ack != tt.ackSeen {
|
||||
t.Errorf("Durchlass-Router vorhanden=%v", ack)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ungeplant getrennt (Lease noch gültig): Wartungsseite ohne Ende und ohne Grund.
|
||||
func TestApply_DisconnectedNode(t *testing.T) {
|
||||
in := input(t, wpLabels())
|
||||
in.Disconnected = true
|
||||
res, _ := engine().Apply(in)
|
||||
h := headersOf(t, res, "wp")
|
||||
if h[HeaderStage] != string(StageMaintenance) {
|
||||
t.Errorf("Stufe %v", h[HeaderStage])
|
||||
}
|
||||
if _, ok := h[HeaderUntil]; ok {
|
||||
t.Error("getrennter Knoten mit Ende-Header")
|
||||
}
|
||||
|
||||
// Angemeldete Wartung hat Vorrang (deren Grund/Ende wird gezeigt).
|
||||
in.Maintenance = &Maintenance{PublicReason: "Reboot nach Update"}
|
||||
res, _ = engine().Apply(in)
|
||||
if h := headersOf(t, res, "wp"); h[HeaderPublicReason] != url.QueryEscape("Reboot nach Update") {
|
||||
t.Errorf("Grund der Wartung fehlt: %v", h)
|
||||
}
|
||||
|
||||
// Paywall bleibt Paywall.
|
||||
in.Maintenance = nil
|
||||
in.Tenant.Status = store.TenantSuspendedPayment
|
||||
res, _ = engine().Apply(in)
|
||||
if h := headersOf(t, res, "wp"); h[HeaderStage] != string(StagePaywall) {
|
||||
t.Errorf("Paywall verdrängt: %v", h[HeaderStage])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user