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
@@ -0,0 +1,229 @@
|
||||
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)))
|
||||
}
|
||||
Reference in new issue
Block a user