Files
sultan/pkg/server/jobs.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

230 lines
8.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"fmt"
"net/http"
"time"
"sultan/pkg/checkmk"
"sultan/pkg/notify"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// retention: Aufbewahrung von Einladungen, abgelaufenen Leases und
// zugestellten Befehlen (entspricht der Community-Lease-Laufzeit).
const retention = 10 * 24 * time.Hour
func commercial(t store.Tenant) bool { return t.Tier != store.TierCommunity }
// runJobs läuft bei jedem Refresh: Vertragsablauf, Benachrichtigungen,
// Aufräumen.
func (s *Server) runJobs(ctx context.Context) {
now := time.Now()
tenants, err := s.store.ListTenants(ctx)
if err != nil {
s.log.Error("Job: Mandanten lesen", "err", err)
return
}
for _, t := range tenants {
s.expireContract(ctx, t, now)
if s.notifier != nil && commercial(t) {
s.notifyContract(ctx, t, now)
s.notifyDunning(ctx, t, now)
}
}
if s.notifier != nil {
s.notifyOffline(ctx, now)
}
s.housekeeping(ctx)
}
// expireContract setzt kommerzielle Verträge ab dem Ablaufzeitpunkt dauerhaft
// auf expired (Community wird automatisch neu lizenziert).
func (s *Server) expireContract(ctx context.Context, t store.Tenant, now time.Time) {
if !commercial(t) || t.Status == store.TenantExpired || t.Status == store.TenantQuarantined || now.Before(t.ValidUntil) {
return
}
if err := s.store.SetTenantStatus(ctx, t.ID, store.TenantExpired); err != nil {
s.log.Error("Job: Vertrag auf expired setzen", "tenant", t.ID, "err", err)
return
}
s.log.Warn("Vertrag abgelaufen – Mandant auf expired gesetzt", "tenant", t.ID, "valid_until", t.ValidUntil)
}
func (s *Server) notifyContract(ctx context.Context, t store.Tenant, now time.Time) {
day := t.ValidUntil.UTC().Format(time.DateOnly)
days := int(t.ValidUntil.Sub(now).Hours() / 24)
switch {
case t.Status == store.TenantExpired || !now.Before(t.ValidUntil):
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":0", notify.Message{
Severity: notify.Critical,
Title: "Vertrag " + t.ID + " abgelaufen",
Body: fmt.Sprintf("Der Vertrag von *%s* ist am %s abgelaufen. Die Seiten zeigen die Landingpage.", t.ID, day),
})
case days <= checkmk.ContractCritDays:
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":14", notify.Message{
Severity: notify.Critical,
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
Body: fmt.Sprintf("Vertragsende für *%s*: %s. Verlängerung erforderlich.", t.ID, day),
})
case days <= checkmk.ContractWarnDays:
s.sendOnce(ctx, "contract:"+t.ID+":"+day+":30", notify.Message{
Severity: notify.Warning,
Title: fmt.Sprintf("Vertrag %s läuft in %d Tagen ab", t.ID, days),
Body: fmt.Sprintf("Vertragsende für *%s*: %s.", t.ID, day),
})
}
}
func (s *Server) notifyDunning(ctx context.Context, t store.Tenant, now time.Time) {
level := policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now)
if level == 0 {
return
}
sev := notify.Warning
if level >= 2 {
sev = notify.Critical
}
text := map[int]string{1: "Hinweisseite mit 5 s Weiterleitung", 2: "Hinweisseite mit 10 s Weiterleitung", 3: "Kunde offline (Einladungsseite)"}[min(level, 3)]
s.sendOnce(ctx, fmt.Sprintf("dunning:%s:%s:%d", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), level), notify.Message{
Severity: sev,
Title: fmt.Sprintf("Mahnstufe %d für %s", level, t.ID),
Body: fmt.Sprintf("Zahlung von *%s* seit %s offen. Wirkung: %s.", t.ID, t.PaymentOverdueSince.UTC().Format(time.DateOnly), text),
})
}
// notifyOffline alarmiert einmal pro Ausfall, wenn ein Knoten länger als
// OfflineAlertAfter getrennt ist – nicht bei angemeldeter Wartung oder drain.
func (s *Server) notifyOffline(ctx context.Context, now time.Time) {
nodes, err := s.store.ListNodes(ctx, "")
if err != nil {
return
}
for _, n := range nodes {
if n.Status != store.NodeOffline || n.Maintenance.Active(now) || now.Sub(n.LastSeen) < s.cfg.OfflineAlertAfter {
continue
}
s.sendOnce(ctx, "offline:"+n.ID+":"+n.LastSeen.UTC().Format(time.RFC3339Nano), notify.Message{
Severity: notify.Critical,
Title: "Knoten " + n.ID + " offline",
Body: fmt.Sprintf("Knoten *%s* (Mandant %s, %s) ist seit %s getrennt. Besucher sehen die Wartungsseite, solange die Lease gilt.",
n.ID, n.TenantID, n.Hostname, n.LastSeen.Local().Format("2006-01-02 15:04")),
})
}
}
// notifyBackOnline meldet „wieder online“ – nur für Ausfälle, zu denen ein
// Offline-Alarm verschickt wurde, und je Ausfall genau einmal.
func (s *Server) notifyBackOnline(nodeID string) {
ctx := context.Background()
prefix := "offline:" + nodeID + ":"
keys, err := s.store.NotificationKeys(ctx, prefix)
if err != nil {
return
}
for _, k := range keys {
msg := notify.Message{Severity: notify.Info, Title: "Knoten " + nodeID + " wieder online"}
if since, err := time.Parse(time.RFC3339Nano, k[len(prefix):]); err == nil {
msg.Body = fmt.Sprintf("Knoten *%s* ist nach %s wieder verbunden.", nodeID, outage(time.Since(since)))
} else {
msg.Body = fmt.Sprintf("Knoten *%s* ist wieder verbunden.", nodeID)
}
s.sendOnce(ctx, "recovered:"+k, msg)
}
}
func outage(d time.Duration) string {
switch {
case d < time.Minute:
return fmt.Sprintf("%d s", int(d/time.Second))
case d < time.Hour:
return fmt.Sprintf("%d min", int(d/time.Minute))
default:
return fmt.Sprintf("%d h %d min", int(d/time.Hour), int(d%time.Hour/time.Minute))
}
}
// sendOnce verschickt eine Nachricht genau einmal je Schlüssel; scheitert
// der Versand, wird es beim nächsten Durchlauf erneut versucht.
func (s *Server) sendOnce(ctx context.Context, key string, m notify.Message) {
if s.store.Notified(ctx, key) {
return
}
if err := s.notifier.Send(ctx, m); err != nil {
s.log.Warn("Benachrichtigung nicht zugestellt – nächster Versuch beim nächsten Durchlauf", "key", key, "err", err)
return
}
if err := s.store.RecordNotification(ctx, key); err != nil {
s.log.Error("Benachrichtigung vermerken", "key", key, "err", err)
}
}
func (s *Server) housekeeping(ctx context.Context) {
for name, purge := range map[string]func(context.Context, time.Duration) (int64, error){
"Einladungen": s.store.PurgeEnrollmentTokens,
"Leases": s.store.PurgeLeases,
"Befehle": s.store.PurgeNodeCommands,
} {
if n, err := purge(ctx, retention); err != nil {
s.log.Error("Aufräumen", "was", name, "err", err)
} else if n > 0 {
s.log.Info("Aufgeräumt", "was", name, "anzahl", n)
}
}
}
// recordViolation zählt Verstöße für Checkmk (letzte 24 h, im Speicher).
func (s *Server) recordViolation(at time.Time) {
s.vmu.Lock()
defer s.vmu.Unlock()
s.violations = append(s.violations, at)
}
func (s *Server) violations24h(now time.Time) int {
s.vmu.Lock()
defer s.vmu.Unlock()
cutoff := now.Add(-24 * time.Hour)
i := 0
for i < len(s.violations) && s.violations[i].Before(cutoff) {
i++
}
s.violations = s.violations[i:]
return len(s.violations)
}
// handleCheckmk liefert den Zustand im Checkmk-Local-Check-Format.
func (s *Server) handleCheckmk(w http.ResponseWriter, r *http.Request) {
ctx, now := r.Context(), time.Now()
state := checkmk.State{Violations24h: s.violations24h(now), OfflineCrit: s.cfg.OfflineAlertAfter, Location: time.Local}
tenants, err := s.store.ListTenants(ctx)
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
for _, t := range tenants {
state.Tenants = append(state.Tenants, checkmk.Tenant{
ID: t.ID, Commercial: commercial(t), Status: string(t.Status), ValidUntil: t.ValidUntil,
DunningLevel: policy.DunningSchedule{}.Level(t.PaymentOverdueSince, now), OverdueSince: t.PaymentOverdueSince,
})
}
nodes, err := s.store.ListNodes(ctx, "")
if err != nil {
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
for _, n := range nodes {
cn := checkmk.Node{ID: n.ID, Status: string(n.Status), OfflineSince: n.LastSeen}
if n.Maintenance.Active(now) {
cn.MaintenanceActive, cn.MaintenanceUntil, cn.MaintenanceReason = true, n.Maintenance.Until, n.Maintenance.Reason
}
if l, err := s.store.GetLease(ctx, n.ID); err == nil {
cn.LeaseUntil = l.ValidUntil
}
state.Nodes = append(state.Nodes, cn)
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(checkmk.Render(state, now)))
}