Files
worker/internal/distworker/selfcheck.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

374 строки
12 KiB
Go

package distworker
import (
"context"
"encoding/json"
"fmt"
"log"
mathrand "math/rand/v2"
"net/url"
"sort"
"strconv"
"strings"
"time"
"rocketgit.ru/rsmon/worker/app/models"
"rocketgit.ru/rsmon/worker/app/models/concerns"
"rocketgit.ru/rsmon/worker/internal/checkexec"
"rocketgit.ru/rsmon/worker/internal/notify"
"rocketgit.ru/rsmon/worker/internal/wire"
)
const (
selfcheckInterval = 30 * time.Second
selfcheckTimeoutMillis = 10_000
selfcheckJitterMax = 30 * time.Second
selfcheckProbePath = "/up"
// selfcheckConsensusWait is the time the cluster-level down
// verdict must hold before the selfcheck fires a system alert.
// 5 minutes matches the user-facing requirement: a single
// blip should not page, but a sustained outage must.
selfcheckConsensusWait = 5 * time.Minute
selfcheckDownMessage = "Нет связи с основным api"
selfcheckRecoveryMessage = "Связь с основным api восстановлена"
selfcheckLeaderMessage = "Изменился мастер воркер оповещений"
selfcheckStateOK = "OK"
selfcheckStateWarn = "WARN"
// systemContactKindEmail is the wire-level identifier for email
// system contacts, matching Contact.Kind values from the control plane.
systemContactKindEmail = "email"
// notificationChannel labels are short tags the webapp renders
// alongside each row on /notifications. Kept distinct from the
// systemContactKind* constants so a single contact can be
// reached over multiple channels without an extra column.
notificationChannelSMTP = "smtp"
notificationChannelTelegram = "telegram"
)
// selfcheckState tracks the cluster-level up/down verdict for the
// master API as decided by simple-majority consensus over self +
// reachable peers. Alert fires only after the down verdict has held
// for selfcheckConsensusWait; recovery fires only after a matching
// up verdict has held for the same window.
type selfcheckState struct {
consensus *consensusState
}
// startSelfcheck launches the periodic selfcheck loop. The first tick is
// delayed by a random 0..selfcheckJitterMax so a fleet restart does not
// stampede the main API simultaneously. Returns when ctx is canceled.
func (r *Runner) startSelfcheck(ctx context.Context) {
jitter := time.Duration(mathrand.Int64N(int64(selfcheckJitterMax))) //nolint:gosec // non-cryptographic jitter to stagger fleet probes
select {
case <-time.After(jitter):
case <-ctx.Done():
return
}
state := &selfcheckState{consensus: &consensusState{}}
ticker := time.NewTicker(selfcheckInterval)
defer ticker.Stop()
r.runSelfcheckOnce(ctx, state)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.runSelfcheckOnce(ctx, state)
}
}
}
func (r *Runner) runSelfcheckOnce(ctx context.Context, state *selfcheckState) {
target := selfcheckTarget(r.config.URL)
if target == "" {
return
}
now := time.Now()
ok := runMainAPIHTTPCheck(target)
upFlag := ok
r.SetMasterStatus(&upFlag, now)
peers := r.peerObservationsForConsensus(now)
configuredPeers := len(r.Peers())
// In a multi-worker deployment we need a peer-backed quorum
// (self + at least one fresh peer) before any verdict is
// reported. A lone self vote — peers configured but no fresh
// peer observations yet — is consensusNoQuorum so the
// selfcheck state machine does not start its 5-minute down
// timer on what might be a transient fleet-bootstrap blip.
// The configured peer list itself is the signal that the
// operator wants cross-worker consensus; peerObservations
// above already drops stale/error observations so "configured
// peers" maps 1:1 to "peer observations can be fresh".
minVotes := 1
if configuredPeers > 0 {
minVotes = 2
}
verdict := decideConsensus(&upFlag, peers, minVotes)
up, down, total := tallyConsensus(&upFlag, peers)
voters := consensusVoters(r.WorkerID(), peers)
notificationLeader := consensusNotificationLeader(voters)
log.Printf("worker: selfcheck verdict=%s up=%d down=%d total=%d min_votes=%d target=%s",
verdict, up, down, total, minVotes, target)
if oldLeader, changed := state.consensus.notificationLeaderChanged(notificationLeader); changed && r.isConsensusNotificationLeader(notificationLeader) {
log.Printf("worker: selfcheck notification leader changed old=%s new=%s; firing system alert", oldLeader, notificationLeader)
r.sendSystemAlert(ctx, true, r.formatSelfcheckLeaderMessage(oldLeader, target, verdict, up, down, total, minVotes, configuredPeers, len(peers), notificationLeader, voters))
}
if verdict == consensusDown {
if state.consensus.isDownConsensusHeld(verdict, now) &&
!state.consensus.alertActive {
state.consensus.markDownAlertFired()
if !r.isConsensusNotificationLeader(notificationLeader) {
log.Printf("worker: selfcheck down consensus held %s; notification leader=%s; skipping send", selfcheckConsensusWait, notificationLeader)
return
}
log.Printf("worker: selfcheck down consensus held %s; firing system alert", selfcheckConsensusWait)
r.sendSystemAlert(ctx, true, r.formatSelfcheckMessage(selfcheckDownMessage, target, verdict, up, down, total, minVotes, configuredPeers, len(peers), notificationLeader, voters))
}
return
}
if verdict == consensusUp && state.consensus.shouldFireRecovery(verdict, now) {
state.consensus.markRecoveryFired()
if !r.isConsensusNotificationLeader(notificationLeader) {
log.Printf("worker: selfcheck up consensus held %s; notification leader=%s; skipping recovery", selfcheckConsensusWait, notificationLeader)
return
}
log.Printf("worker: selfcheck up consensus held %s; firing recovery", selfcheckConsensusWait)
r.sendSystemAlert(ctx, false, r.formatSelfcheckMessage(selfcheckRecoveryMessage, target, verdict, up, down, total, minVotes, configuredPeers, len(peers), notificationLeader, voters))
}
}
func (r *Runner) formatSelfcheckLeaderMessage(
oldLeader, target string,
verdict consensusDecision,
up, down, total, minVotes, configuredPeers, freshPeers int,
notificationLeader string,
voters []string,
) string {
return r.formatSelfcheckMessage(
fmt.Sprintf("%s: %s -> %s", selfcheckLeaderMessage, oldLeader, notificationLeader),
target, verdict, up, down, total, minVotes, configuredPeers, freshPeers, notificationLeader, voters,
)
}
func (r *Runner) formatSelfcheckMessage(
message, target string,
verdict consensusDecision,
up, down, total, minVotes, configuredPeers, freshPeers int,
notificationLeader string,
voters []string,
) string {
workerID := r.WorkerID()
if workerID == "" {
workerID = "unknown"
}
region := r.RegionCode()
if region == "" {
region = "unknown"
}
return fmt.Sprintf(
"%s\n\nWorker: %s\nRegion: %s\nTarget: %s\nRaft status: lightweight peer quorum\nNotification leader: %s\nConsensus: %s\nVotes: up=%d down=%d total=%d min_votes=%d\nWorkers seen: %d current votes, %d fresh peers of %d configured peers\nVoters: %s\nHold time: %s",
message, workerID, region, target, notificationLeader, verdict, up, down, total, minVotes, total, freshPeers, configuredPeers, strings.Join(voters, ","), selfcheckConsensusWait,
)
}
func (r *Runner) isConsensusNotificationLeader(leader string) bool {
return leader != "" && r.WorkerID() == leader
}
func consensusVoters(self string, peers []peerObservation) []string {
voters := make([]string, 0, len(peers)+1)
if self != "" {
voters = append(voters, self)
}
for _, peer := range peers {
if peer.WorkerID != "" {
voters = append(voters, peer.WorkerID)
}
}
sort.Strings(voters)
return voters
}
func consensusNotificationLeader(voters []string) string {
if len(voters) == 0 {
return ""
}
return voters[0]
}
func selfcheckTarget(baseURL string) string {
baseURL = strings.TrimRight(baseURL, "/")
if baseURL == "" {
return ""
}
return baseURL + selfcheckProbePath
}
func runMainAPIHTTPCheck(target string) bool {
settings, _ := json.Marshal(models.CheckSettings{
ExpectedAnswer: "default",
RequestMethod: "GET",
Timeout: selfcheckTimeoutMillis,
SlowTime: selfcheckTimeoutMillis,
})
name := "main api selfcheck"
monitor := &models.Monitor{Host: selfcheckHost(target)}
check := models.Check{
ID: -1,
Name: &name,
Kind: "http", //nolint:goconst // check kind is a wire string, not the http package identifier
Interval: int(selfcheckInterval.Seconds()),
URL: &target,
Settings: settings,
}
results := checkexec.Execute(monitor, []models.Check{check})
if len(results) == 0 {
return false
}
state := results[0].Result.State
return state == selfcheckStateOK || state == selfcheckStateWarn
}
func selfcheckHost(target string) string {
parsed, err := url.Parse(target)
if err != nil || parsed.Host == "" {
return target
}
return parsed.Host
}
// sendSystemAlert notifies all cached system contacts using cached credentials.
// failure=true means "down" message, false means "recovered".
func (r *Runner) sendSystemAlert(_ context.Context, failure bool, message string) {
creds := r.Credentials()
if creds == nil {
log.Printf("worker: selfcheck alert skipped: no credentials")
return
}
contacts := r.SystemContacts()
if len(contacts) == 0 {
log.Printf("worker: selfcheck alert skipped: no system contacts")
return
}
subject := "RSMon worker alert"
if !failure {
subject = "RSMon worker recovery"
}
for i := range contacts {
c := contacts[i]
switch c.Kind {
case systemContactKindEmail:
if len(creds.SMTP) == 0 {
continue
}
cred := smtpCredToModel(&creds.SMTP[0])
now := time.Now().UTC()
row := &NotificationRow{
Kind: c.Kind,
Channel: notificationChannelSMTP,
Subject: subject,
Body: message,
At: now,
}
if err := notify.Email(cred, c.Value, subject, message, ""); err != nil {
log.Printf("worker: selfcheck email to %s failed: %v", c.Value, err)
row.OK = false
row.Error = err.Error()
} else {
row.OK = true
}
r.RecordNotification(row)
case "telegram_private", "telegram_group":
if len(creds.Telegram) == 0 {
continue
}
chatID, err := parseTelegramChatID(c.Value)
if err != nil {
log.Printf("worker: selfcheck telegram chat_id parse failed for %s: %v", c.Value, err)
continue
}
cred := telegramCredToModel(creds.Telegram[0])
body := fmt.Sprintf("%s\n\n%s", subject, message)
now := time.Now().UTC()
row := &NotificationRow{
Kind: c.Kind,
Channel: notificationChannelTelegram,
Subject: subject,
Body: message,
At: now,
}
if err := notify.Telegram(cred, chatID, body); err != nil {
log.Printf("worker: selfcheck telegram to %s failed: %v", c.Value, err)
row.OK = false
row.Error = err.Error()
} else {
row.OK = true
}
r.RecordNotification(row)
}
}
}
// parseTelegramChatID converts a numeric telegram chat id stored as a
// string into an int64.
func parseTelegramChatID(raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("empty chat id")
}
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("parse chat id: %w", err)
}
return id, nil
}
// smtpCredToModel wraps a wire SMTP credential in a models.NotificationCredential.
// SecretEnc carries the plaintext with the "plain:" prefix so models.GetSecret
// returns it unchanged — workers do not have the encryption key configured.
func smtpCredToModel(c *wire.SMTPCredential) *models.NotificationCredential {
port := c.Port
enabled := true
return &models.NotificationCredential{
Model: concerns.Model{ID: c.ID},
Kind: models.CredentialKindSMTP,
Name: c.Name,
Server: &c.Server,
Port: &port,
Login: &c.Login,
FromName: &c.FromName,
FromAddr: &c.FromAddress,
InsecureSkipVerify: c.InsecureSkipVerify,
Enabled: &enabled,
SecretEnc: "plain:" + c.Password,
}
}
// telegramCredToModel wraps a wire Telegram credential in a
// models.NotificationCredential. SecretEnc carries the plaintext with the
// "plain:" prefix so models.GetSecret returns it unchanged.
func telegramCredToModel(c wire.TelegramCredential) *models.NotificationCredential {
enabled := true
cred := &models.NotificationCredential{
Model: concerns.Model{ID: c.ID},
Kind: models.CredentialKindTelegram,
Name: c.Name,
BotName: &c.BotName,
APIURL: &c.APIURL,
Enabled: &enabled,
SecretEnc: "plain:" + c.Token,
}
return cred
}