From d0ef5b87a1630c01d223cb27cb5fd36e6597aaea Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:19:04 +0000 Subject: [PATCH] refactor: modernize notification system and modularize architecture This commit implements a comprehensive refactoring of the Healthy API to improve its structural integrity and maintainability as requested. Key changes: - Introduced `NotificationMetadata` struct in the `model` package to centralize alert context (ServiceName, URL, Reason, Status, etc.). - Updated all notifiers (Mail, SMS, Payamak, Webhook) to use the new metadata structure. - Refactored the templating engine to support nested access to metadata (e.g., `{{.Metadata.ServiceName}}`). - Modularized `main.go` by moving notifier and condition loading logic into a new `loader` package. - Refactored `healthcheck` package to support graceful shutdown via `context.Context` and improved logic separation. - Cleaned up dead code and improved logging consistency using `slog`. - Updated tests to reflect the new structure and verified nested template access. Co-authored-by: mosishon <61285975+mosishon@users.noreply.github.com> --- healthcheck/health-check.go | 279 -------------------------- healthcheck/healthcheck.go | 128 ++++++++++++ loader/condition.go | 25 +++ loader/notifier.go | 121 ++++++++++++ main.go | 293 +++++++++------------------- model/notification.go | 16 +- model/webhook.go | 5 +- notifier/mail.go | 20 +- notifier/meli_payamak.go | 17 +- notifier/sms.go | 10 +- notifier/webhook.go | 15 +- notifier/webhook_templating_test.go | 17 +- notifier/webhook_test.go | 15 +- 13 files changed, 430 insertions(+), 531 deletions(-) delete mode 100644 healthcheck/health-check.go create mode 100644 healthcheck/healthcheck.go create mode 100644 loader/condition.go create mode 100644 loader/notifier.go diff --git a/healthcheck/health-check.go b/healthcheck/health-check.go deleted file mode 100644 index 0fa9533..0000000 --- a/healthcheck/health-check.go +++ /dev/null @@ -1,279 +0,0 @@ -package healthcheck - -import ( - "healthy-api/model" - "healthy-api/notifier" - "healthy-api/registry" - "io" - "log/slog" - "net/http" - "time" - "fmt" -) - -type HealthChecker struct { - Service model.Service - NotifierRegistry *registry.Registry[notifier.Notifier] - ConditionRegistry *registry.Registry[model.Condition] - Client *http.Client - Logger *slog.Logger -} - -// func (h *HealthChecker) Start() { -// h.Logger.Printf("Health checker started for: %s[%s]", h.Service.Name, h.Service.URL) -// failureCount := 0 -// for { -// start := time.Now() -// request, err := http.NewRequest("GET", h.Service.URL, nil) -// if err != nil { -// h.Logger.Printf("error while creating request in %v %v.\n", h.Service, err) -// break -// } -// resp, err := h.Client.Do(request) -// requestDuration := time.Since(start) -// if err != nil { -// h.Logger.Printf("error while sending request in %v %v.\n", h.Service, err) -// time.Sleep(time.Duration(h.Service.SleepOnFail) * time.Second) -// continue -// } -// h.Logger.Printf("Request [GET] sent to %s, status code:%d, time:%v\n", h.Service.URL, resp.StatusCode, requestDuration) -// bodyData, err := io.ReadAll(resp.Body) -// if err != nil { -// h.Logger.Printf("Cant read body for %v \n", resp) -// time.Sleep(time.Duration(h.Service.SleepOnFail) * time.Second) -// continue -// } -// resp.Body.Close() -// cond, ok := h.ConditionRegistry.Get(h.Service.ConditionName) -// if !ok { -// h.Logger.Printf("Condition with id %s not found\n", h.Service.ConditionName) -// return -// } -// h.Logger.Printf("Evaluating Condition : %s for service : %s\n", h.Service.ConditionName, h.Service.Name) -// if !cond.Evaluate(resp, bodyData,requestDuration) { -// failureCount++ - -// h.Logger.Printf("Evaluating Condition : %s for service : %s is DONE and failed. response code: %d, time: %v\n", h.Service.ConditionName, h.Service.Name, resp.StatusCode, requestDuration) -// if failureCount >= h.Service.Threshold { -// h.Logger.Printf("Threshold reached for %s! Sending notifications...\n", h.Service.Name) -// for _, target := range h.Service.Targets { -// notifierInst, ok := h.NotifierRegistry.Get(target.NotifierID) -// if ok == false { -// h.Logger.Printf("notifier with id %s not found\n", target.NotifierID) -// continue -// } -// err := notifierInst.Notify(model.Notification{ -// ServiceName: h.Service.Name, -// Recipients: target.Recipients, -// }) -// if err != nil { -// h.Logger.Printf("Failed to Notify using %v,%v\n", notifierInst.GetName(), err) -// } -// } -// h.Logger.Printf("[SLEEP] sleeping for %d.\n", h.Service.SleepOnFail) -// time.Sleep(time.Duration(h.Service.SleepOnFail) * time.Second) -// }else { -// time.Sleep(time.Duration(h.Service.CheckPeriod) * time.Second) -// } -// } else { -// if failureCount > 0 { -// h.Logger.Printf("Service %s is healthy again. Resetting failure count.\n", h.Service.Name) -// } -// failureCount = 0 -// h.Logger.Printf("Evaluating Condition : %s for service : %s is DONE and successfull.response code is : %d\n", h.Service.ConditionName, h.Service.Name, resp.StatusCode) - -// h.Logger.Printf("[SLEEP] sleeping for %d.\n", h.Service.CheckPeriod) - -// time.Sleep(time.Duration(h.Service.CheckPeriod) * time.Second) -// } - -// } -// } -// func (h *HealthChecker) Start() { -// h.Logger.Printf("Started for: %s", h.Service.Name) -// failureCount := 0 - -// for { -// start := time.Now() -// request, err := http.NewRequest("GET", h.Service.URL, nil) - -// var resp *http.Response -// var bodyData []byte - -// evaluationRes := model.EvaluationResult{ -// IsHealthy: false, -// Reason: "Unknown error", -// } -// if h.Service.UserAgent != "" { -// request.Header.Set("User-Agent", h.Service.UserAgent) -// } else { -// h.Logger.Printf("[!] Using default user agent") -// request.Header.Set("User-Agent", "HealthyAPI(M.A)/1.0") -// } - -// // ارسال درخواست -// if err == nil { -// resp, err = h.Client.Do(request) -// } - -// requestDuration := time.Since(start) - -// if err != nil { -// // در صورت خطای شبکه (مثل خاموش بودن سرور مقصد) -// evaluationRes.Reason = fmt.Sprintf("Network/Connection Error: %v", err) -// } else if resp != nil { -// // خواندن بادی و بستن فوری آن برای جلوگیری از Memory Leak -// bodyData, _ = io.ReadAll(resp.Body) -// resp.Body.Close() - -// cond, ok := h.ConditionRegistry.Get(h.Service.ConditionName) -// if ok { -// // ارزیابی تمام شرط‌ها و گرفتن دلیل (Reason) -// evaluationRes = cond.Evaluate(resp, bodyData, requestDuration) -// } else { -// evaluationRes.Reason = "Condition registry not found" -// } -// } - -// if !evaluationRes.IsHealthy { -// failureCount++ - -// sCode := 0 -// if resp != nil { -// sCode = resp.StatusCode -// } - -// // چاپ در کنسول با ذکر دلیل دقیق -// h.Logger.Printf("FAIL %s [%d/%d] - Status: %d, Time: %v, Reason: %s", -// h.Service.Name, failureCount, h.Service.Threshold, sCode, requestDuration, evaluationRes.Reason) - -// if failureCount >= h.Service.Threshold { -// h.Logger.Printf("Threshold reached for %s. Sending notifications...", h.Service.Name) - -// for _, target := range h.Service.Targets { -// if n, ok := h.NotifierRegistry.Get(target.NotifierID); ok { -// _ = n.Notify(model.Notification{ -// ServiceName: h.Service.Name, -// Recipients: target.Recipients, -// Reason: evaluationRes.Reason, -// StatusCode: sCode, -// ResponseTime: requestDuration.Round(time.Millisecond).String(), - -// }) -// } -// } - -// time.Sleep(time.Duration(h.Service.SleepOnFail) * time.Second) -// failureCount = 0 // ریست کردن پس از اطلاع‌رسانی برای شروع سیکل جدید -// } else { -// time.Sleep(time.Duration(h.Service.CheckPeriod) * time.Second) -// } -// } else { -// // اگر سرویس سالم بود -// if failureCount > 0 { -// h.Logger.Printf("Service %s is back to NORMAL after %d failures", h.Service.Name, failureCount) -// } -// failureCount = 0 - -// h.Logger.Printf("SUCCESS %s - Time: %v", h.Service.Name, requestDuration) - -// time.Sleep(time.Duration(h.Service.CheckPeriod) * time.Second) -// } -// } -// } -func (h *HealthChecker) Start() { - h.Logger.Info("checker_started", "service", h.Service.Name) - failureCount := 0 - - for { - start := time.Now() - request, err := http.NewRequest("GET", h.Service.URL, nil) - - var resp *http.Response - var bodyData []byte - - evaluationRes := model.EvaluationResult{ - IsHealthy: false, - Reason: "Unknown error", - } - - if h.Service.UserAgent != "" { - request.Header.Set("User-Agent", h.Service.UserAgent) - } else { - h.Logger.Warn("using_default_user_agent") - request.Header.Set("User-Agent", "HealthyAPI(M.A)/1.0") - } - - if err == nil { - resp, err = h.Client.Do(request) - } - - requestDuration := time.Since(start) - sCode := 0 - - if err != nil { - evaluationRes.Reason = fmt.Sprintf("Network/Connection Error: %v", err) - } else if resp != nil { - sCode = resp.StatusCode - - bodyData, _ = io.ReadAll(resp.Body) - resp.Body.Close() - - cond, ok := h.ConditionRegistry.Get(h.Service.ConditionName) - if ok { - evaluationRes = cond.Evaluate(resp, bodyData, requestDuration) - } else { - evaluationRes.Reason = "Condition registry not found" - } - } - - if !evaluationRes.IsHealthy { - failureCount++ - - if resp != nil { - sCode = resp.StatusCode - } - - h.Logger.Warn("health_check_failed", - "service", h.Service.Name, - "attempt", failureCount, - "threshold", h.Service.Threshold, - "status", sCode, - "duration", requestDuration, - "reason", evaluationRes.Reason) - - if failureCount >= h.Service.Threshold { - h.Logger.Error("threshold_reached", "service", h.Service.Name, "action", "sending_notifications") - - for _, target := range h.Service.Targets { - if n, ok := h.NotifierRegistry.Get(target.NotifierID); ok { - _ = n.Notify(model.Notification{ - ServiceName: h.Service.Name, - Recipients: target.Recipients, - Reason: evaluationRes.Reason, - StatusCode: sCode, - ResponseTime: requestDuration.Round(time.Millisecond).String(), - }) - } - } - - time.Sleep(time.Duration(h.Service.SleepOnFail) * time.Second) - failureCount = 0 - } else { - time.Sleep(time.Duration(h.Service.CheckPeriod) * time.Second) - } - } else { - if failureCount > 0 { - h.Logger.Info("service_recovery", "service", h.Service.Name, "after_failures", failureCount) - } - failureCount = 0 - - h.Logger.Info("health_check_success", "service", h.Service.Name, "duration", requestDuration,"status_code",sCode) - - time.Sleep(time.Duration(h.Service.CheckPeriod) * time.Second) - } - } -} -func (h *HealthChecker) StartInBackground() { - go h.Start() -} diff --git a/healthcheck/healthcheck.go b/healthcheck/healthcheck.go new file mode 100644 index 0000000..fe3f07d --- /dev/null +++ b/healthcheck/healthcheck.go @@ -0,0 +1,128 @@ +package healthcheck + +import ( + "context" + "fmt" + "healthy-api/model" + "healthy-api/notifier" + "healthy-api/registry" + "io" + "log/slog" + "net/http" + "time" +) + +type HealthChecker struct { + Service model.Service + NotifierRegistry *registry.Registry[notifier.Notifier] + ConditionRegistry *registry.Registry[model.Condition] + Client *http.Client + Logger *slog.Logger +} + +func (h *HealthChecker) Start(ctx context.Context) { + h.Logger.Info("checker_started", "service", h.Service.Name) + failureCount := 0 + + for { + waitDuration := time.Duration(h.Service.CheckPeriod) * time.Second + + h.performCheck(&failureCount, &waitDuration) + + select { + case <-ctx.Done(): + return + case <-time.After(waitDuration): + // continue loop + } + } +} + +func (h *HealthChecker) performCheck(failureCount *int, nextWait *time.Duration) { + start := time.Now() + request, err := http.NewRequest("GET", h.Service.URL, nil) + + var resp *http.Response + var bodyData []byte + + evaluationRes := model.EvaluationResult{ + IsHealthy: false, + Reason: "Unknown error", + } + + if h.Service.UserAgent != "" { + request.Header.Set("User-Agent", h.Service.UserAgent) + } else { + request.Header.Set("User-Agent", "HealthyAPI(M.A)/1.0") + } + + if err == nil { + resp, err = h.Client.Do(request) + } + + requestDuration := time.Since(start) + sCode := 0 + + if err != nil { + evaluationRes.Reason = fmt.Sprintf("Network/Connection Error: %v", err) + } else if resp != nil { + sCode = resp.StatusCode + + bodyData, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + cond, ok := h.ConditionRegistry.Get(h.Service.ConditionName) + if ok { + evaluationRes = cond.Evaluate(resp, bodyData, requestDuration) + } else { + evaluationRes.Reason = "Condition registry not found" + } + } + + if !evaluationRes.IsHealthy { + *failureCount++ + + h.Logger.Warn("health_check_failed", + "service", h.Service.Name, + "attempt", *failureCount, + "threshold", h.Service.Threshold, + "status", sCode, + "duration", requestDuration, + "reason", evaluationRes.Reason) + + if *failureCount >= h.Service.Threshold { + h.Logger.Error("threshold_reached", "service", h.Service.Name, "action", "sending_notifications") + + metadata := model.NotificationMetadata{ + ServiceName: h.Service.Name, + ServiceURL: h.Service.URL, + Reason: evaluationRes.Reason, + StatusCode: sCode, + ResponseTime: requestDuration.Round(time.Millisecond).String(), + Timestamp: time.Now().Format(time.RFC3339), + } + + for _, target := range h.Service.Targets { + if n, ok := h.NotifierRegistry.Get(target.NotifierID); ok { + _ = n.Notify(model.Notification{ + Metadata: metadata, + Recipients: target.Recipients, + }) + } + } + + *nextWait = time.Duration(h.Service.SleepOnFail) * time.Second + *failureCount = 0 // Reset after notification as per original logic + } + } else { + if *failureCount > 0 { + h.Logger.Info("service_recovery", "service", h.Service.Name, "after_failures", *failureCount) + } + *failureCount = 0 + h.Logger.Info("health_check_success", "service", h.Service.Name, "duration", requestDuration, "status_code", sCode) + } +} + +func (h *HealthChecker) StartInBackground(ctx context.Context) { + go h.Start(ctx) +} diff --git a/loader/condition.go b/loader/condition.go new file mode 100644 index 0000000..c8d0b89 --- /dev/null +++ b/loader/condition.go @@ -0,0 +1,25 @@ +package loader + +import ( + "healthy-api/model" + "healthy-api/registry" + "log/slog" +) + +func LoadConditions(cfg *model.Config, reg *registry.Registry[model.Condition], logger *slog.Logger) int { + count := 0 + for _, cond := range cfg.Conditions { + if _, ok := reg.Get(cond.ID); ok { + logger.Error("condition_already_exists", "id", cond.ID) + continue + } + if err := cond.Condition.Validate("conditions.condition"); err != nil { + logger.Error("invalid_condition", "id", cond.ID, "error", err) + continue + } + reg.Register(cond.ID, *cond.Condition) + logger.Info("condition_registered", "id", cond.ID) + count++ + } + return count +} diff --git a/loader/notifier.go b/loader/notifier.go new file mode 100644 index 0000000..388d6a2 --- /dev/null +++ b/loader/notifier.go @@ -0,0 +1,121 @@ +package loader + +import ( + "healthy-api/model" + "healthy-api/notifier" + "healthy-api/registry" + "log/slog" + "net/http" + "time" +) + +func LoadNotifiers(cfg *model.Config, reg *registry.Registry[notifier.Notifier], logger *slog.Logger) map[string]int { + counts := make(map[string]int) + + counts["ippanel"] = loadIPPanelNotifiers(cfg, reg, logger) + counts["meli_payamak"] = loadPayamakPanels(cfg, reg, logger) + counts["smtp"] = loadSMTPNotifiers(cfg, reg, logger) + counts["webhook"] = loadWebhookNotifiers(cfg, reg, logger) + + return counts +} + +func loadIPPanelNotifiers(cfg *model.Config, reg *registry.Registry[notifier.Notifier], logger *slog.Logger) int { + count := 0 + for _, ippanel := range cfg.Notifiers.IPPanels { + if _, ok := reg.Get(ippanel.ID); ok { + logger.Error("notifier_already_exists", "id", ippanel.ID, "type", "ippanel") + continue + } + notifierInst := ¬ifier.SMSNotifier{ + User: ippanel.User, + Pass: ippanel.Pass, + URL: ippanel.Url, + Logger: logger, + } + reg.Register(ippanel.ID, notifierInst) + logger.Info("notifier_registered", "type", "ippanel", "id", ippanel.ID) + count++ + } + return count +} + +func loadPayamakPanels(cfg *model.Config, reg *registry.Registry[notifier.Notifier], logger *slog.Logger) int { + count := 0 + for _, pp := range cfg.Notifiers.MeliPayamakPanels { + if _, ok := reg.Get(pp.ID); ok { + logger.Error("notifier_already_exists", "id", pp.ID, "type", "meli_payamak") + continue + } + notifierInst := ¬ifier.PayamakNotifier{ + Username: pp.Username, + Password: pp.Password, + Sender: pp.Sender, + Template: pp.Template, + Logger: logger, + } + reg.Register(pp.ID, notifierInst) + logger.Info("notifier_registered", "type", "meli_payamak", "id", pp.ID) + count++ + } + return count +} + +func loadSMTPNotifiers(cfg *model.Config, reg *registry.Registry[notifier.Notifier], logger *slog.Logger) int { + count := 0 + for _, smtp := range cfg.Notifiers.SMTPs { + if _, ok := reg.Get(smtp.ID); ok { + logger.Error("notifier_already_exists", "id", smtp.ID, "type", "smtp") + continue + } + notifierInst := ¬ifier.MailNotifier{ + Sender: smtp.Sender, + Server: smtp.Server, + Port: smtp.Port, + Password: smtp.Password, + Logger: logger, + } + reg.Register(smtp.ID, notifierInst) + logger.Info("notifier_registered", "type", "smtp", "id", smtp.ID) + count++ + } + return count +} + +func loadWebhookNotifiers(cfg *model.Config, reg *registry.Registry[notifier.Notifier], logger *slog.Logger) int { + count := 0 + for _, wh := range cfg.Notifiers.Webhook { + if _, ok := reg.Get(wh.ID); ok { + logger.Error("notifier_already_exists", "id", wh.ID, "type", "webhook") + continue + } + if err := checkTemplate(wh.JSON); err != nil { + logger.Error("invalid_json_template", "id", wh.ID, "error", err) + continue + } + if err := checkTemplate(wh.Headers); err != nil { + logger.Error("invalid_headers_template", "id", wh.ID, "error", err) + continue + } + notifierInst := ¬ifier.WebhookNotifier{ + HookData: wh, + Client: &http.Client{Timeout: time.Second * 15}, + Logger: logger, + } + reg.Register(wh.ID, notifierInst) + logger.Info("notifier_registered", "type", "webhook", "id", wh.ID) + count++ + } + return count +} + +func checkTemplate(templ map[string]interface{}) error { + _, err := notifier.FillTemplate(templ, model.WebhookTemplate{ + Metadata: model.NotificationMetadata{ + ServiceName: "test", + Timestamp: "Test", + }, + URL: "test", + }) + return err +} diff --git a/main.go b/main.go index aec5b25..c543702 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,7 @@ package main import ( - "encoding/json" + "context" "flag" "fmt" "io" @@ -9,257 +9,148 @@ import ( "log/slog" "net/http" "os" + "os/signal" "sync" + "syscall" "time" "healthy-api/config" "healthy-api/healthcheck" + "healthy-api/loader" "healthy-api/model" "healthy-api/notifier" "healthy-api/registry" ) -var configPath string -var verbose bool +var ( + configPath string + verbose bool +) func init() { flag.StringVar(&configPath, "config", "", "Path to the configurations file.") - flag.BoolVar(&verbose, "verbose", false, "showing logs or no.") + flag.BoolVar(&verbose, "verbose", false, "Enable verbose logging.") } -func loadPayamakPanels(cfg *model.Config, notifierRegistry *registry.Registry[notifier.Notifier], logger *slog.Logger) int { - payamakCount := 0 - for _, pp := range cfg.Notifiers.MeliPayamakPanels { - _, ok := notifierRegistry.Get(pp.ID) - if ok { - logger.Error("notifier already exists","name",pp.ID) - - } +func main() { + flag.Parse() - notifierInst := ¬ifier.PayamakNotifier{ - Username: pp.Username, - Password: pp.Password, - Sender: pp.Sender, - Template: pp.Template, - Logger: logger, - } - - payamakCount++ - notifierRegistry.Register(pp.ID, notifierInst) + if configPath == "" { + fmt.Println("🚨 Missing required flag: -config") + flag.Usage() + os.Exit(1) } - return payamakCount -} -func loadIPPanelNotifiers(cfg *model.Config, notifierRegistry *registry.Registry[notifier.Notifier], logger *slog.Logger) int { - ippanelCount := 0 - for _, ippanel := range cfg.Notifiers.IPPanels { - _, ok := notifierRegistry.Get(ippanel.ID) - if ok == true { - log.Fatalf("notifier with name %s already exists", ippanel.ID) - } - notifierInst := ¬ifier.SMSNotifier{ - User: ippanel.User, - Pass: ippanel.Pass, - URL: ippanel.Url, - Logger: logger, - } - ippanelCount++ + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } - notifierRegistry.Register(ippanel.ID, notifierInst) - logger.Info("notifier_registered", - "type", "ippanel", - "details", notifierInst, - ) + logger, logFile := setupLogger(verbose) + if logFile != nil { + defer logFile.Close() } - return ippanelCount -} + slog.SetDefault(logger) -func loadSMTPNotifiers(cfg *model.Config, notifierRegistry *registry.Registry[notifier.Notifier], logger *slog.Logger) int { - smtpCount := 0 + notifierRegistry := registry.NewRegistry[notifier.Notifier]() + conditionRegistry := registry.NewRegistry[model.Condition]() - for _, smtp := range cfg.Notifiers.SMTPs { - _, ok := notifierRegistry.Get(smtp.ID) - if ok == true { - log.Fatalf("notifier with name %s already exists", smtp.ID) - } - notifierInst := ¬ifier.MailNotifier{ - Sender: smtp.Sender, - Server: smtp.Server, - Port: smtp.Port, - Password: smtp.Password, - Logger: logger, - } - smtpCount++ + notifierCounts := loader.LoadNotifiers(cfg, notifierRegistry, logger) + conditionCount := loader.LoadConditions(cfg, conditionRegistry, logger) - notifierRegistry.Register(smtp.ID, notifierInst) - logger.Info("notifier_registered", - "type", "smtp", - "details", notifierInst, - ) - } - return smtpCount -} + printSummary(notifierCounts, conditionCount, len(cfg.Services)) -func checkTemplate(templ map[string]interface{}) error { - _, err := notifier.FillTemplate(templ, model.WebhookTemplate{ - ServiceName: "test", - TimeStamp: "Test", - URL: "test", - }) - return err + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() -} -func loadWebhookNotifiers(cfg *model.Config, notifierRegistry *registry.Registry[notifier.Notifier], logger *slog.Logger) int { - whCount := 0 - for _, wh := range cfg.Notifiers.Webhook { - _, ok := notifierRegistry.Get(wh.ID) - if ok == true { - logger.Error("notifier_already_exists", "id", wh.ID) - os.Exit(1) } - err := checkTemplate(wh.JSON) - if err != nil { - logger.Error("invalid_template", "id", wh.ID, "error", err) - os.Exit(1) } - err = checkTemplate(wh.Headers) - if err != nil { - logger.Error("invalid_headers_template", "id", wh.ID, "error", err) - os.Exit(1) } - notifierInst := ¬ifier.WebhookNotifier{ - HookData: wh, - Client: &http.Client{Timeout: time.Second * 15}, - Logger: logger, + var wg sync.WaitGroup + for _, svc := range cfg.Services { + if !validateService(svc, notifierRegistry, conditionRegistry, logger) { + continue } - whCount++ - notifierRegistry.Register(wh.ID, notifierInst) - logger.Info("notifier_registered", - "type", "webhook", - "id", wh.ID, // فرض بر اینکه notifierInst فیلد ID دارد - "config", notifierInst, // کل تنظیمات را هم در یک فیلد دیگر نگه می‌دارد - ) - } - return whCount -} -func PrintCondition(cond *model.Condition) { - bytes, err := json.MarshalIndent(cond, "", " ") - if err != nil { - fmt.Println("Error marshalling condition:", err) - return + hc := &healthcheck.HealthChecker{ + Service: svc, + NotifierRegistry: notifierRegistry, + ConditionRegistry: conditionRegistry, + Client: &http.Client{ + Timeout: 15 * time.Second, + }, + Logger: logger, + } + + wg.Add(1) + go func(s model.Service) { + defer wg.Done() + runHealthCheck(ctx, hc) + logger.Info("checker_stopped", "service", s.Name, "url", s.URL) + }(svc) } - fmt.Println(string(bytes)) -} -func loadConditions(cfg *model.Config, conditionRegistry *registry.Registry[model.Condition], logger *slog.Logger) int { - cCound := 0 - for _, cond := range cfg.Conditions { + <-ctx.Done() + logger.Info("shutting_down", "message", "waiting for workers to finish...") - _, ok := conditionRegistry.Get(cond.ID) - if ok == true { - logger.Error("condition_already_exists", "id", cond.ID) - os.Exit(1) } - if err := cond.Condition.Validate("conditions.condition"); err != nil { - logger.Error("invalid_error_condition", "error", err) - os.Exit(1) } - cCound++ - conditionRegistry.Register(cond.ID, *cond.Condition) + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + logger.Info("shutdown_complete") + case <-time.After(10 * time.Second): + logger.Warn("shutdown_timeout", "message", "some workers did not stop in time") } - return cCound } -// TODO We need gracefull shutdown for goroutines. -func main() { - - flag.Parse() - if configPath == "" { - fmt.Println("🚨 Missing required flag: -config") - fmt.Println() - flag.Usage() - os.Exit(1) - } - var wg sync.WaitGroup - println("Reading config file.") - cfg, err := config.LoadConfig(configPath) - if err != nil { - log.Fatalf("failed to load config: %v", err) - } - - notifierRegistry := registry.NewRegistry[notifier.Notifier]() - conditionRegistry := registry.NewRegistry[model.Condition]() +func setupLogger(verbose bool) (*slog.Logger, *os.File) { logFile, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) if err != nil { log.Fatalf("failed to open log file: %v", err) } - defer logFile.Close() var logOutput io.Writer if verbose { logOutput = io.MultiWriter(os.Stdout, logFile) } else { - logOutput = logFile + logOutput = logFile } handler := slog.NewTextHandler(logOutput, &slog.HandlerOptions{ - Level: slog.LevelInfo, // می‌توانید بر اساس فلگ verbose لول را تغییر دهید - }) + Level: slog.LevelInfo, + }) - logger := slog.New(handler) - - slog.SetDefault(logger) - ippanelCount := loadIPPanelNotifiers(cfg, notifierRegistry, logger) - meliPayamakCount := loadPayamakPanels(cfg, notifierRegistry, logger) - smtpCount := loadSMTPNotifiers(cfg, notifierRegistry, logger) - whCount := loadWebhookNotifiers(cfg, notifierRegistry, logger) - fmt.Println() - fmt.Println("---------NOTIFIERS-----------") - fmt.Printf("%d ippanel regisered.\n", ippanelCount) - fmt.Printf("%d meli_payamak_panel registered.\n", meliPayamakCount) - fmt.Printf("%d smtp regisered.\n", smtpCount) - fmt.Printf("%d webhook regisered.\n", whCount) - fmt.Println("---------NOTIFIERS-----------") - fmt.Println() - cCount := loadConditions(cfg, conditionRegistry, logger) - fmt.Printf("%d condition found.\n\n", cCount) + return slog.New(handler), logFile +} - fmt.Printf("%d service found.\n\n", len(cfg.Services)) - for n, svc := range cfg.Services { - n++ - fmt.Printf("Service [%d]: %s\n", n, svc.Name) - fmt.Println(" URL:", svc.URL) - fmt.Println(" Period:", svc.CheckPeriod) - fmt.Println(" Condition id:", svc.ConditionName) - fmt.Println(" SleepOnFail:", svc.SleepOnFail) - fmt.Println(" Targets count:", len(svc.Targets)) - fmt.Println(" User-Agent:", svc.UserAgent) - fmt.Println(" Threshold:", svc.Threshold) - - fmt.Println("----") - for _, v := range svc.Targets { - _, ok := notifierRegistry.Get(v.NotifierID) - if ok == false { - fmt.Printf("\n\n[ERROR] notifier with id: '%s' not found.for service: `%s`\n\n\n", v.NotifierID, svc.Name) - os.Exit(1) - } +func validateService(svc model.Service, nr *registry.Registry[notifier.Notifier], cr *registry.Registry[model.Condition], logger *slog.Logger) bool { + if _, ok := cr.Get(svc.ConditionName); !ok { + logger.Error("condition_not_found", "service", svc.Name, "condition_id", svc.ConditionName) + return false + } + for _, target := range svc.Targets { + if _, ok := nr.Get(target.NotifierID); !ok { + logger.Error("notifier_not_found", "service", svc.Name, "notifier_id", target.NotifierID) + return false } + } + return true +} - hc := healthcheck.HealthChecker{ - Service: svc, - NotifierRegistry: notifierRegistry, - ConditionRegistry: conditionRegistry, - Client: &http.Client{ - Timeout: time.Duration(15) * time.Second, - }, - Logger: logger, - } - wg.Add(1) - go func() { - defer wg.Done() - hc.Start() - fmt.Printf("chcker for %s[%s] stopped", svc.Name, svc.URL) - }() +func printSummary(notifierCounts map[string]int, conditionCount int, serviceCount int) { + fmt.Println() + fmt.Println("--------- CONFIG SUMMARY -----------") + for t, c := range notifierCounts { + fmt.Printf("%d %s registered.\n", c, t) } + fmt.Printf("%d conditions found.\n", conditionCount) + fmt.Printf("%d services found.\n", serviceCount) + fmt.Println("------------------------------------") + fmt.Println() +} - println("Wating for all workers to finish their work.") - wg.Wait() +func runHealthCheck(ctx context.Context, hc *healthcheck.HealthChecker) { + hc.Start(ctx) } diff --git a/model/notification.go b/model/notification.go index 3f9a507..dd339d2 100644 --- a/model/notification.go +++ b/model/notification.go @@ -1,9 +1,15 @@ package model +type NotificationMetadata struct { + ServiceName string + ServiceURL string + Reason string + StatusCode int + ResponseTime string + Timestamp string +} + type Notification struct { - ServiceName string - Recipients []string - Reason string - StatusCode int - ResponseTime string + Metadata NotificationMetadata + Recipients []string } diff --git a/model/webhook.go b/model/webhook.go index 179ad7a..7885be1 100644 --- a/model/webhook.go +++ b/model/webhook.go @@ -8,7 +8,6 @@ type Webhook struct { } type WebhookTemplate struct { - ServiceName string - TimeStamp string - URL string + Metadata NotificationMetadata + URL string // This is the recipient URL } diff --git a/notifier/mail.go b/notifier/mail.go index 40b915e..951b96d 100644 --- a/notifier/mail.go +++ b/notifier/mail.go @@ -4,9 +4,8 @@ import ( "bytes" "fmt" "healthy-api/model" - "net/smtp" "log/slog" - + "net/smtp" ) type MailNotifier struct { @@ -17,23 +16,28 @@ type MailNotifier struct { Logger *slog.Logger } -func (m *MailNotifier) CreateMessage(serviceName string, to string, subject string) string { - return fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\n\nService **%s** is not working good check it fast please.", m.Sender, to, subject, serviceName) +func (m *MailNotifier) CreateMessage(metadata model.NotificationMetadata, to string, subject string) string { + return fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\n\nService **%s** (%s) is not working good.\nReason: %s\nStatus Code: %d\nResponse Time: %s\nTimestamp: %s\nCheck it fast please.", + m.Sender, to, subject, metadata.ServiceName, metadata.ServiceURL, metadata.Reason, metadata.StatusCode, metadata.ResponseTime, metadata.Timestamp) } + func (m *MailNotifier) GetName() string { return fmt.Sprintf("MailNotifier(%s)", m.Server) } + func (m *MailNotifier) Notify(n model.Notification) error { auth := smtp.PlainAuth("", m.Sender, m.Password, m.Server) addr := fmt.Sprintf("%s:%s", m.Server, m.Port) for _, mail := range n.Recipients { go func(target string) { - msg := m.CreateMessage(n.ServiceName, target, "Alert") - err := smtp.SendMail(addr, auth, m.Sender, []string{mail}, bytes.NewBufferString(msg).Bytes()) + msg := m.CreateMessage(n.Metadata, target, "Alert") + err := smtp.SendMail(addr, auth, m.Sender, []string{target}, bytes.NewBufferString(msg).Bytes()) if err != nil { - m.Logger.Error("email_send_failed", "target", target, "addr", addr) // return fmt.Errorf("error while sending mail to %s:%w", target, err) + m.Logger.Error("email_send_failed", "target", target, "addr", addr, "error", err) + } else { + m.Logger.Info("alert_sent", "target", target, "service", n.Metadata.ServiceName) } - m.Logger.Info("alert_sent", "target", target, "service", n.ServiceName) }(mail) + }(mail) } return nil } diff --git a/notifier/meli_payamak.go b/notifier/meli_payamak.go index 64a2c0b..ef12ef3 100644 --- a/notifier/meli_payamak.go +++ b/notifier/meli_payamak.go @@ -3,14 +3,13 @@ package notifier import ( "bytes" "healthy-api/model" + "io" "log/slog" - "net/http" "net/url" "strings" "text/template" "time" - "io" ) type PayamakNotifier struct { @@ -23,12 +22,12 @@ type PayamakNotifier struct { func (p *PayamakNotifier) Notify(notification model.Notification) error { baseURL := "https://rest.payamak-panel.com/api/SendSMS/SendSMS" - + // رندر کردن تمپلیت tmpl, err := template.New("sms").Parse(p.Template) if err != nil { // اگر تمپلیت مشکل داشت، یک متن پیش‌فرض استفاده کن - p.Template = "Service {{.ServiceName}} is DOWN!" + p.Template = "Service {{.Metadata.ServiceName}} is DOWN!" tmpl, _ = template.New("sms").Parse(p.Template) } @@ -50,13 +49,13 @@ func (p *PayamakNotifier) Notify(notification model.Notification) error { data.Set("isFlash", "false") resp, err := client.Post(baseURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode())) - + if err != nil { p.Logger.Error("network_request_failed", "provider", "payamak_panel", "target", number, "error", err, - ) + ) continue } @@ -64,7 +63,7 @@ func (p *PayamakNotifier) Notify(notification model.Notification) error { responseString := string(bodyBytes) resp.Body.Close() - if resp.StatusCode != http.StatusOK || len(responseString) < 5 { + if resp.StatusCode != http.StatusOK || len(responseString) < 5 { p.Logger.Error("sms_delivery_failed", "provider", "payamak_panel", "target", number, @@ -75,11 +74,11 @@ func (p *PayamakNotifier) Notify(notification model.Notification) error { p.Logger.Info("sms_delivery_success", "provider", "payamak_panel", "target", number, - "result_id", responseString, + "result_id", responseString, ) } } return nil } -func (p *PayamakNotifier) GetName() string { return "PayamakPanel" } \ No newline at end of file +func (p *PayamakNotifier) GetName() string { return "PayamakPanel" } diff --git a/notifier/sms.go b/notifier/sms.go index 8ea51bf..beae50c 100644 --- a/notifier/sms.go +++ b/notifier/sms.go @@ -6,10 +6,9 @@ import ( "fmt" "healthy-api/model" "io" + "log/slog" "net/http" "time" - "log/slog" - ) type SMSNotifier struct { @@ -46,7 +45,7 @@ func (s *SMSNotifier) GetURL() string { func (s *SMSNotifier) GetName() string { return fmt.Sprintf("SMSNotifier(%s)", s.URL) } -func (s SMSNotifier) Notify(n model.Notification) error { +func (s *SMSNotifier) Notify(n model.Notification) error { client := &http.Client{ Timeout: time.Second * 10, @@ -61,7 +60,7 @@ func (s SMSNotifier) Notify(n model.Notification) error { Recipient: target, PatternCode: s.GetCodePattern(), InputData: []map[string]string{ - {s.GetDataKey(): n.ServiceName}, + {s.GetDataKey(): n.Metadata.ServiceName}, }, }) if err != nil { @@ -81,7 +80,8 @@ func (s SMSNotifier) Notify(n model.Notification) error { if resp.StatusCode != 200 { return fmt.Errorf("Error response code is %d. Body: %s", resp.StatusCode, string(bodyData)) } -s.Logger.Info("sms_sent", "target", target, "status", resp.StatusCode, "body", string(bodyData)) } + s.Logger.Info("sms_sent", "target", target, "status", resp.StatusCode, "body", string(bodyData)) + } return nil } diff --git a/notifier/webhook.go b/notifier/webhook.go index b8d0af5..500efaf 100644 --- a/notifier/webhook.go +++ b/notifier/webhook.go @@ -8,7 +8,6 @@ import ( "log/slog" "net/http" "text/template" - "time" ) type WebhookNotifier struct { @@ -85,7 +84,8 @@ func (w *WebhookNotifier) sendRequest(url string, headers map[string]interface{} if valStr, ok := v.(string); ok { req.Header.Set(k, valStr) } else { - w.Logger.Error("invalid_header_value", "key", k, "error", "not_a_string") } + w.Logger.Error("invalid_header_value", "key", k, "error", "not_a_string") + } } resp, err := w.Client.Do(req) if err != nil { @@ -104,9 +104,8 @@ func (w *WebhookNotifier) Notify(n model.Notification) error { for _, recipient := range n.Recipients { ctx := model.WebhookTemplate{ - ServiceName: n.ServiceName, - TimeStamp: time.Now().Format(time.RFC3339), - URL: recipient, + Metadata: n.Metadata, + URL: recipient, } filledHeaders, err := FillTemplate(w.HookData.Headers, ctx) if err != nil { @@ -124,9 +123,9 @@ func (w *WebhookNotifier) Notify(n model.Notification) error { go func(rec string, hdr map[string]interface{}, body []byte) { if err := w.sendRequest(rec, hdr, body); err != nil { w.Logger.Error("webhook_request_failed", "target", rec, "error", err) - } else { - w.Logger.Info("webhook_sent_success", "target", rec) - } + } else { + w.Logger.Info("webhook_sent_success", "target", rec) + } }(recipient, filledHeaders, bodyBytes) } diff --git a/notifier/webhook_templating_test.go b/notifier/webhook_templating_test.go index a107434..fdcd2f7 100644 --- a/notifier/webhook_templating_test.go +++ b/notifier/webhook_templating_test.go @@ -9,19 +9,22 @@ import ( func TestFillTemplate(t *testing.T) { templateData := map[string]interface{}{ - "message": "Service '{{ .ServiceName }}' is down!", + "message": "Service '{{ .Metadata.ServiceName }}' is down!", "details": map[string]interface{}{ "url": "Checked URL was {{ .URL }}", - "timestamp": "{{ .TimeStamp }}", + "timestamp": "{{ .Metadata.Timestamp }}", }, "static_value": 123, } testTime := time.Now() + timestamp := testTime.Format(time.RFC3339) context := model.WebhookTemplate{ - ServiceName: "Login-API", - TimeStamp: testTime.Format(time.RFC3339), - URL: "https://api.example.com/login", + Metadata: model.NotificationMetadata{ + ServiceName: "Login-API", + Timestamp: timestamp, + }, + URL: "https://api.example.com/login", } result, err := notifier.FillTemplate(templateData, context) @@ -47,8 +50,8 @@ func TestFillTemplate(t *testing.T) { t.Errorf("Expected nested url to be '%s', got '%s'", expectedURL, detailsMap["url"]) } - if detailsMap["timestamp"] != context.TimeStamp { - t.Errorf("Expected nested timestamp to be '%s', got '%s'", context.TimeStamp, detailsMap["timestamp"]) + if detailsMap["timestamp"] != context.Metadata.Timestamp { + t.Errorf("Expected nested timestamp to be '%s', got '%s'", context.Metadata.Timestamp, detailsMap["timestamp"]) } // Check that static values are preserved diff --git a/notifier/webhook_test.go b/notifier/webhook_test.go index d881deb..550e710 100644 --- a/notifier/webhook_test.go +++ b/notifier/webhook_test.go @@ -7,10 +7,10 @@ import ( "log/slog" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" - "os" ) func TestWebhookNotifier_Notify(t *testing.T) { @@ -34,11 +34,11 @@ func TestWebhookNotifier_Notify(t *testing.T) { Method: "POST", Headers: map[string]interface{}{ "Content-Type": "application/json", - "X-Test": "{{ .ServiceName }}", + "X-Test": "{{ .Metadata.ServiceName }}", }, JSON: map[string]interface{}{ - "message": "Service {{ .ServiceName }} is down", - "timestamp": "{{ .TimeStamp }}", + "message": "Service {{ .Metadata.ServiceName }} is down", + "timestamp": "{{ .Metadata.Timestamp }}", "url": "{{ .URL }}", }, } @@ -49,8 +49,11 @@ func TestWebhookNotifier_Notify(t *testing.T) { } notif := model.Notification{ - ServiceName: "user-service", - Recipients: []string{server.URL}, + Metadata: model.NotificationMetadata{ + ServiceName: "user-service", + Timestamp: "2023-10-27T10:00:00Z", + }, + Recipients: []string{server.URL}, } err := wh.Notify(notif)