feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
95
internal/checkexec/exec.go
Обычный файл
95
internal/checkexec/exec.go
Обычный файл
@@ -0,0 +1,95 @@
|
||||
// Package checkexec provides DB-free check execution for distributed workers.
|
||||
// It executes checks without saving results to database or InfluxDB,
|
||||
// allowing remote workers to report results via API.
|
||||
package checkexec
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/checks/calls"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cbssl"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cdns"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cftp"
|
||||
"rsgit.ru/rsmon/rsmon/checks/chttp"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cping"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cssh"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cssl"
|
||||
"rsgit.ru/rsmon/rsmon/checks/ctcp"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cudp"
|
||||
"rsgit.ru/rsmon/rsmon/checks/cwhois"
|
||||
"rsgit.ru/rsmon/rsmon/checks/llmhttp"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// ExecutedCheck is the DB-free result of a distributed check execution.
|
||||
type ExecutedCheck struct {
|
||||
Result checkresult.CheckResult
|
||||
Metrics []wire.MetricPoint
|
||||
}
|
||||
|
||||
// Execute runs checks without saving to DB or InfluxDB.
|
||||
// Results are returned for reporting via API to the control plane.
|
||||
// This is designed for distributed workers that have no direct DB access.
|
||||
func Execute(m *models.Monitor, checks []models.Check) []ExecutedCheck {
|
||||
results := make([]ExecutedCheck, 0, len(checks))
|
||||
for i := range checks {
|
||||
c := &checks[i]
|
||||
c.Monitor = m
|
||||
switch c.Kind {
|
||||
case "http":
|
||||
r := chttp.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
case "ssl":
|
||||
r := cssl.Perform(c)
|
||||
results = append(results, executed(&r.CheckResult))
|
||||
case "ssh":
|
||||
r := cssh.Perform(c)
|
||||
results = append(results, executed(&r.CheckResult))
|
||||
case "ftp":
|
||||
r := cftp.Perform(c)
|
||||
results = append(results, executed(&r.CheckResult))
|
||||
case "dns":
|
||||
r := cdns.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
case "whois":
|
||||
r := cwhois.Perform(c)
|
||||
results = append(results, executed(&r.CheckResult))
|
||||
case "bssl":
|
||||
r := cbssl.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
case "llm":
|
||||
r := calls.Perform(c)
|
||||
results = append(results, executed(&r.CheckResult))
|
||||
case "llm-http":
|
||||
r := llmhttp.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
case "ping":
|
||||
r := cping.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
case "tcp":
|
||||
r := ctcp.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
case "udp":
|
||||
r := cudp.Perform(c)
|
||||
results = append(results, executedWithMetric(c, r.CheckResult, r.InfluxTags(*c), r.InfluxFields()))
|
||||
}
|
||||
// Note: "rkn" checks are intentionally omitted as they are Russia-specific
|
||||
// regulatory checks that should not run on distributed workers.
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func executed(result *checkresult.CheckResult) ExecutedCheck {
|
||||
return ExecutedCheck{Result: *result}
|
||||
}
|
||||
|
||||
func executedWithMetric(c *models.Check, result checkresult.CheckResult, tags map[string]string, fields map[string]interface{}) ExecutedCheck { //nolint:gocritic,lll // helper keeps typed check results close to execution
|
||||
return ExecutedCheck{
|
||||
Result: result,
|
||||
Metrics: []wire.MetricPoint{{
|
||||
Metric: c.MetricName(),
|
||||
Tags: tags,
|
||||
Fields: fields,
|
||||
}},
|
||||
}
|
||||
}
|
||||
38
internal/checkresult/result.go
Обычный файл
38
internal/checkresult/result.go
Обычный файл
@@ -0,0 +1,38 @@
|
||||
// Package checkresult holds the DB-free outcome of a single check execution.
|
||||
package checkresult
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckResult is the in-memory outcome of a single check execution.
|
||||
// The check packages build a CheckResult, then call SaveTo to persist
|
||||
// state, error, warnings, infos, and expiry back to the check row.
|
||||
type CheckResult struct {
|
||||
State string
|
||||
Error error
|
||||
Warnings []string
|
||||
Infos []string
|
||||
Duration time.Duration
|
||||
Expires *time.Time
|
||||
}
|
||||
|
||||
// GetState returns the result state (OK/ERR/FAIL/WARN).
|
||||
func (r *CheckResult) GetState() string {
|
||||
return r.State
|
||||
}
|
||||
|
||||
// GetError returns the result error, if any.
|
||||
func (r *CheckResult) GetError() error {
|
||||
return r.Error
|
||||
}
|
||||
|
||||
// GetWarnings returns the human-readable warnings produced by the check.
|
||||
func (r *CheckResult) GetWarnings() []string {
|
||||
return r.Warnings
|
||||
}
|
||||
|
||||
// GetInfos returns the human-readable info messages produced by the check.
|
||||
func (r *CheckResult) GetInfos() []string {
|
||||
return r.Infos
|
||||
}
|
||||
185
internal/distworker/client.go
Обычный файл
185
internal/distworker/client.go
Обычный файл
@@ -0,0 +1,185 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// HTTPStatusError reports an HTTP response returned while dialing the websocket.
|
||||
type HTTPStatusError struct {
|
||||
StatusCode int
|
||||
Status string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *HTTPStatusError) Error() string {
|
||||
if e.Body == "" {
|
||||
return fmt.Sprintf("websocket dial failed: %s", e.Status)
|
||||
}
|
||||
return fmt.Sprintf("websocket dial failed: %s: %s", e.Status, e.Body)
|
||||
}
|
||||
|
||||
// Client is the HTTP client for communicating with the control plane
|
||||
type Client struct {
|
||||
endpoint string
|
||||
authToken string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(endpoint, authToken string) *Client {
|
||||
return &Client{
|
||||
endpoint: endpoint,
|
||||
authToken: authToken,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// postJSON is a helper for sending JSON POST requests
|
||||
func (c *Client) postJSON(path string, payload interface{}) (*http.Response, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequest("POST", c.endpoint+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
if c.authToken != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.authToken)
|
||||
}
|
||||
|
||||
return c.httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
// checkStatusCode checks if the response status is OK, returns error otherwise
|
||||
func checkStatusCode(resp *http.Response, errorMsg string) error {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("%s failed: %s: %s", errorMsg, resp.Status, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Heartbeat sends a heartbeat to the control plane
|
||||
func (c *Client) Heartbeat(req wire.HeartbeatRequest) error {
|
||||
resp, err := c.postJSON("/api/internal/workers/heartbeat", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
return checkStatusCode(resp, "heartbeat")
|
||||
}
|
||||
|
||||
// RotateToken asks the main app's internal API to mint a new
|
||||
// bearer token for this worker. The current bearer is used for
|
||||
// authentication; the response carries the freshly issued token.
|
||||
//
|
||||
// Returns the new token string. The main app invalidates the old
|
||||
// token immediately.
|
||||
func (c *Client) RotateToken() (string, error) {
|
||||
resp, err := c.postJSON("/api/internal/workers/rotate-token", struct{}{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("rotate-token failed: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var out struct {
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", fmt.Errorf("decode rotate-token response: %w", err)
|
||||
}
|
||||
if out.AuthToken == "" {
|
||||
return "", fmt.Errorf("rotate-token response empty")
|
||||
}
|
||||
return out.AuthToken, nil
|
||||
}
|
||||
|
||||
// GetJobs fetches available check jobs from the control plane
|
||||
func (c *Client) GetJobs() (*wire.JobsResponse, error) {
|
||||
httpReq, err := http.NewRequest("GET", c.endpoint+"/api/internal/workers/jobs", http.NoBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.authToken)
|
||||
|
||||
resp, err := c.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("get jobs failed: %s: %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var result wire.JobsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ReportResults sends check results to the control plane
|
||||
func (c *Client) ReportResults(req wire.ResultsRequest) error {
|
||||
resp, err := c.postJSON("/api/internal/workers/results", req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
return checkStatusCode(resp, "report results")
|
||||
}
|
||||
|
||||
// WorkerSocket connects to the websocket task channel.
|
||||
func (c *Client) WorkerSocket() (*websocket.Conn, error) {
|
||||
endpoint := strings.TrimRight(c.endpoint, "/")
|
||||
wsURL := endpoint
|
||||
if !strings.HasSuffix(wsURL, "/worker") && !strings.HasSuffix(wsURL, "/api/worker") {
|
||||
wsURL += "/worker"
|
||||
}
|
||||
if strings.HasPrefix(wsURL, "https://") {
|
||||
wsURL = "wss://" + strings.TrimPrefix(wsURL, "https://")
|
||||
} else if strings.HasPrefix(wsURL, "http://") {
|
||||
wsURL = "ws://" + strings.TrimPrefix(wsURL, "http://")
|
||||
}
|
||||
|
||||
u, err := url.Parse(wsURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("token", c.authToken)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
conn, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil && resp != nil {
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status, Body: string(body)}
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
203
internal/distworker/config.go
Обычный файл
203
internal/distworker/config.go
Обычный файл
@@ -0,0 +1,203 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultMaxConcurrency is the default upper bound for the worker pool size
|
||||
// and the number of dispatch goroutines that drain the job queue.
|
||||
const DefaultMaxConcurrency = 32
|
||||
|
||||
const (
|
||||
// DefaultHTTPHost is the bind address used when WORKER_HOST is unset.
|
||||
DefaultHTTPHost = "0.0.0.0"
|
||||
// DefaultHTTPPort is the bind port used when WORKER_PORT is unset.
|
||||
// Picked >20000 to avoid colliding with the main RSMon app (which
|
||||
// binds 7401 by default) when the worker is co-located on the
|
||||
// same host. Operators are still free to override via WORKER_PORT.
|
||||
DefaultHTTPPort = 27401
|
||||
|
||||
// schemeHTTP / schemeHTTPS are the only schemes accepted on
|
||||
// WORKER_URL. Peer workers and the main app need an http(s) origin
|
||||
// they can dial with Go's net/http stack.
|
||||
schemeHTTP = "http"
|
||||
schemeHTTPS = "https"
|
||||
|
||||
// loopbackHostnames lists hostnames treated as loopback for the
|
||||
// http-on-non-loopback warning emitted by ValidateHTTPConfig.
|
||||
loopbackHostnames = "localhost,127.0.0.1,::1,0.0.0.0"
|
||||
)
|
||||
|
||||
// HTTPConfig holds the settings that govern the worker's local HTTP
|
||||
// listener (web app MVP in Task 3 and Raft peer connections in Task 4).
|
||||
// Host/Port are the bind interface. URL is the publicly-advertised
|
||||
// location peers and the main app use to reach the worker; it is NOT
|
||||
// derived from Host:Port because workers commonly sit behind a reverse
|
||||
// proxy / Traefik with HTTPS while listening on plain HTTP internally.
|
||||
//
|
||||
// Login/Password are basic auth credentials for the worker web app API
|
||||
// (Task 3). They are kept in memory only; Task 3 may hash them before
|
||||
// any persistent store.
|
||||
type HTTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
URL string
|
||||
Login string
|
||||
Password string
|
||||
}
|
||||
|
||||
// IsAuthConfigured reports whether both WORKER_LOGIN and WORKER_PASSWORD
|
||||
// are set. The HTTP listener refuses to start otherwise.
|
||||
func (c HTTPConfig) IsAuthConfigured() bool {
|
||||
return c.Login != "" && c.Password != ""
|
||||
}
|
||||
|
||||
// IsListenConfigured reports whether the listener should bind at all.
|
||||
// Currently always true while WORKER_PORT > 0; placeholder for Task 3
|
||||
// to wire "skip listen" semantics.
|
||||
func (c HTTPConfig) IsListenConfigured() bool {
|
||||
return c.Port > 0
|
||||
}
|
||||
|
||||
// Config holds the local worker connection settings.
|
||||
// Runtime settings are delivered by the control plane over websocket.
|
||||
type Config struct {
|
||||
URL string
|
||||
Token string
|
||||
|
||||
// MaxConcurrency caps the worker pool size and the number of dispatcher
|
||||
// goroutines. A value <= 0 falls back to DefaultMaxConcurrency.
|
||||
// Runtime configuration delivered by the control plane via the websocket
|
||||
// "init" or "config" message is clamped to this value when resizing.
|
||||
MaxConcurrency int
|
||||
|
||||
// HTTP holds the local web app / Raft listener settings (Task 2).
|
||||
HTTP HTTPConfig
|
||||
}
|
||||
|
||||
// ConfigFromEnv creates a Config from environment variables.
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
URL: normalizeURL(os.Getenv("RSMON_URL")),
|
||||
Token: os.Getenv("RSMON_TOKEN"),
|
||||
HTTP: HTTPConfigFromEnv(),
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPConfigFromEnv reads HTTP listener settings from the environment.
|
||||
// Empty WORKER_HOST defaults to DefaultHTTPHost; empty WORKER_PORT defaults
|
||||
// to DefaultHTTPPort. A malformed WORKER_PORT falls back to the default.
|
||||
// URL is parsed loosely here; ValidateHTTPConfig does the real check.
|
||||
func HTTPConfigFromEnv() HTTPConfig {
|
||||
port := DefaultHTTPPort
|
||||
if raw := strings.TrimSpace(os.Getenv("WORKER_PORT")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 {
|
||||
port = v
|
||||
}
|
||||
}
|
||||
host := strings.TrimSpace(os.Getenv("WORKER_HOST"))
|
||||
if host == "" {
|
||||
host = DefaultHTTPHost
|
||||
}
|
||||
return HTTPConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
URL: strings.TrimSpace(os.Getenv("WORKER_URL")),
|
||||
Login: os.Getenv("WORKER_LOGIN"),
|
||||
Password: os.Getenv("WORKER_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateHTTPConfig enforces the Task 2 invariants:
|
||||
//
|
||||
// - WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty.
|
||||
// A mixed state (XOR) is a config bug and must fail fast so an
|
||||
// operator notices immediately. Both empty is allowed while the HTTP
|
||||
// listener is not started yet.
|
||||
// - When the listener would actually start (WORKER_PORT > 0), both
|
||||
// must be set; otherwise we are going to expose an unauthenticated
|
||||
// endpoint.
|
||||
// - WORKER_URL, if set, must be a parseable absolute URL. Relative
|
||||
// URLs are rejected because peer workers and the main app need a
|
||||
// concrete origin to dial.
|
||||
// - A WORKER_URL with scheme=http on a non-loopback host logs a
|
||||
// warning: production deployments normally terminate TLS at a
|
||||
// reverse proxy (Traefik, nginx).
|
||||
func ValidateHTTPConfig(c HTTPConfig, willListen bool) error {
|
||||
loginSet := c.Login != ""
|
||||
passSet := c.Password != ""
|
||||
if loginSet != passSet {
|
||||
return fmt.Errorf(
|
||||
"WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty (got login=%s password=%s)",
|
||||
boolStr(loginSet), boolStr(passSet))
|
||||
}
|
||||
if willListen && !c.IsAuthConfigured() {
|
||||
return fmt.Errorf("HTTP listener refused to start: WORKER_LOGIN and WORKER_PASSWORD must be set when WORKER_PORT > 0")
|
||||
}
|
||||
if c.URL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(c.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WORKER_URL is not a valid URL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("WORKER_URL must be an absolute URL with scheme and host (got %q)", c.URL)
|
||||
}
|
||||
if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS {
|
||||
return fmt.Errorf("WORKER_URL scheme must be http or https (got %q)", u.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarnInsecurePublicURL logs a warning when WORKER_URL uses plain http
|
||||
// for a non-loopback host. Returns the host so the caller can log it.
|
||||
// A no-op for the loopback case (typical local dev) and for https URLs.
|
||||
func WarnInsecurePublicURL(rawURL string) (host string, shouldWarn bool) {
|
||||
if rawURL == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if u.Scheme != schemeHTTP {
|
||||
return u.Host, false
|
||||
}
|
||||
if isLoopbackHost(u.Hostname()) {
|
||||
return u.Host, false
|
||||
}
|
||||
return u.Host, true
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
for _, h := range strings.Split(loopbackHostnames, ",") {
|
||||
if host == strings.TrimSpace(h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "set"
|
||||
}
|
||||
return "empty"
|
||||
}
|
||||
|
||||
func normalizeURL(endpoint string) string {
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
if strings.HasSuffix(endpoint, "/api/worker") {
|
||||
return strings.TrimSuffix(endpoint, "/api/worker")
|
||||
}
|
||||
if strings.HasSuffix(endpoint, "/worker") {
|
||||
return strings.TrimSuffix(endpoint, "/worker")
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
197
internal/distworker/config_test.go
Обычный файл
197
internal/distworker/config_test.go
Обычный файл
@@ -0,0 +1,197 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestHTTPConfigFromEnv_Defaults verifies the documented defaults when no
|
||||
// HTTP-related env vars are set: 0.0.0.0:27401 and empty URL / login /
|
||||
// password. The default port was bumped from 7401 to 27401 to avoid
|
||||
// colliding with the main RSMon app when the worker is co-located.
|
||||
func TestHTTPConfigFromEnv_Defaults(t *testing.T) {
|
||||
t.Setenv("WORKER_HOST", "")
|
||||
t.Setenv("WORKER_PORT", "")
|
||||
t.Setenv("WORKER_URL", "")
|
||||
t.Setenv("WORKER_LOGIN", "")
|
||||
t.Setenv("WORKER_PASSWORD", "")
|
||||
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, DefaultHTTPHost, cfg.Host, "host should default to 0.0.0.0")
|
||||
assert.Equal(t, DefaultHTTPPort, cfg.Port, "port should default to 27401")
|
||||
assert.Equal(t, "", cfg.URL)
|
||||
assert.Equal(t, "", cfg.Login)
|
||||
assert.Equal(t, "", cfg.Password)
|
||||
assert.False(t, cfg.IsAuthConfigured())
|
||||
}
|
||||
|
||||
// TestHTTPConfigFromEnv_Overrides verifies the env vars win over the
|
||||
// defaults when explicitly set, including a non-default port.
|
||||
func TestHTTPConfigFromEnv_Overrides(t *testing.T) {
|
||||
t.Setenv("WORKER_HOST", "127.0.0.1")
|
||||
t.Setenv("WORKER_PORT", "9100")
|
||||
t.Setenv("WORKER_URL", "https://worker.example.com")
|
||||
t.Setenv("WORKER_LOGIN", "ops")
|
||||
t.Setenv("WORKER_PASSWORD", "s3cret")
|
||||
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, "127.0.0.1", cfg.Host)
|
||||
assert.Equal(t, 9100, cfg.Port)
|
||||
assert.Equal(t, "https://worker.example.com", cfg.URL)
|
||||
assert.Equal(t, "ops", cfg.Login)
|
||||
assert.Equal(t, "s3cret", cfg.Password)
|
||||
assert.True(t, cfg.IsAuthConfigured())
|
||||
}
|
||||
|
||||
// TestHTTPConfigFromEnv_PortGarbageFallsBackToDefault covers the "operator
|
||||
// fat-fingered WORKER_PORT" case: the helper must not panic and must
|
||||
// fall back to DefaultHTTPPort instead of starting on a zero port.
|
||||
func TestHTTPConfigFromEnv_PortGarbageFallsBackToDefault(t *testing.T) {
|
||||
t.Setenv("WORKER_PORT", "not-a-port")
|
||||
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, DefaultHTTPPort, cfg.Port,
|
||||
"unparseable WORKER_PORT should fall back to the default")
|
||||
}
|
||||
|
||||
// TestHTTPConfigFromEnv_PortOutOfRangeFallsBackToDefault covers zero and
|
||||
// out-of-range port values which are rejected by url/strconv semantics.
|
||||
func TestHTTPConfigFromEnv_PortOutOfRangeFallsBackToDefault(t *testing.T) {
|
||||
for _, v := range []string{"0", "-1", "70000"} {
|
||||
t.Run("port="+v, func(t *testing.T) {
|
||||
t.Setenv("WORKER_PORT", v)
|
||||
cfg := HTTPConfigFromEnv()
|
||||
assert.Equal(t, DefaultHTTPPort, cfg.Port,
|
||||
"WORKER_PORT=%q should fall back to the default", v)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_LoginXORPasswordRejected covers the central
|
||||
// invariant: a mixed login/password state is a config bug and must
|
||||
// fail fast.
|
||||
func TestValidateHTTPConfig_LoginXORPasswordRejected(t *testing.T) {
|
||||
t.Run("only login set", func(t *testing.T) {
|
||||
err := ValidateHTTPConfig(HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
Login: "ops", Password: "",
|
||||
}, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "WORKER_LOGIN and WORKER_PASSWORD")
|
||||
})
|
||||
t.Run("only password set", func(t *testing.T) {
|
||||
err := ValidateHTTPConfig(HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
Login: "", Password: "s3cret",
|
||||
}, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "WORKER_LOGIN and WORKER_PASSWORD")
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_BothEmptyAllowedWhenNotListening reflects the
|
||||
// Task 2 deferral: while the HTTP listener is not started (Task 3),
|
||||
// leaving both unset is permitted so a worker that does not yet need
|
||||
// the web app can still start.
|
||||
func TestValidateHTTPConfig_BothEmptyAllowedWhenNotListening(t *testing.T) {
|
||||
err := ValidateHTTPConfig(HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
Login: "", Password: "",
|
||||
}, false)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_BothEmptyRejectedWhenListening confirms that
|
||||
// when Task 3 actually starts the listener, an unauthenticated listener
|
||||
// is refused.
|
||||
func TestValidateHTTPConfig_BothEmptyRejectedWhenListening(t *testing.T) {
|
||||
err := ValidateHTTPConfig(HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
Login: "", Password: "",
|
||||
}, true)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "refused to start")
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_URLAbsoluteRequired covers the relative-URL
|
||||
// rejection: peer workers and the main app need a concrete origin.
|
||||
func TestValidateHTTPConfig_URLAbsoluteRequired(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"relative path", "/worker"},
|
||||
{"missing scheme", "example.com"},
|
||||
{"missing host", "https://"},
|
||||
{"empty after trim", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := HTTPConfig{Host: "0.0.0.0", Port: 27401, URL: tc.url}
|
||||
err := ValidateHTTPConfig(cfg, false)
|
||||
// empty url is allowed
|
||||
if tc.url == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_URLSchemeAllowed covers the two accepted schemes.
|
||||
func TestValidateHTTPConfig_URLSchemeAllowed(t *testing.T) {
|
||||
for _, scheme := range []string{"http", "https"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
cfg := HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
URL: scheme + "://localhost:27401",
|
||||
}
|
||||
assert.NoError(t, ValidateHTTPConfig(cfg, false))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateHTTPConfig_URLSchemeRejected covers non-http(s) schemes.
|
||||
func TestValidateHTTPConfig_URLSchemeRejected(t *testing.T) {
|
||||
for _, scheme := range []string{"ftp", "file", "ws", "wss"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
cfg := HTTPConfig{
|
||||
Host: "0.0.0.0", Port: 27401,
|
||||
URL: scheme + "://localhost:27401",
|
||||
}
|
||||
err := ValidateHTTPConfig(cfg, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "scheme must be http or https")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnInsecurePublicURL exercises the warning helper: only http on
|
||||
// non-loopback hosts should warn. The returned host is the parsed host
|
||||
// (host:port when present) so the caller can log a useful target.
|
||||
func TestWarnInsecurePublicURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
wantWarn bool
|
||||
wantHost string
|
||||
}{
|
||||
{"empty", "", false, ""},
|
||||
{"https production", "https://worker.example.com", false, "worker.example.com"},
|
||||
{"http loopback", "http://localhost:27401", false, "localhost:27401"},
|
||||
{"http 127.0.0.1", "http://127.0.0.1:27401", false, "127.0.0.1:27401"},
|
||||
{"http 0.0.0.0", "http://0.0.0.0:27401", false, "0.0.0.0:27401"},
|
||||
{"http production", "http://worker.example.com", true, "worker.example.com"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, warn := WarnInsecurePublicURL(tc.url)
|
||||
assert.Equal(t, tc.wantWarn, warn)
|
||||
assert.Equal(t, tc.wantHost, host)
|
||||
})
|
||||
}
|
||||
}
|
||||
237
internal/distworker/consensus.go
Обычный файл
237
internal/distworker/consensus.go
Обычный файл
@@ -0,0 +1,237 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// consensusDecision is the outcome of one consensus round. The
|
||||
// selfcheck module drives its alert state machine off Down/Up; NoQuorum
|
||||
// is the "we don't know yet" verdict returned when too few voters
|
||||
// reported for either side to be a majority.
|
||||
type consensusDecision int
|
||||
|
||||
const (
|
||||
// consensusNoQuorum means we have fewer than the minimum voters
|
||||
// required to reach a majority. The selfcheck holds the
|
||||
// previous state instead of flipping.
|
||||
consensusNoQuorum consensusDecision = iota
|
||||
// consensusUp means a majority of (self + known peers) reported
|
||||
// the master API as up.
|
||||
consensusUp
|
||||
// consensusDown means a majority reported the master API as
|
||||
// down. The selfcheck starts (or continues) the down timer.
|
||||
consensusDown
|
||||
)
|
||||
|
||||
// String makes the verdict easy to log without bespoke formatting.
|
||||
func (d consensusDecision) String() string {
|
||||
switch d {
|
||||
case consensusUp:
|
||||
return "up"
|
||||
case consensusDown:
|
||||
return "down"
|
||||
default:
|
||||
return "no-quorum"
|
||||
}
|
||||
}
|
||||
|
||||
// tallyConsensus counts how many of self+peers are up vs down. The
|
||||
// returned sizes are useful for logging/debugging and let the
|
||||
// selfcheck report "2 of 3 voters say down" in its log line.
|
||||
//
|
||||
// selfUp == nil means "self vote is unknown" (e.g. the first tick
|
||||
// has not completed). The cluster size is the number of known votes
|
||||
// (self, if known, plus each peer observation).
|
||||
func tallyConsensus(selfUp *bool, peers []peerObservation) (up, down, total int) {
|
||||
if selfUp != nil {
|
||||
total++
|
||||
if *selfUp {
|
||||
up++
|
||||
} else {
|
||||
down++
|
||||
}
|
||||
}
|
||||
for i := range peers {
|
||||
total++
|
||||
if peers[i].Up {
|
||||
up++
|
||||
} else {
|
||||
down++
|
||||
}
|
||||
}
|
||||
return up, down, total
|
||||
}
|
||||
|
||||
// decideConsensus applies the simple-majority rule: whichever side
|
||||
// (up or down) has at least floor(total/2)+1 votes wins. When total
|
||||
// is 0 (no votes at all) or neither side reaches that threshold the
|
||||
// result is consensusNoQuorum.
|
||||
//
|
||||
// minVotes is the minimum number of fresh votes (self + peers) that
|
||||
// must be present before any verdict is reported. It is the gate
|
||||
// that turns simple-majority into a peer-backed cluster quorum: a
|
||||
// multi-worker deployment (WorkerInit.Peers non-empty) passes
|
||||
// minVotes=2 so a lone self vote — no fresh peer observations yet —
|
||||
// is consensusNoQuorum, both for up and down. A single-worker /
|
||||
// no-peer deployment passes minVotes=1 to preserve the prior
|
||||
// single-node behavior. Values below 1 are clamped to 1.
|
||||
//
|
||||
// The "self" vote is required to reach quorum: a worker that has not
|
||||
// produced its own first probe cannot make a down consensus call
|
||||
// (its own vote would be missing). Pass nil for selfUp to model the
|
||||
// pre-first-probe window.
|
||||
func decideConsensus(selfUp *bool, peers []peerObservation, minVotes int) consensusDecision {
|
||||
if selfUp == nil {
|
||||
return consensusNoQuorum
|
||||
}
|
||||
if minVotes < 1 {
|
||||
minVotes = 1
|
||||
}
|
||||
up, down, total := tallyConsensus(selfUp, peers)
|
||||
if total < minVotes {
|
||||
return consensusNoQuorum
|
||||
}
|
||||
// Standard majority for a non-empty set: floor(total/2)+1.
|
||||
// For 1 voter (self only, no peers yet) this collapses to 1
|
||||
// which still requires unanimous agreement with self.
|
||||
majority := total/2 + 1
|
||||
if up >= majority {
|
||||
return consensusUp
|
||||
}
|
||||
if down >= majority {
|
||||
return consensusDown
|
||||
}
|
||||
return consensusNoQuorum
|
||||
}
|
||||
|
||||
// consensusState tracks the cluster-level up/down verdict over time
|
||||
// so the selfcheck module can fire alerts only after the verdict has
|
||||
// held for selfcheckConsensusWait. This mirrors the "incident
|
||||
// state machine" idea from
|
||||
// docs/distributed/worker-to-worker-raft.md §9.1 in miniature: we
|
||||
// only have two states (down / clear) and one wait threshold, but
|
||||
// the structure is the same so the next slice can swap the rule for
|
||||
// the full FSM without changing the alert site.
|
||||
type consensusState struct {
|
||||
mu sync.Mutex
|
||||
// downSince records the wall-clock time the cluster verdict
|
||||
// first flipped to consensusDown. Cleared when the verdict
|
||||
// flips back to consensusUp. nil means "not currently down".
|
||||
downSince *time.Time
|
||||
// alertActive mirrors the prior selfcheckState.sent* flags. It
|
||||
// is true between the down-alert firing and the recovery alert
|
||||
// firing so a duplicate probe does not re-send the same
|
||||
// "master is down" notification.
|
||||
alertActive bool
|
||||
// lastVerdict keeps the most recent decision for the next tick
|
||||
// to compare against without recomputing from scratch.
|
||||
lastVerdict consensusDecision
|
||||
// notificationLeader is the last deterministic worker elected to
|
||||
// send system-contact notifications for this local view of the
|
||||
// cluster. The first non-empty leader only initializes the field;
|
||||
// later changes are alert-worthy.
|
||||
notificationLeader string
|
||||
}
|
||||
|
||||
func (s *consensusState) notificationLeaderChanged(leader string) (old string, changed bool) {
|
||||
if s == nil || leader == "" {
|
||||
return "", false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.notificationLeader == "" {
|
||||
s.notificationLeader = leader
|
||||
return "", false
|
||||
}
|
||||
if s.notificationLeader == leader {
|
||||
return "", false
|
||||
}
|
||||
old = s.notificationLeader
|
||||
s.notificationLeader = leader
|
||||
return old, true
|
||||
}
|
||||
|
||||
// isDownConsensusHeld reports whether the supplied verdict means
|
||||
// "down for at least selfcheckConsensusWait" given the current
|
||||
// state. A fresh down verdict sets downSince; a subsequent down
|
||||
// verdict keeps the original timestamp so the wait is measured from
|
||||
// the first observation, not the most recent.
|
||||
func (s *consensusState) isDownConsensusHeld(verdict consensusDecision, now time.Time) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch verdict {
|
||||
case consensusDown:
|
||||
if s.downSince == nil {
|
||||
t := now
|
||||
s.downSince = &t
|
||||
}
|
||||
s.lastVerdict = verdict
|
||||
return now.Sub(*s.downSince) >= selfcheckConsensusWait
|
||||
case consensusUp:
|
||||
s.downSince = nil
|
||||
s.alertActive = false
|
||||
s.lastVerdict = verdict
|
||||
return false
|
||||
default:
|
||||
// NoQuorum: hold the existing state. Do not reset
|
||||
// downSince (a transient blip should not extend the
|
||||
// timer, but it should not erase progress either).
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// markDownAlertFired records that the down alert has been emitted so
|
||||
// the next tick does not fire it again. Idempotent.
|
||||
func (s *consensusState) markDownAlertFired() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.alertActive = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// shouldFireRecovery reports whether the cluster has been up long
|
||||
// enough to fire a recovery alert. Recovery uses the same wait
|
||||
// window as the down alert so a flapping verdict does not spam
|
||||
// recovery notifications.
|
||||
func (s *consensusState) shouldFireRecovery(verdict consensusDecision, now time.Time) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if verdict != consensusUp {
|
||||
return false
|
||||
}
|
||||
if !s.alertActive {
|
||||
return false
|
||||
}
|
||||
if s.lastVerdict != consensusUp {
|
||||
// Just flipped from down to up; stamp the recovery timer.
|
||||
s.lastVerdict = verdict
|
||||
t := now
|
||||
s.downSince = &t
|
||||
return false
|
||||
}
|
||||
if s.downSince == nil {
|
||||
return false
|
||||
}
|
||||
return now.Sub(*s.downSince) >= selfcheckConsensusWait
|
||||
}
|
||||
|
||||
// markRecoveryFired clears the alert-active flag so a future
|
||||
// down verdict can fire the down alert again.
|
||||
func (s *consensusState) markRecoveryFired() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.alertActive = false
|
||||
s.downSince = nil
|
||||
s.mu.Unlock()
|
||||
}
|
||||
280
internal/distworker/notification.go
Обычный файл
280
internal/distworker/notification.go
Обычный файл
@@ -0,0 +1,280 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/sender"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// ExecuteNotification runs one NotificationTask end-to-end. It looks up the
|
||||
// matching credential from the worker's in-memory cache (pushed via the init
|
||||
// / config websocket message) and dispatches to the per-method executor.
|
||||
// Returns a NotificationResultReport ready to send back to the control plane.
|
||||
//
|
||||
// Phase 1 of docs/plans/worker-notifier-mvp.md ships four supported methods
|
||||
// (email / telegram / webhook / mattermost). sms and voice are explicit
|
||||
// permanent failures with status=unsupported_method so the operator knows
|
||||
// the worker received the task and chose not to deliver it (rather than
|
||||
// silently dropping it as the production plan forbids).
|
||||
//
|
||||
//nolint:gocritic // task model is shared with the rest of the dispatcher; keep by-value
|
||||
func (r *Runner) ExecuteNotification(ctx context.Context, task models.Task) wire.NotificationResultReport {
|
||||
start := time.Now()
|
||||
|
||||
report := wire.NotificationResultReport{
|
||||
JobID: task.JobID,
|
||||
Status: wire.NotificationResultPermanent,
|
||||
DurationMs: 0,
|
||||
}
|
||||
|
||||
if len(task.Payload) == 0 {
|
||||
report.Status = wire.NotificationResultPermanent
|
||||
report.Error = stringPtr("notification task payload is empty")
|
||||
report.DurationMs = int(time.Since(start) / time.Millisecond)
|
||||
return report
|
||||
}
|
||||
|
||||
var nt wire.NotificationTask
|
||||
if err := json.Unmarshal(task.Payload, &nt); err != nil {
|
||||
report.Error = stringPtr("invalid notification payload: " + err.Error())
|
||||
report.DurationMs = int(time.Since(start) / time.Millisecond)
|
||||
return report
|
||||
}
|
||||
if nt.JobID == "" {
|
||||
nt.JobID = task.JobID
|
||||
}
|
||||
if nt.MessageID == 0 && task.MessageID != nil {
|
||||
nt.MessageID = *task.MessageID
|
||||
}
|
||||
report.JobID = nt.JobID
|
||||
report.LeaseToken = nt.LeaseToken
|
||||
report.MessageID = nt.MessageID
|
||||
if task.Deadline != nil && !task.Deadline.After(time.Now()) {
|
||||
report.Error = stringPtr("notification deadline expired")
|
||||
report.DurationMs = int(time.Since(start) / time.Millisecond)
|
||||
return report
|
||||
}
|
||||
|
||||
creds := r.Credentials()
|
||||
if creds == nil {
|
||||
report.Error = stringPtr("worker has no notification credentials pushed yet")
|
||||
report.DurationMs = int(time.Since(start) / time.Millisecond)
|
||||
return report
|
||||
}
|
||||
|
||||
status, providerResp, errStr, retry, execErr := r.deliverNotification(ctx, nt, creds)
|
||||
if task.Deadline != nil && !task.Deadline.After(time.Now()) {
|
||||
status, providerResp, errStr, retry, execErr = wire.NotificationResultPermanent, "", "notification deadline expired", nil, context.DeadlineExceeded
|
||||
}
|
||||
report.Status = status
|
||||
report.ProviderResponse = stringPtrOrNil(providerResp)
|
||||
report.Error = stringPtrOrNil(errStr)
|
||||
if retry != nil {
|
||||
report.RetryAfterSeconds = retry
|
||||
}
|
||||
report.DurationMs = int(time.Since(start) / time.Millisecond)
|
||||
if execErr != nil {
|
||||
log.Printf("worker: notification delivery job=%s message=%d method=%s status=%s error_class=delivery_failed duration_ms=%d",
|
||||
nt.JobID, nt.MessageID, nt.Method, status, report.DurationMs)
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// deliverNotification dispatches one notification task to the per-method
|
||||
// sub-executor. Returns (status, providerResponse, error, retryAfterSeconds,
|
||||
// internalError). The internalError is non-nil only for unexpected panics or
|
||||
// credential-resolution failures the result report should log.
|
||||
//
|
||||
//nolint:gocritic // wire payload is shared; keep by-value
|
||||
func (r *Runner) deliverNotification(
|
||||
ctx context.Context,
|
||||
nt wire.NotificationTask,
|
||||
creds *wire.NotificationCredentials,
|
||||
) (string, string, string, *int, error) {
|
||||
switch nt.Method {
|
||||
case systemContactKindEmail:
|
||||
if len(creds.SMTP) == 0 {
|
||||
return wire.NotificationResultPermanent,
|
||||
"", "no SMTP credential authorized for this worker", nil,
|
||||
errors.New("smtp: no credential")
|
||||
}
|
||||
cred, ok := selectSMTPCredential(creds.SMTP, nt.CredentialID)
|
||||
if !ok {
|
||||
return wire.NotificationResultPermanent, "", "requested SMTP credential is not authorized for this worker",
|
||||
nil, errors.New("smtp: credential not found")
|
||||
}
|
||||
err := sender.SendEmailWithCredentialContext(ctx,
|
||||
nt.Contact.Value, nt.Subject, nt.BodyText, nt.BodyHTML, &cred,
|
||||
)
|
||||
return classifyResult(err, "")
|
||||
|
||||
case "telegram":
|
||||
if len(creds.Telegram) == 0 {
|
||||
return wire.NotificationResultPermanent, "", "no Telegram credential authorized for this worker",
|
||||
nil, errors.New("telegram: no credential")
|
||||
}
|
||||
// Convert wire.TelegramCredential -> models.NotificationCredential
|
||||
// for the existing tg.SendMessageWithToken entry point. We do not
|
||||
// persist this; it lives only on the goroutine stack.
|
||||
cred, ok := selectTelegramCredential(creds.Telegram, nt.CredentialID)
|
||||
if !ok {
|
||||
return wire.NotificationResultPermanent, "", "requested Telegram credential is not authorized for this worker",
|
||||
nil, errors.New("telegram: credential not found")
|
||||
}
|
||||
nc := wireTelegramToModel(&cred)
|
||||
err := sender.SendTelegramWithCredentialContext(ctx,
|
||||
nt.Contact.Value, nt.Subject, nt.BodyMarkdown, nc,
|
||||
)
|
||||
return classifyResult(err, "")
|
||||
|
||||
case "webhook":
|
||||
signingSecret := ""
|
||||
if creds.Webhook != nil {
|
||||
signingSecret = creds.Webhook.SigningSecret
|
||||
}
|
||||
body, mErr := json.Marshal(nt)
|
||||
if mErr != nil {
|
||||
return wire.NotificationResultPermanent, "", "marshal webhook payload: " + mErr.Error(), nil, mErr
|
||||
}
|
||||
resp, err := sender.SendWebhookWithCredentialContext(ctx, body, nt.Contact.Value, signingSecret)
|
||||
return classifyResult(err, stringOrEmpty(resp))
|
||||
|
||||
case "mattermost":
|
||||
var mc *wire.MattermostCredential
|
||||
if creds.Mattermost != nil {
|
||||
mc = creds.Mattermost
|
||||
}
|
||||
resp, err := sender.SendMattermostWithCredentialContext(ctx,
|
||||
nt.Contact.Value, nt.MessageKind, nt.Subject, nt.BodyMarkdown, mc,
|
||||
)
|
||||
return classifyResult(err, stringOrEmpty(resp))
|
||||
|
||||
case "sms", "voice":
|
||||
return wire.NotificationResultPermanent, "", "unsupported_method: " + nt.Method,
|
||||
nil, errors.New("unsupported method: " + nt.Method)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return wire.NotificationResultRetryable, "", "context canceled: " + err.Error(), intPtr(5), err
|
||||
}
|
||||
return wire.NotificationResultPermanent, "", "unknown notification method: " + nt.Method,
|
||||
nil, errors.New("unknown method: " + nt.Method)
|
||||
}
|
||||
|
||||
func selectSMTPCredential(creds []wire.SMTPCredential, id *int64) (wire.SMTPCredential, bool) {
|
||||
if id != nil {
|
||||
for _, cred := range creds {
|
||||
if cred.ID == *id {
|
||||
return cred, true
|
||||
}
|
||||
}
|
||||
return wire.SMTPCredential{}, false
|
||||
}
|
||||
return creds[0], true
|
||||
}
|
||||
|
||||
func selectTelegramCredential(creds []wire.TelegramCredential, id *int64) (wire.TelegramCredential, bool) {
|
||||
if id != nil {
|
||||
for _, cred := range creds {
|
||||
if cred.ID == *id {
|
||||
return cred, true
|
||||
}
|
||||
}
|
||||
return wire.TelegramCredential{}, false
|
||||
}
|
||||
return creds[0], true
|
||||
}
|
||||
|
||||
// classifyResult translates a delivery error into the wire status enum.
|
||||
// SMTP 421 / 4xx with a Retry-After hint becomes retryable + suggested
|
||||
// delay. Network errors become retryable with a fixed 30s backoff. Anything
|
||||
// else is permanent.
|
||||
func classifyResult(err error, providerResp string) (string, string, string, *int, error) {
|
||||
if err == nil {
|
||||
return wire.NotificationResultDelivered, providerResp, "", nil, nil
|
||||
}
|
||||
errStr := err.Error()
|
||||
|
||||
// Heuristic: any 4xx SMTP response is retryable. The legacy sender does
|
||||
// not parse this, so the worker does a substring match on the error.
|
||||
if isRetryableSMTP(errStr) {
|
||||
retry := 30
|
||||
return wire.NotificationResultRetryable, providerResp, errStr, &retry, err
|
||||
}
|
||||
// Network / DNS / TLS / context errors are typically transient.
|
||||
if isTransientTransport(errStr) {
|
||||
retry := 15
|
||||
return wire.NotificationResultRetryable, providerResp, errStr, &retry, err
|
||||
}
|
||||
// Telegram "chat not found", webhook 4xx response (non-5xx) -> permanent.
|
||||
return wire.NotificationResultPermanent, providerResp, errStr, nil, err
|
||||
}
|
||||
|
||||
func isRetryableSMTP(s string) bool {
|
||||
prefixes := []string{"smtp: 4", "421", "450", "451", "452"}
|
||||
for _, p := range prefixes {
|
||||
if len(s) >= len(p) && s[:len(p)] == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isTransientTransport(s string) bool {
|
||||
markers := []string{"timeout", "tempor", "connection refused", "no such host", "i/o timeout", "tls"}
|
||||
for _, m := range markers {
|
||||
if bytes.Contains([]byte(s), []byte(m)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func stringOrEmpty(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string { return &s }
|
||||
|
||||
func stringPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
// wireTelegramToModel translates a wire.TelegramCredential (init push shape)
|
||||
// into a models.NotificationCredential (DB shape) so it can be passed to the
|
||||
// existing tg.SendMessageWithToken entry point. The BotName/APIURL fields
|
||||
// are preserved; the secret is the bot token.
|
||||
func wireTelegramToModel(wt *wire.TelegramCredential) *models.NotificationCredential {
|
||||
nc := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Name: wt.Name,
|
||||
}
|
||||
if wt.BotName != "" {
|
||||
nc.BotName = &wt.BotName
|
||||
}
|
||||
if wt.APIURL != "" {
|
||||
nc.APIURL = &wt.APIURL
|
||||
}
|
||||
// Store the token in SecretEnc without encryption; SendMessageWithToken
|
||||
// does not read SecretEnc directly — it expects the helper to decrypt
|
||||
// via GetSecret. We go around that by writing the plaintext into the
|
||||
// struct field via the SetSecret path. Plain prefix lets the legacy
|
||||
// decrypt path return the raw value.
|
||||
nc.SetSecret(wt.Token) //nolint:errcheck // best-effort; failure becomes runtime error in SendMessageWithToken
|
||||
return nc
|
||||
}
|
||||
233
internal/distworker/notification_test.go
Обычный файл
233
internal/distworker/notification_test.go
Обычный файл
@@ -0,0 +1,233 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// runnerWithCreds is the smallest fixture that yields a Runner with a
|
||||
// credentials block already applied (no DB, no init websocket).
|
||||
func runnerWithCreds(creds *wire.NotificationCredentials) *Runner {
|
||||
r := NewRunner(&Config{MaxConcurrency: 4})
|
||||
r.credentialsMu.Lock()
|
||||
r.credentials = creds
|
||||
r.credentialsMu.Unlock()
|
||||
return r
|
||||
}
|
||||
|
||||
// TestExecuteNotification_Email_NoCredentials verifies that the executor
|
||||
// returns a permanent failure when no SMTP credential is pushed.
|
||||
func TestExecuteNotification_Email_NoCredentials(t *testing.T) {
|
||||
r := runnerWithCreds(nil)
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-1",
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-1","message_id":1,"notification_id":1,
|
||||
"method":"email","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"email","value":"ops@example.com","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
assert.NotNil(t, report.Error)
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status, "must be permanent so the producer does not loop")
|
||||
}
|
||||
|
||||
func TestClassifyResult_NilEmailErrorDoesNotPanic(t *testing.T) {
|
||||
status, _, text, retry, err := classifyResult(nil, "")
|
||||
assert.Equal(t, wire.NotificationResultDelivered, status)
|
||||
assert.Empty(t, text)
|
||||
assert.Nil(t, retry)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_UnsupportedMethod covers sms/voice returning
|
||||
// permanent + unsupported_method.
|
||||
func TestExecuteNotification_UnsupportedMethod(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}},
|
||||
Telegram: []wire.TelegramCredential{{Name: "bot", Token: "123:abc"}},
|
||||
})
|
||||
for _, method := range []string{"sms", "voice"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-" + method,
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-` + method + `","message_id":1,"notification_id":1,
|
||||
"method":"` + method + `","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"` + method + `","value":"x","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
require.NotNil(t, report.Error)
|
||||
assert.Contains(t, *report.Error, "unsupported_method")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteNotification_UnknownMethod classifies unknown methods as
|
||||
// permanent so we never loop on bad producer output.
|
||||
func TestExecuteNotification_UnknownMethod(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-x",
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-x","message_id":1,"notification_id":1,
|
||||
"method":"pigeon","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"pigeon","value":"ops@example.com","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_EmptyPayloadPermanent: a malformed task must be
|
||||
// rejected permanently so the operator can spot it on the admin page.
|
||||
func TestExecuteNotification_EmptyPayloadPermanent(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{Server: "smtp.example.com", Port: 587, Login: "u", Password: "p"}},
|
||||
})
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{JobID: "job-empty"})
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
require.NotNil(t, report.Error)
|
||||
}
|
||||
|
||||
func TestExecuteNotification_ExpiredDeadlineDoesNotDeliver(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||
expired := time.Now().Add(-time.Second)
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{JobID: "expired", Deadline: &expired, Payload: []byte(`{"job_id":"expired","message_id":1,"method":"email","contact":{"id":1,"kind":"email"}}`)})
|
||||
require.NotNil(t, report.Error)
|
||||
assert.Contains(t, *report.Error, "deadline expired")
|
||||
assert.Equal(t, wire.NotificationResultPermanent, report.Status)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_DurationPositive: every report carries a non-zero
|
||||
// duration_ms even when the work happens instantly. This is what the
|
||||
// notification_deliveries audit row expects.
|
||||
func TestExecuteNotification_DurationPositive(t *testing.T) {
|
||||
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "job-d",
|
||||
Payload: []byte(`{
|
||||
"job_id":"job-d","message_id":1,"notification_id":1,
|
||||
"method":"sms","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"sms","value":"x","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.GreaterOrEqual(t, report.DurationMs, 0)
|
||||
}
|
||||
|
||||
// TestClassifyResult verifies the error -> status mapping.
|
||||
func TestClassifyResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus string
|
||||
wantRetryAfter *int
|
||||
}{
|
||||
{"nil error", nil, wire.NotificationResultDelivered, nil},
|
||||
{"permanent provider error", errors.New("550 mailbox not found"), wire.NotificationResultPermanent, nil},
|
||||
{"smtp 421 retryable", errors.New("smtp: 421 try again later"), wire.NotificationResultRetryable, intPtr(30)},
|
||||
{"smtp 452 retryable", errors.New("452 insufficient storage"), wire.NotificationResultRetryable, intPtr(30)},
|
||||
{"network timeout retryable", errors.New("dial tcp: i/o timeout"), wire.NotificationResultRetryable, intPtr(15)},
|
||||
{"tls handshake retryable", errors.New("tls: handshake failure"), wire.NotificationResultRetryable, intPtr(15)},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
status, _, errStr, retry, _ := classifyResult(tc.err, "")
|
||||
assert.Equal(t, tc.wantStatus, status)
|
||||
if tc.err != nil {
|
||||
assert.NotEmpty(t, errStr)
|
||||
}
|
||||
if tc.wantRetryAfter == nil {
|
||||
assert.Nil(t, retry)
|
||||
} else {
|
||||
require.NotNil(t, retry)
|
||||
assert.Equal(t, *tc.wantRetryAfter, *retry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireTelegramToModel_RoundTrip sanity-checks the wire->DB credential
|
||||
// translation used by the telegram branch.
|
||||
func TestWireTelegramToModel_RoundTrip(t *testing.T) {
|
||||
wire := &wire.TelegramCredential{
|
||||
ID: 1,
|
||||
Name: "main-bot",
|
||||
BotName: "rsmon_bot",
|
||||
Token: "123456:ABCDEFG",
|
||||
APIURL: "https://api.telegram.org",
|
||||
}
|
||||
nc := wireTelegramToModel(wire)
|
||||
require.NotNil(t, nc)
|
||||
assert.Equal(t, models.CredentialKindTelegram, nc.Kind)
|
||||
assert.Equal(t, "main-bot", nc.Name)
|
||||
require.NotNil(t, nc.BotName)
|
||||
assert.Equal(t, "rsmon_bot", *nc.BotName)
|
||||
require.NotNil(t, nc.APIURL)
|
||||
assert.Equal(t, "https://api.telegram.org", *nc.APIURL)
|
||||
got, err := nc.GetSecret()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "123456:ABCDEFG", got)
|
||||
}
|
||||
|
||||
func TestSelectNotificationCredentialByID(t *testing.T) {
|
||||
id := int64(2)
|
||||
smtp, ok := selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "second", smtp.Name)
|
||||
|
||||
tg, ok := selectTelegramCredential([]wire.TelegramCredential{{ID: 1, Name: "first"}, {ID: 2, Name: "second"}}, &id)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "second", tg.Name)
|
||||
|
||||
missingID := int64(3)
|
||||
_, ok = selectSMTPCredential([]wire.SMTPCredential{{ID: 1, Name: "first"}}, &missingID)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// TestEnqueueNotification_BoundedQueue ensures the notifyQueue provides
|
||||
// backpressure: the channel capacity is queueCapacity() and EnqueueNotification
|
||||
// blocks once it is full.
|
||||
func TestEnqueueNotification_BoundedQueue(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 4})
|
||||
r.notifyQueue = make(chan wire.NotificationTask, 4)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
require.True(t, r.EnqueueNotification(wire.NotificationTask{JobID: "x"}))
|
||||
}
|
||||
// Channel is full; a non-blocking send must fail. We cannot truly verify
|
||||
// the blocking case in unit tests, so we just assert the depth counter.
|
||||
assert.Equal(t, int64(4), r.notifyDepth)
|
||||
}
|
||||
|
||||
// TestExecuteNotification_ReportsJobIDFromPayload verifies the executor
|
||||
// falls back to the task's JobID when the payload has none.
|
||||
func TestExecuteNotification_ReportsJobIDFromPayload(t *testing.T) {
|
||||
r := runnerWithCreds(nil)
|
||||
report := r.ExecuteNotification(context.Background(), models.Task{
|
||||
JobID: "outer-job",
|
||||
Payload: []byte(`{
|
||||
"job_id":"","message_id":1,"notification_id":1,
|
||||
"method":"sms","subject":"x","body_text":"x","body_html":"<p>x</p>",
|
||||
"contact":{"id":1,"kind":"sms","value":"x","name":"ops"},
|
||||
"message_kind":"down"
|
||||
}`),
|
||||
})
|
||||
assert.Equal(t, "outer-job", report.JobID)
|
||||
}
|
||||
|
||||
// guard against time import being unused if the above compile-time helpers
|
||||
// are dropped in a future refactor.
|
||||
var _ = time.Second
|
||||
241
internal/distworker/peer.go
Обычный файл
241
internal/distworker/peer.go
Обычный файл
@@ -0,0 +1,241 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// peerStatusPath is the HTTP path the worker webapp serves at
|
||||
// GET /api/peer/status. Kept on a single constant so the polling
|
||||
// client and the handler can never drift.
|
||||
const peerStatusPath = "/api/peer/status"
|
||||
|
||||
// peerStatusTimeout bounds how long a single peer probe is allowed
|
||||
// to take. Short enough that a sluggish peer does not stall the
|
||||
// selfcheck loop; long enough to absorb a single TCP retransmit.
|
||||
const peerStatusTimeout = 5 * time.Second
|
||||
|
||||
// peerStatusMaxAge is how stale a peer's last observation is allowed
|
||||
// to get before the consensus treats it as "unknown" (excluded from
|
||||
// the vote). Equal to two poll intervals so a single dropped probe
|
||||
// does not immediately disqualify a peer.
|
||||
const peerStatusMaxAge = 2 * peerPollInterval
|
||||
|
||||
// peerPollInterval drives the peer-poller cadence. Kept equal to the
|
||||
// local selfcheck interval so observations stay aligned.
|
||||
const peerPollInterval = 30 * time.Second
|
||||
|
||||
// PeerStatus is the JSON a worker returns at GET /api/peer/status.
|
||||
// The shape is stable so peer workers can pin against it without
|
||||
// coordinating a schema bump. ObservedAt is the wall-clock time the
|
||||
// local selfcheck produced this status; peers ignore entries older
|
||||
// than peerStatusMaxAge.
|
||||
type PeerStatus struct {
|
||||
WorkerID string `json:"worker_id"`
|
||||
Up bool `json:"up"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
}
|
||||
|
||||
// peerObservation is the per-peer entry the consensus uses. The
|
||||
// selfcheck module does not reach into the wire type directly; it
|
||||
// always works with the cached peerObservation so the JSON shape can
|
||||
// evolve without forcing a selfcheck refactor.
|
||||
type peerObservation struct {
|
||||
WorkerID string
|
||||
Up bool
|
||||
ObservedAt time.Time
|
||||
Err error // transient fetch error; nil = observation is valid
|
||||
}
|
||||
|
||||
// peerCache holds the latest observation per peer worker_id. The map
|
||||
// is guarded by a single RWMutex because reads (consensus) vastly
|
||||
// outnumber writes (one per peer per poll tick). Entries survive
|
||||
// across applyInit refreshes; the peer poller overwrites the
|
||||
// per-worker slot every tick.
|
||||
type peerCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]peerObservation
|
||||
}
|
||||
|
||||
func newPeerCache() *peerCache {
|
||||
return &peerCache{items: map[string]peerObservation{}}
|
||||
}
|
||||
|
||||
// snapshot returns a defensive copy of the current observations in
|
||||
// no particular order. Callers must not mutate the returned slice.
|
||||
func (c *peerCache) snapshot() []peerObservation {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
out := make([]peerObservation, 0, len(c.items))
|
||||
for _, v := range c.items {
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// put stores a fresh observation. The poller calls this every tick;
|
||||
// the consensus reads via snapshot().
|
||||
func (c *peerCache) put(obs peerObservation) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.items[obs.WorkerID] = obs
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// resetFor replaces the cache contents with one fresh observation per
|
||||
// peer in the supplied list. Used when applyInit refreshes the peer
|
||||
// set so a removed peer's stale observation is dropped immediately
|
||||
// rather than lingering until peerStatusMaxAge expires.
|
||||
func (c *peerCache) resetFor(peers []wire.PeerInfo) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.items = make(map[string]peerObservation, len(peers))
|
||||
for i := range peers {
|
||||
p := peers[i]
|
||||
if p.WorkerID == "" {
|
||||
continue
|
||||
}
|
||||
c.items[p.WorkerID] = peerObservation{WorkerID: p.WorkerID}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchPeerStatus issues a single GET /api/peer/status to the given
|
||||
// peer and decodes the response. Network errors and non-2xx codes
|
||||
// are returned as a populated peerObservation with Err set so the
|
||||
// caller can decide whether to update the cache (we do, to mark the
|
||||
// peer as "we tried"). Caller is responsible for not calling this
|
||||
// concurrently for the same peer in a way that would race the
|
||||
// peerCache lock; the per-peer update is serialized via the cache.
|
||||
//
|
||||
// peer is taken by pointer to keep the wire.PeerInfo copy off the
|
||||
// hot path; the function is called once per peer per poll interval.
|
||||
func fetchPeerStatus(ctx context.Context, peer *wire.PeerInfo) peerObservation {
|
||||
obs := peerObservation{WorkerID: peer.WorkerID}
|
||||
if peer.URL == "" {
|
||||
obs.Err = fmt.Errorf("peer %s: empty url", peer.WorkerID)
|
||||
return obs
|
||||
}
|
||||
u, err := url.Parse(peer.URL)
|
||||
if err != nil || (u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS) {
|
||||
obs.Err = fmt.Errorf("peer %s: bad url %q", peer.WorkerID, peer.URL)
|
||||
return obs
|
||||
}
|
||||
endpoint := strings.TrimRight(peer.URL, "/") + peerStatusPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
|
||||
if err != nil {
|
||||
obs.Err = fmt.Errorf("peer %s: build request: %w", peer.WorkerID, err)
|
||||
return obs
|
||||
}
|
||||
if peer.Login != "" {
|
||||
req.SetBasicAuth(peer.Login, peer.Password)
|
||||
}
|
||||
client := &http.Client{Timeout: peerStatusTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
obs.Err = fmt.Errorf("peer %s: get: %w", peer.WorkerID, err)
|
||||
return obs
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
obs.Err = fmt.Errorf("peer %s: status %d", peer.WorkerID, resp.StatusCode)
|
||||
return obs
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||
if err != nil {
|
||||
obs.Err = fmt.Errorf("peer %s: read body: %w", peer.WorkerID, err)
|
||||
return obs
|
||||
}
|
||||
var status PeerStatus
|
||||
if err := json.Unmarshal(body, &status); err != nil {
|
||||
obs.Err = fmt.Errorf("peer %s: decode: %w", peer.WorkerID, err)
|
||||
return obs
|
||||
}
|
||||
obs.Up = status.Up
|
||||
obs.ObservedAt = status.ObservedAt
|
||||
return obs
|
||||
}
|
||||
|
||||
// peerPollerLoop runs once per peerPollInterval and refreshes the
|
||||
// cache. It exits when ctx is canceled. The loop runs each peer
|
||||
// sequentially so a slow / unreachable peer does not spawn N
|
||||
// concurrent goroutines that would overwhelm the local listener.
|
||||
func (r *Runner) peerPollerLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(peerPollInterval)
|
||||
defer ticker.Stop()
|
||||
// Run once shortly after startup so a worker that boots while
|
||||
// the master is already down does not wait a full interval
|
||||
// before the first peer observation lands.
|
||||
first := time.NewTimer(2 * time.Second)
|
||||
defer first.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-first.C:
|
||||
case <-ticker.C:
|
||||
}
|
||||
r.pollPeersOnce(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// pollPeersOnce fetches every cached peer's status and stores the
|
||||
// results. Safe to call directly from tests to drive a deterministic
|
||||
// poll cycle.
|
||||
func (r *Runner) pollPeersOnce(ctx context.Context) {
|
||||
peers := r.Peers()
|
||||
if len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range peers {
|
||||
p := peers[i]
|
||||
obs := fetchPeerStatus(ctx, &p)
|
||||
r.peerCache.put(obs)
|
||||
if obs.Err != nil {
|
||||
log.Printf("worker peer poll: %s: %v", p.WorkerID, obs.Err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// peerObservationsForConsensus returns the peer observations the
|
||||
// consensus should consider. A peer whose last observation is older
|
||||
// than peerStatusMaxAge is dropped from the vote (treated as
|
||||
// "unknown") so a worker that lost connectivity to one peer cannot
|
||||
// single-handedly decide the cluster is healthy.
|
||||
func (r *Runner) peerObservationsForConsensus(now time.Time) []peerObservation {
|
||||
all := r.peerCache.snapshot()
|
||||
out := make([]peerObservation, 0, len(all))
|
||||
for _, obs := range all {
|
||||
if obs.WorkerID == "" {
|
||||
continue
|
||||
}
|
||||
if obs.Err != nil {
|
||||
continue
|
||||
}
|
||||
if obs.ObservedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
if now.Sub(obs.ObservedAt) > peerStatusMaxAge {
|
||||
continue
|
||||
}
|
||||
out = append(out, obs)
|
||||
}
|
||||
return out
|
||||
}
|
||||
424
internal/distworker/peer_test.go
Обычный файл
424
internal/distworker/peer_test.go
Обычный файл
@@ -0,0 +1,424 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
func TestDecideConsensus(t *testing.T) {
|
||||
up := true
|
||||
down := false
|
||||
peersUp := []peerObservation{{WorkerID: "p1", Up: true, ObservedAt: time.Now()}}
|
||||
peersDown := []peerObservation{{WorkerID: "p1", Up: false, ObservedAt: time.Now()}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
self *bool
|
||||
peers []peerObservation
|
||||
minVotes int
|
||||
want consensusDecision
|
||||
}{
|
||||
{
|
||||
name: "self only up (single-node, minVotes=1)",
|
||||
self: &up,
|
||||
peers: nil,
|
||||
minVotes: 1,
|
||||
want: consensusUp,
|
||||
},
|
||||
{
|
||||
name: "self only down (single-node, minVotes=1)",
|
||||
self: &down,
|
||||
peers: nil,
|
||||
minVotes: 1,
|
||||
want: consensusDown,
|
||||
},
|
||||
{
|
||||
name: "self down + 2 peers down = majority down",
|
||||
self: &down,
|
||||
peers: []peerObservation{
|
||||
{WorkerID: "p1", Up: false, ObservedAt: time.Now()},
|
||||
{WorkerID: "p2", Up: false, ObservedAt: time.Now()},
|
||||
},
|
||||
minVotes: 2,
|
||||
want: consensusDown,
|
||||
},
|
||||
{
|
||||
name: "self up + 2 peers up = majority up",
|
||||
self: &up,
|
||||
peers: []peerObservation{
|
||||
{WorkerID: "p1", Up: true, ObservedAt: time.Now()},
|
||||
{WorkerID: "p2", Up: true, ObservedAt: time.Now()},
|
||||
},
|
||||
minVotes: 2,
|
||||
want: consensusUp,
|
||||
},
|
||||
{
|
||||
name: "self up + 1 up + 1 down = majority up (2 of 3)",
|
||||
self: &up,
|
||||
peers: []peerObservation{
|
||||
{WorkerID: "p1", Up: true, ObservedAt: time.Now()},
|
||||
{WorkerID: "p2", Up: false, ObservedAt: time.Now()},
|
||||
},
|
||||
minVotes: 2,
|
||||
want: consensusUp,
|
||||
},
|
||||
{
|
||||
name: "self up + 1 down (1 up, 1 down) = tie (no quorum)",
|
||||
self: &up,
|
||||
peers: []peerObservation{
|
||||
{WorkerID: "p1", Up: false, ObservedAt: time.Now()},
|
||||
},
|
||||
minVotes: 2,
|
||||
want: consensusNoQuorum,
|
||||
},
|
||||
{
|
||||
name: "self down + 1 up + 1 down = majority down (2 of 3)",
|
||||
self: &down,
|
||||
peers: []peerObservation{
|
||||
{WorkerID: "p1", Up: true, ObservedAt: time.Now()},
|
||||
{WorkerID: "p2", Up: false, ObservedAt: time.Now()},
|
||||
},
|
||||
minVotes: 2,
|
||||
want: consensusDown,
|
||||
},
|
||||
{
|
||||
name: "self vote missing = no quorum",
|
||||
self: nil,
|
||||
peers: peersDown,
|
||||
minVotes: 2,
|
||||
want: consensusNoQuorum,
|
||||
},
|
||||
{
|
||||
name: "self vote missing + 1 up peer = still no quorum",
|
||||
self: nil,
|
||||
peers: peersUp,
|
||||
minVotes: 2,
|
||||
want: consensusNoQuorum,
|
||||
},
|
||||
{
|
||||
name: "minVotes=0 clamped to 1 (single-node self up)",
|
||||
self: &up,
|
||||
peers: nil,
|
||||
minVotes: 0,
|
||||
want: consensusUp,
|
||||
},
|
||||
{
|
||||
name: "minVotes<0 clamped to 1 (single-node self down)",
|
||||
self: &down,
|
||||
peers: nil,
|
||||
minVotes: -3,
|
||||
want: consensusDown,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, decideConsensus(tc.self, tc.peers, tc.minVotes))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsensusState_FiresAfterWait(t *testing.T) {
|
||||
state := &consensusState{}
|
||||
now := time.Now()
|
||||
|
||||
// Stamp the initial down verdict at the baseline so subsequent
|
||||
// ticks measure against a known start time.
|
||||
held := state.isDownConsensusHeld(consensusDown, now)
|
||||
assert.False(t, held, "must not fire on the first down tick")
|
||||
assert.NotNil(t, state.downSince, "downSince should be stamped on first down verdict")
|
||||
|
||||
// Not yet fired even after 4m59s.
|
||||
held = state.isDownConsensusHeld(consensusDown, now.Add(4*time.Minute+59*time.Second))
|
||||
assert.False(t, held, "must not fire before the wait elapses")
|
||||
|
||||
// Fire after the wait elapses.
|
||||
held = state.isDownConsensusHeld(consensusDown, now.Add(5*time.Minute+time.Second))
|
||||
assert.True(t, held, "must fire once the wait has elapsed")
|
||||
state.markDownAlertFired()
|
||||
assert.True(t, state.alertActive)
|
||||
|
||||
// Subsequent down verdicts do not re-stamp downSince.
|
||||
stampBefore := *state.downSince
|
||||
_ = state.isDownConsensusHeld(consensusDown, now.Add(10*time.Minute))
|
||||
assert.Equal(t, stampBefore, *state.downSince,
|
||||
"downSince must be set on the first observation, not re-stamped")
|
||||
|
||||
// Recovery clears the down timer and the alert flag.
|
||||
_ = state.isDownConsensusHeld(consensusUp, now.Add(11*time.Minute))
|
||||
assert.Nil(t, state.downSince)
|
||||
assert.False(t, state.alertActive)
|
||||
}
|
||||
|
||||
func TestConsensusState_RecoveryRequiresHeld(t *testing.T) {
|
||||
state := &consensusState{}
|
||||
now := time.Now()
|
||||
|
||||
// Pretend we already fired a down alert.
|
||||
state.alertActive = true
|
||||
t0 := now
|
||||
state.downSince = &t0
|
||||
|
||||
// First up verdict stamps a fresh recovery timer; the alert
|
||||
// must NOT fire yet.
|
||||
assert.False(t, state.shouldFireRecovery(consensusUp, now.Add(time.Second)))
|
||||
assert.Equal(t, consensusUp, state.lastVerdict)
|
||||
|
||||
// Stale up verdict (verdict != up) does not advance the
|
||||
// recovery timer.
|
||||
assert.False(t, state.shouldFireRecovery(consensusDown, now.Add(time.Minute)))
|
||||
|
||||
// Held long enough, recovery fires.
|
||||
assert.True(t, state.shouldFireRecovery(consensusUp, now.Add(selfcheckConsensusWait+time.Second)))
|
||||
state.markRecoveryFired()
|
||||
assert.False(t, state.alertActive)
|
||||
assert.Nil(t, state.downSince)
|
||||
}
|
||||
|
||||
func TestConsensusState_NoQuorumHoldsState(t *testing.T) {
|
||||
state := &consensusState{}
|
||||
now := time.Now()
|
||||
|
||||
// First establish a down verdict + timer.
|
||||
_ = state.isDownConsensusHeld(consensusDown, now)
|
||||
stampBefore := *state.downSince
|
||||
state.markDownAlertFired()
|
||||
|
||||
// NoQuorum must not reset the timer; the alert stays fired
|
||||
// so the next down verdict does not double-fire.
|
||||
_ = state.isDownConsensusHeld(consensusNoQuorum, now.Add(2*time.Minute))
|
||||
assert.Equal(t, stampBefore, *state.downSince)
|
||||
assert.True(t, state.alertActive)
|
||||
}
|
||||
|
||||
func TestTallyConsensus(t *testing.T) {
|
||||
up, down := true, false
|
||||
peers := []peerObservation{
|
||||
{Up: true}, {Up: false}, {Up: true},
|
||||
}
|
||||
gotUp, gotDown, gotTotal := tallyConsensus(&up, peers)
|
||||
assert.Equal(t, 3, gotUp)
|
||||
assert.Equal(t, 1, gotDown)
|
||||
assert.Equal(t, 4, gotTotal)
|
||||
|
||||
gotUp, gotDown, gotTotal = tallyConsensus(&down, nil)
|
||||
assert.Equal(t, 0, gotUp)
|
||||
assert.Equal(t, 1, gotDown)
|
||||
assert.Equal(t, 1, gotTotal)
|
||||
|
||||
gotUp, gotDown, gotTotal = tallyConsensus(nil, peers)
|
||||
assert.Equal(t, 2, gotUp)
|
||||
assert.Equal(t, 1, gotDown)
|
||||
assert.Equal(t, 3, gotTotal)
|
||||
}
|
||||
|
||||
func TestPeerCacheSnapshotAndPut(t *testing.T) {
|
||||
c := newPeerCache()
|
||||
c.put(peerObservation{WorkerID: "a", Up: true, ObservedAt: time.Now()})
|
||||
c.put(peerObservation{WorkerID: "b", Up: false, ObservedAt: time.Now()})
|
||||
|
||||
snap := c.snapshot()
|
||||
assert.Len(t, snap, 2)
|
||||
}
|
||||
|
||||
func TestPeerCacheResetForDropsRemovedPeers(t *testing.T) {
|
||||
c := newPeerCache()
|
||||
c.put(peerObservation{WorkerID: "a", Up: true, ObservedAt: time.Now()})
|
||||
c.put(peerObservation{WorkerID: "b", Up: true, ObservedAt: time.Now()})
|
||||
|
||||
c.resetFor([]wire.PeerInfo{{WorkerID: "a"}, {WorkerID: "c"}})
|
||||
snap := c.snapshot()
|
||||
ids := map[string]peerObservation{}
|
||||
for _, s := range snap {
|
||||
ids[s.WorkerID] = s
|
||||
}
|
||||
_, hasA := ids["a"]
|
||||
_, hasB := ids["b"]
|
||||
_, hasC := ids["c"]
|
||||
assert.True(t, hasA, "peer 'a' retained")
|
||||
assert.False(t, hasB, "removed peer 'b' dropped")
|
||||
assert.True(t, hasC, "new peer 'c' added")
|
||||
}
|
||||
|
||||
func TestApplyInitStoresPeersAndResetsCache(t *testing.T) {
|
||||
executor := func(interface{}) interface{} { return []wire.CheckResultReport{} }
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
require.NotNil(t, r.peerCache, "runner must own a peer cache after NewRunner")
|
||||
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 2,
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402"},
|
||||
{WorkerID: "w-3", URL: "http://127.0.0.1:27403"},
|
||||
},
|
||||
})
|
||||
got := r.Peers()
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "w-2", got[0].WorkerID)
|
||||
assert.Equal(t, "w-3", got[1].WorkerID)
|
||||
|
||||
// Cache should be primed with empty observations for the new
|
||||
// peers; the per-peer entries are visible via snapshot().
|
||||
snap := r.peerCache.snapshot()
|
||||
assert.Len(t, snap, 2)
|
||||
for _, s := range snap {
|
||||
assert.True(t, s.ObservedAt.IsZero(),
|
||||
"freshly-reset peer %s should have a zero observed_at", s.WorkerID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPeerStatus_DecodesUpResponse(t *testing.T) {
|
||||
up := true
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(PeerStatus{
|
||||
WorkerID: "remote",
|
||||
Up: up,
|
||||
ObservedAt: now,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
obs := fetchPeerStatus(context.Background(), &wire.PeerInfo{
|
||||
WorkerID: "remote",
|
||||
URL: srv.URL,
|
||||
})
|
||||
assert.True(t, obs.Up)
|
||||
assert.NoError(t, obs.Err)
|
||||
assert.Equal(t, "remote", obs.WorkerID)
|
||||
assert.Equal(t, now, obs.ObservedAt)
|
||||
}
|
||||
|
||||
func TestFetchPeerStatus_NetworkErrorIsRecorded(t *testing.T) {
|
||||
obs := fetchPeerStatus(context.Background(), &wire.PeerInfo{
|
||||
WorkerID: "unreachable",
|
||||
URL: "http://127.0.0.1:1",
|
||||
})
|
||||
assert.Error(t, obs.Err)
|
||||
assert.False(t, obs.Up)
|
||||
}
|
||||
|
||||
func TestFetchPeerStatus_BadURLRejected(t *testing.T) {
|
||||
obs := fetchPeerStatus(context.Background(), &wire.PeerInfo{
|
||||
WorkerID: "weird",
|
||||
URL: "ftp://example.com",
|
||||
})
|
||||
assert.Error(t, obs.Err)
|
||||
}
|
||||
|
||||
func TestDecideConsensus_MultiWorkerQuorumGate(t *testing.T) {
|
||||
// When the control plane has configured peers (a multi-worker
|
||||
// install), a lone self vote must NOT be treated as a
|
||||
// cluster-wide verdict in either direction. A second,
|
||||
// independent vote is required to reach any verdict.
|
||||
up := true
|
||||
down := false
|
||||
peersDown := []peerObservation{{WorkerID: "p1", Up: false, ObservedAt: time.Now()}}
|
||||
peersUp := []peerObservation{{WorkerID: "p1", Up: true, ObservedAt: time.Now()}}
|
||||
|
||||
t.Run("self down + configured peers but no fresh peer = no quorum", func(t *testing.T) {
|
||||
assert.Equal(t, consensusNoQuorum, decideConsensus(&down, nil, 2),
|
||||
"a lone down vote must not page when peers are configured but not reporting")
|
||||
assert.Equal(t, consensusNoQuorum, decideConsensus(&up, nil, 2),
|
||||
"a lone up vote must not clear an active alert when peers are configured but not reporting")
|
||||
})
|
||||
|
||||
t.Run("self down + one fresh down peer = down consensus", func(t *testing.T) {
|
||||
assert.Equal(t, consensusDown, decideConsensus(&down, peersDown, 2))
|
||||
})
|
||||
|
||||
t.Run("self up + one fresh up peer = up consensus", func(t *testing.T) {
|
||||
assert.Equal(t, consensusUp, decideConsensus(&up, peersUp, 2))
|
||||
})
|
||||
|
||||
t.Run("self down + one fresh up peer = no quorum (1/1 split)", func(t *testing.T) {
|
||||
// With two voters and one of each, neither side has a
|
||||
// majority, so the verdict stays NoQuorum. This is the
|
||||
// intended conservative behavior — we don't want a
|
||||
// single dissenting peer to override self.
|
||||
assert.Equal(t, consensusNoQuorum, decideConsensus(&down, peersUp, 2))
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecideConsensus_SingleNodePreserved(t *testing.T) {
|
||||
// When no peers are configured (single-worker / no-peer
|
||||
// deployment) the caller passes minVotes=1, which preserves
|
||||
// the prior single-node behavior: a self-only vote is enough
|
||||
// to drive a verdict in either direction.
|
||||
up := true
|
||||
down := false
|
||||
|
||||
assert.Equal(t, consensusUp, decideConsensus(&up, nil, 1),
|
||||
"single-node self up must still reach up consensus")
|
||||
assert.Equal(t, consensusDown, decideConsensus(&down, nil, 1),
|
||||
"single-node self down must still reach down consensus")
|
||||
}
|
||||
|
||||
func TestPeerObservationsForConsensus_DropsStale(t *testing.T) {
|
||||
executor := func(interface{}) interface{} { return []wire.CheckResultReport{} }
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
|
||||
now := time.Now()
|
||||
fresh := peerObservation{WorkerID: "p1", Up: true, ObservedAt: now.Add(-30 * time.Second)}
|
||||
stale := peerObservation{WorkerID: "p2", Up: true, ObservedAt: now.Add(-5 * time.Minute)}
|
||||
errored := peerObservation{WorkerID: "p3", Up: true, Err: assert.AnError}
|
||||
unknown := peerObservation{WorkerID: "p4"}
|
||||
r.peerCache.put(fresh)
|
||||
r.peerCache.put(stale)
|
||||
r.peerCache.put(errored)
|
||||
r.peerCache.put(unknown)
|
||||
|
||||
got := r.peerObservationsForConsensus(now)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "p1", got[0].WorkerID)
|
||||
}
|
||||
|
||||
func TestPollPeersOnceIsConcurrencySafe(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(PeerStatus{
|
||||
WorkerID: "remote",
|
||||
Up: true,
|
||||
ObservedAt: time.Now().UTC(),
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
executor := func(interface{}) interface{} { return []wire.CheckResultReport{} }
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
URL: "http://127.0.0.1:1", // local self probe will fail, no effect on peer poll
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "p1", URL: srv.URL},
|
||||
},
|
||||
})
|
||||
|
||||
// Run multiple concurrent poll cycles. The cache uses a single
|
||||
// RWMutex; this verifies there is no data race under that
|
||||
// access pattern.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
r.pollPeersOnce(context.Background())
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
assert.GreaterOrEqual(t, atomic.LoadInt32(&hits), int32(1))
|
||||
}
|
||||
156
internal/distworker/results.go
Обычный файл
156
internal/distworker/results.go
Обычный файл
@@ -0,0 +1,156 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// recentResultsSize is the in-memory ring buffer capacity for the
|
||||
// last N check results produced by the worker. The webapp reads
|
||||
// from this buffer for the /checks page and the recent-results
|
||||
// counters on /overview.
|
||||
const recentResultsSize = 200
|
||||
|
||||
// recentNotificationsSize mirrors recentResultsSize for emitted
|
||||
// notifications. Phase 1 only writes selfcheck alerts to this
|
||||
// buffer (the main app's notification flow still lives in the main
|
||||
// app); the buffer is shape-stable so future phases can append
|
||||
// without changing the page contract.
|
||||
const recentNotificationsSize = 100
|
||||
|
||||
// ResultRow is one row from the worker's in-memory result ring
|
||||
// buffer. Kept in the distworker package so the webapp can read it
|
||||
// without going through wire (which is a payload envelope, not a
|
||||
// stable render type).
|
||||
type ResultRow struct {
|
||||
MonitorID int64
|
||||
CheckID int64
|
||||
Kind string
|
||||
Host string
|
||||
State string
|
||||
DurationMs int64
|
||||
Error string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// NotificationRow is one row from the worker's notification ring
|
||||
// buffer. Phase 1 only fills this from selfcheck alerts; the row
|
||||
// shape is forward-compatible with main-app-issued notifications.
|
||||
type NotificationRow struct {
|
||||
Kind string // "email", "telegram_private", "telegram_group"
|
||||
Channel string
|
||||
Subject string
|
||||
Body string
|
||||
OK bool
|
||||
Error string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// resultBuffer is a thread-safe FIFO ring buffer of ResultRow. The
|
||||
// Runner owns one; both the dispatcher goroutine and the webapp
|
||||
// readers can touch it concurrently.
|
||||
type resultBuffer struct {
|
||||
mu sync.RWMutex
|
||||
buf []ResultRow
|
||||
head int
|
||||
size int
|
||||
}
|
||||
|
||||
func newResultBuffer() *resultBuffer {
|
||||
return &resultBuffer{buf: make([]ResultRow, recentResultsSize)}
|
||||
}
|
||||
|
||||
// add appends one entry, evicting the oldest when full.
|
||||
func (b *resultBuffer) add(r *ResultRow) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.buf[b.head] = *r
|
||||
b.head = (b.head + 1) % len(b.buf)
|
||||
if b.size < len(b.buf) {
|
||||
b.size++
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot returns the most recent n rows in chronological order.
|
||||
// n <= 0 returns an empty slice.
|
||||
func (b *resultBuffer) snapshot(n int) []ResultRow {
|
||||
return ringSnapshot(&b.mu, &b.head, &b.size, &b.buf, n)
|
||||
}
|
||||
|
||||
// notificationBuffer mirrors resultBuffer for NotificationRow.
|
||||
type notificationBuffer struct {
|
||||
mu sync.RWMutex
|
||||
buf []NotificationRow
|
||||
head int
|
||||
size int
|
||||
}
|
||||
|
||||
func newNotificationBuffer() *notificationBuffer {
|
||||
return ¬ificationBuffer{buf: make([]NotificationRow, recentNotificationsSize)}
|
||||
}
|
||||
|
||||
func (b *notificationBuffer) add(r *NotificationRow) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.buf[b.head] = *r
|
||||
b.head = (b.head + 1) % len(b.buf)
|
||||
if b.size < len(b.buf) {
|
||||
b.size++
|
||||
}
|
||||
}
|
||||
|
||||
func (b *notificationBuffer) snapshot(n int) []NotificationRow {
|
||||
return ringSnapshot(&b.mu, &b.head, &b.size, &b.buf, n)
|
||||
}
|
||||
|
||||
// ringSnapshot is a generic FIFO ring-buffer snapshot. The caller
|
||||
// passes the mutex/head/size/buf by pointer; the lock is taken
|
||||
// while reading. n <= 0 returns nil. The returned slice is a copy
|
||||
// so callers can hand it to non-locking code paths (e.g. the
|
||||
// webapp's templates) without holding the buffer lock.
|
||||
func ringSnapshot[T any](mu *sync.RWMutex, head, size *int, buf *[]T, n int) []T {
|
||||
if mu == nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
if *size == 0 {
|
||||
return nil
|
||||
}
|
||||
if n > *size {
|
||||
n = *size
|
||||
}
|
||||
out := make([]T, 0, n)
|
||||
start := (*head - n + len(*buf)) % len(*buf)
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (start + i) % len(*buf)
|
||||
out = append(out, (*buf)[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resultRowFromReport converts a wire.CheckResultReport into the
|
||||
// internal ResultRow type the webapp renders.
|
||||
func resultRowFromReport(env *resultEnvelope, report *wire.CheckResultReport, at time.Time) *ResultRow {
|
||||
row := &ResultRow{
|
||||
MonitorID: report.MonitorID,
|
||||
CheckID: report.CheckID,
|
||||
Kind: env.job.Kind,
|
||||
Host: env.job.Host,
|
||||
State: report.State,
|
||||
DurationMs: report.DurationMs,
|
||||
At: at,
|
||||
}
|
||||
if report.Error != nil {
|
||||
row.Error = *report.Error
|
||||
}
|
||||
return row
|
||||
}
|
||||
141
internal/distworker/results_test.go
Обычный файл
141
internal/distworker/results_test.go
Обычный файл
@@ -0,0 +1,141 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
func TestResultBufferSnapshotEmpty(t *testing.T) {
|
||||
b := newResultBuffer()
|
||||
assert.Nil(t, b.snapshot(0))
|
||||
assert.Nil(t, b.snapshot(10))
|
||||
}
|
||||
|
||||
func TestResultBufferAppendAndSnapshot(t *testing.T) {
|
||||
b := newResultBuffer()
|
||||
for i := 0; i < 12; i++ {
|
||||
b.add(&ResultRow{MonitorID: int64(i), State: "OK"})
|
||||
}
|
||||
got := b.snapshot(20)
|
||||
// Buffer is fixed-size so we cap at recentResultsSize; here we
|
||||
// added only 12 entries so the buffer still holds all of them.
|
||||
require.Len(t, got, 12)
|
||||
assert.Equal(t, int64(0), got[0].MonitorID)
|
||||
assert.Equal(t, int64(11), got[11].MonitorID)
|
||||
}
|
||||
|
||||
func TestResultBufferEvictsOldest(t *testing.T) {
|
||||
b := newResultBuffer()
|
||||
total := recentResultsSize + 5
|
||||
for i := 0; i < total; i++ {
|
||||
b.add(&ResultRow{MonitorID: int64(i)})
|
||||
}
|
||||
got := b.snapshot(recentResultsSize)
|
||||
require.Len(t, got, recentResultsSize)
|
||||
// The first surviving entry is i=5 (the (N+1)-th add).
|
||||
assert.Equal(t, int64(5), got[0].MonitorID)
|
||||
assert.Equal(t, int64(total-1), got[recentResultsSize-1].MonitorID)
|
||||
}
|
||||
|
||||
func TestResultBufferConcurrentAccess(t *testing.T) {
|
||||
b := newResultBuffer()
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(off int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
b.add(&ResultRow{MonitorID: int64(off*100 + i)})
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
_ = b.snapshot(10)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
got := b.snapshot(recentResultsSize)
|
||||
assert.NotNil(t, got)
|
||||
}
|
||||
|
||||
func TestNotificationBufferAppendAndSnapshot(t *testing.T) {
|
||||
b := newNotificationBuffer()
|
||||
for i := 0; i < 3; i++ {
|
||||
b.add(&NotificationRow{Subject: "s" + itoaForTest(i), At: time.Now()})
|
||||
}
|
||||
got := b.snapshot(10)
|
||||
require.Len(t, got, 3)
|
||||
assert.Equal(t, "s0", got[0].Subject)
|
||||
}
|
||||
|
||||
func TestRunnerRecentResultsBeforeStart(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 2})
|
||||
assert.Nil(t, r.RecentResults(5),
|
||||
"RecentResults must be safe before Start")
|
||||
}
|
||||
|
||||
func TestRunnerRecentNotificationsBeforeStart(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 2})
|
||||
assert.Nil(t, r.RecentNotifications(5))
|
||||
}
|
||||
|
||||
func TestRunnerRecordNotification(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 2})
|
||||
r.RecordNotification(&NotificationRow{Subject: "x", OK: true})
|
||||
r.RecordNotification(&NotificationRow{Subject: "y", OK: false, Error: "boom"})
|
||||
got := r.RecentNotifications(10)
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "x", got[0].Subject)
|
||||
assert.True(t, got[0].OK)
|
||||
assert.False(t, got[1].OK)
|
||||
assert.Equal(t, "boom", got[1].Error)
|
||||
}
|
||||
|
||||
func TestRunnerIdentityFieldsFromInit(t *testing.T) {
|
||||
executor := func(interface{}) interface{} { return []wire.CheckResultReport{} }
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
RegionCode: "eu",
|
||||
Version: "v1.2.3",
|
||||
Capabilities: []string{"http", "ssl"},
|
||||
})
|
||||
assert.Equal(t, "w-1", r.WorkerID())
|
||||
assert.Equal(t, "eu", r.RegionCode())
|
||||
assert.Equal(t, "v1.2.3", r.WorkerVersion())
|
||||
assert.Equal(t, []string{"http", "ssl"}, r.WorkerCapabilities())
|
||||
}
|
||||
|
||||
func TestRunnerTokenAccessors(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 1, Token: "tok-1"})
|
||||
assert.Equal(t, "tok-1", r.Token())
|
||||
assert.True(t, r.TokenRotatedAt().IsZero(), "no rotation yet")
|
||||
r.tokenRotatedMu.Lock()
|
||||
r.tokenRotatedAt = time.Now().UTC()
|
||||
r.tokenRotatedMu.Unlock()
|
||||
assert.False(t, r.TokenRotatedAt().IsZero())
|
||||
}
|
||||
|
||||
func itoaForTest(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
const d = "0123456789"
|
||||
out := ""
|
||||
for n > 0 {
|
||||
out = string(d[n%10]) + out
|
||||
n /= 10
|
||||
}
|
||||
return out
|
||||
}
|
||||
1030
internal/distworker/runner.go
Обычный файл
1030
internal/distworker/runner.go
Обычный файл
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
19
internal/distworker/runner_protocol_test.go
Обычный файл
19
internal/distworker/runner_protocol_test.go
Обычный файл
@@ -0,0 +1,19 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
func TestEnqueueTaskMessagePrefersEnvelopeOverLegacyCheck(t *testing.T) {
|
||||
r := &Runner{jobQueue: make(chan wire.CheckJob, 2), stopCh: make(chan struct{})}
|
||||
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{Type: wire.TaskTypeCheck, Job: &wire.CheckJob{JobID: "v2"}}, Task: &wire.CheckJob{JobID: "v1"}}
|
||||
require.True(t, r.enqueueTaskMessage(message))
|
||||
job := <-r.jobQueue
|
||||
assert.Equal(t, "v2", job.JobID)
|
||||
assert.Empty(t, r.jobQueue, "the sibling legacy field must not run a second check")
|
||||
}
|
||||
318
internal/distworker/runner_test.go
Обычный файл
318
internal/distworker/runner_test.go
Обычный файл
@@ -0,0 +1,318 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Jeffail/tunny"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// newTestRunner builds a runner with a deterministic executor and the
|
||||
// fixed dispatcher/pool layout. It registers a cleanup hook that drains
|
||||
// the runner so tests can leak-free.
|
||||
func newTestRunner(t *testing.T, maxConc, poolSize int, fn func(payload interface{}) interface{}) *Runner {
|
||||
t.Helper()
|
||||
r := NewRunner(&Config{MaxConcurrency: maxConc})
|
||||
r.executor = fn
|
||||
require.NotNil(t, r.executor)
|
||||
atomic.StoreInt64(&r.concurrency, int64(poolSize))
|
||||
r.jobQueue = make(chan wire.CheckJob, r.queueCapacity())
|
||||
r.results = make(chan resultEnvelope, r.queueCapacity())
|
||||
r.pool = tunny.NewFunc(poolSize, r.executor)
|
||||
for i := 0; i < maxConc; i++ {
|
||||
r.wg.Add(1)
|
||||
go r.dispatcher()
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
r.Stop()
|
||||
r.wg.Wait()
|
||||
r.pool.Close()
|
||||
close(r.results)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestQueueCapacityScalesWithMaxConcurrency(t *testing.T) {
|
||||
cases := []struct {
|
||||
maxConc int
|
||||
wantCapacity int
|
||||
}{
|
||||
// 2*maxConc, floored at minQueueCapacity so a small pool still
|
||||
// has backpressure headroom.
|
||||
{maxConc: 1, wantCapacity: minQueueCapacity},
|
||||
{maxConc: 4, wantCapacity: minQueueCapacity},
|
||||
{maxConc: 16, wantCapacity: 2 * 16},
|
||||
{maxConc: 64, wantCapacity: 2 * 64},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run("max="+strconv.Itoa(tc.maxConc), func(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: tc.maxConc})
|
||||
assert.Equal(t, tc.wantCapacity, r.QueueCapacity(),
|
||||
"queue capacity should scale with max concurrency")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherRunsJobsConcurrently(t *testing.T) {
|
||||
const (
|
||||
maxConc = 8
|
||||
poolSize = 4
|
||||
jobCount = 12
|
||||
hold = 80 * time.Millisecond
|
||||
)
|
||||
var (
|
||||
inFlight atomic.Int64
|
||||
peak atomic.Int64
|
||||
)
|
||||
|
||||
executor := func(payload interface{}) interface{} {
|
||||
cur := inFlight.Add(1)
|
||||
for {
|
||||
p := peak.Load()
|
||||
if cur <= p || peak.CompareAndSwap(p, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(hold)
|
||||
inFlight.Add(-1)
|
||||
job := payload.(wire.CheckJob)
|
||||
return []wire.CheckResultReport{{
|
||||
JobID: job.JobID,
|
||||
CheckID: job.CheckID,
|
||||
State: "OK",
|
||||
}}
|
||||
}
|
||||
|
||||
r := newTestRunner(t, maxConc, poolSize, executor)
|
||||
|
||||
// Drain the results channel so dispatchers do not block.
|
||||
var drainWG sync.WaitGroup
|
||||
drainWG.Add(1)
|
||||
go func() {
|
||||
defer drainWG.Done()
|
||||
for i := 0; i < jobCount; i++ {
|
||||
select {
|
||||
case <-r.results:
|
||||
case <-r.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for i := 0; i < jobCount; i++ {
|
||||
job := wire.CheckJob{
|
||||
JobID: "job-" + strconv.Itoa(i),
|
||||
CheckID: int64(i + 1),
|
||||
Kind: "http",
|
||||
Host: "example.com",
|
||||
}
|
||||
require.True(t, r.Enqueue(job))
|
||||
}
|
||||
|
||||
drainWG.Wait()
|
||||
// The tunny.Pool size (poolSize) limits how many jobs run in
|
||||
// parallel, so the peak should be at most poolSize and at least 2
|
||||
// (otherwise the test would pass on a serial pool).
|
||||
observed := peak.Load()
|
||||
assert.GreaterOrEqual(t, observed, int64(2),
|
||||
"expected concurrent execution, observed peak=%d", observed)
|
||||
assert.LessOrEqual(t, observed, int64(poolSize),
|
||||
"peak should be bounded by pool size, observed peak=%d", observed)
|
||||
}
|
||||
|
||||
func TestEnqueueRespectsBackpressure(t *testing.T) {
|
||||
// Use a small queue with no dispatchers consuming it, so the bounded
|
||||
// channel is the only source of backpressure. This is the cleanest
|
||||
// way to assert that Enqueue parks when the buffer is full.
|
||||
const queueCap = 4
|
||||
r := NewRunner(&Config{MaxConcurrency: 2})
|
||||
r.jobQueue = make(chan wire.CheckJob, queueCap)
|
||||
r.results = make(chan resultEnvelope, queueCap)
|
||||
t.Cleanup(func() {
|
||||
r.Stop()
|
||||
close(r.results)
|
||||
})
|
||||
|
||||
// Fill the bounded queue.
|
||||
for i := 0; i < queueCap; i++ {
|
||||
require.True(t, r.Enqueue(wire.CheckJob{JobID: "prefill-" + strconv.Itoa(i)}))
|
||||
}
|
||||
assert.Equal(t, queueCap, r.QueueDepth(),
|
||||
"queue should be full after %d enqueues", queueCap)
|
||||
|
||||
// The next Enqueue must block because the queue is full.
|
||||
enqueueDone := make(chan bool, 1)
|
||||
go func() {
|
||||
enqueueDone <- r.Enqueue(wire.CheckJob{JobID: "blocking"})
|
||||
}()
|
||||
|
||||
select {
|
||||
case got := <-enqueueDone:
|
||||
t.Fatalf("Enqueue returned %v while the queue was full; expected backpressure", got)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// expected: still parked
|
||||
}
|
||||
|
||||
// Free a slot and confirm the parked Enqueue unblocks.
|
||||
select {
|
||||
case <-r.jobQueue:
|
||||
case <-r.stopCh:
|
||||
t.Fatal("runner stopped unexpectedly")
|
||||
}
|
||||
select {
|
||||
case ok := <-enqueueDone:
|
||||
assert.True(t, ok, "Enqueue should succeed once a slot is free")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Enqueue did not unblock after slot was freed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInitResizesPool(t *testing.T) {
|
||||
executor := func(payload interface{}) interface{} {
|
||||
return []wire.CheckResultReport{}
|
||||
}
|
||||
|
||||
r := newTestRunner(t, 8, 1, executor)
|
||||
// Replace the pool with a proxy that records SetSize calls. The
|
||||
// proxy delegates Close back to the underlying pool so the test
|
||||
// runner cleanup only closes the pool once.
|
||||
original := r.pool
|
||||
proxy := newSizeProbe(original)
|
||||
r.pool = proxy
|
||||
|
||||
r.applyInit(&wire.WorkerInit{Concurrency: 5, WorkerID: "w-1"})
|
||||
assert.Equal(t, 5, r.Concurrency(), "applyInit should update pool size")
|
||||
require.NotEmpty(t, proxy.sizes, "expected pool.SetSize to be called")
|
||||
assert.Equal(t, 5, proxy.sizes[len(proxy.sizes)-1])
|
||||
|
||||
// Concurrency above maxConcurrency should be clamped.
|
||||
r.applyInit(&wire.WorkerInit{Concurrency: 999, WorkerID: "w-1"})
|
||||
assert.Equal(t, r.MaxConcurrency(), r.Concurrency(),
|
||||
"applyInit should clamp concurrency to maxConcurrency")
|
||||
}
|
||||
|
||||
// sizeProbePool wraps a jobPool and records SetSize calls. Close is
|
||||
// forwarded to the wrapped pool so cleanup happens exactly once.
|
||||
type sizeProbePool struct {
|
||||
jobPool
|
||||
sizes []int
|
||||
}
|
||||
|
||||
func newSizeProbe(p jobPool) *sizeProbePool {
|
||||
return &sizeProbePool{jobPool: p}
|
||||
}
|
||||
|
||||
func (s *sizeProbePool) SetSize(n int) {
|
||||
s.sizes = append(s.sizes, n)
|
||||
s.jobPool.SetSize(n)
|
||||
}
|
||||
|
||||
func TestApplyInitIgnoresNonPositive(t *testing.T) {
|
||||
executor := func(payload interface{}) interface{} {
|
||||
return []wire.CheckResultReport{}
|
||||
}
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
// newTestRunner mirrors Start()'s initial concurrency of 1.
|
||||
require.Equal(t, 1, r.Concurrency())
|
||||
|
||||
// Concurrency = 0 must not break the runner or change its size.
|
||||
r.applyInit(&wire.WorkerInit{Concurrency: 0})
|
||||
assert.Equal(t, 1, r.Concurrency(),
|
||||
"non-positive concurrency should be ignored")
|
||||
|
||||
// Concurrency = -5 should also be ignored.
|
||||
r.applyInit(&wire.WorkerInit{Concurrency: -5})
|
||||
assert.Equal(t, 1, r.Concurrency())
|
||||
}
|
||||
|
||||
// TestApplyInitStoresCredentialsInMemory verifies that WorkerInit.Credentials
|
||||
// is stored on the Runner via applyInit and is retrievable through
|
||||
// Credentials(). It also confirms that a subsequent applyInit with nil
|
||||
// credentials replaces the previous value.
|
||||
func TestApplyInitStoresCredentialsInMemory(t *testing.T) {
|
||||
executor := func(payload interface{}) interface{} {
|
||||
return []wire.CheckResultReport{}
|
||||
}
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
|
||||
// Before any applyInit, Credentials() returns nil.
|
||||
assert.Nil(t, r.Credentials(), "Credentials() must return nil before any init")
|
||||
|
||||
want := &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{
|
||||
ID: 11,
|
||||
Name: "primary",
|
||||
Server: "smtp.example.com",
|
||||
Port: 587,
|
||||
Login: "alerts@example.com",
|
||||
Password: "smtp-password-xyz",
|
||||
}},
|
||||
Telegram: []wire.TelegramCredential{{
|
||||
ID: 22,
|
||||
Name: "main-bot",
|
||||
Token: "bot-token-9876543210:ABCDEFG",
|
||||
}},
|
||||
}
|
||||
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 2,
|
||||
Credentials: want,
|
||||
})
|
||||
|
||||
got := r.Credentials()
|
||||
require.NotNil(t, got, "Credentials() must return non-nil after applyInit")
|
||||
require.Len(t, got.SMTP, 1)
|
||||
require.Len(t, got.Telegram, 1)
|
||||
assert.Equal(t, "primary", got.SMTP[0].Name)
|
||||
assert.Equal(t, "smtp-password-xyz", got.SMTP[0].Password)
|
||||
assert.Equal(t, "main-bot", got.Telegram[0].Name)
|
||||
assert.Equal(t, "bot-token-9876543210:ABCDEFG", got.Telegram[0].Token)
|
||||
|
||||
// A subsequent applyInit with nil Credentials replaces the value.
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2})
|
||||
assert.Nil(t, r.Credentials(),
|
||||
"Credentials() must return nil after applyInit with nil Credentials")
|
||||
}
|
||||
|
||||
func TestNotificationHeartbeatCounters(t *testing.T) {
|
||||
r := NewRunner(&Config{MaxConcurrency: 1})
|
||||
atomic.StoreInt64(&r.notifyDepth, 2)
|
||||
atomic.StoreInt64(&r.notifyActive, 3)
|
||||
assert.Equal(t, 5, r.ActiveNotifications())
|
||||
assert.Equal(t, 2, r.NotificationQueueDepth())
|
||||
}
|
||||
|
||||
// TestApplyInitStoresURLInMemory verifies that the URL field added in
|
||||
// Task 2 is stored on the Runner via applyInit and exposed through
|
||||
// URL(). A subsequent applyInit with empty URL replaces the previous
|
||||
// value (consistent with the Credentials contract).
|
||||
func TestApplyInitStoresURLInMemory(t *testing.T) {
|
||||
executor := func(payload interface{}) interface{} {
|
||||
return []wire.CheckResultReport{}
|
||||
}
|
||||
r := newTestRunner(t, 4, 1, executor)
|
||||
|
||||
// Before any applyInit, URL() returns empty.
|
||||
assert.Equal(t, "", r.URL(), "URL() must return empty before any init")
|
||||
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 2,
|
||||
URL: "https://worker-eu.example.com",
|
||||
})
|
||||
assert.Equal(t, "https://worker-eu.example.com", r.URL(),
|
||||
"URL() must return the value pushed by applyInit")
|
||||
|
||||
// Empty URL in a subsequent applyInit must clear the stored value.
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2})
|
||||
assert.Equal(t, "", r.URL(),
|
||||
"URL() must return empty after applyInit with empty URL")
|
||||
}
|
||||
373
internal/distworker/selfcheck.go
Обычный файл
373
internal/distworker/selfcheck.go
Обычный файл
@@ -0,0 +1,373 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
mathrand "math/rand/v2"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/app/models/concerns"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkexec"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notify"
|
||||
"rsgit.ru/rsmon/rsmon/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
|
||||
}
|
||||
625
internal/distworker/selfcheck_test.go
Обычный файл
625
internal/distworker/selfcheck_test.go
Обычный файл
@@ -0,0 +1,625 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
func TestRunMainAPIHTTPCheck_OK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ok := runMainAPIHTTPCheck(srv.URL)
|
||||
assert.True(t, ok, "200 OK should be a successful probe")
|
||||
}
|
||||
|
||||
func TestRunMainAPIHTTPCheck_404IsDown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ok := runMainAPIHTTPCheck(srv.URL)
|
||||
assert.False(t, ok, "normal HTTP check flow treats non-200 as down")
|
||||
}
|
||||
|
||||
func TestRunMainAPIHTTPCheck_5xxIsDown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ok := runMainAPIHTTPCheck(srv.URL)
|
||||
assert.False(t, ok, "5xx should be treated as down")
|
||||
}
|
||||
|
||||
func TestRunMainAPIHTTPCheck_NetworkErrorIsDown(t *testing.T) {
|
||||
ok := runMainAPIHTTPCheck("http://127.0.0.1:1")
|
||||
assert.False(t, ok, "connection refused should be treated as down")
|
||||
}
|
||||
|
||||
func TestSelfcheckTarget(t *testing.T) {
|
||||
assert.Equal(t, "https://rsmon.ru/up", selfcheckTarget("https://rsmon.ru"))
|
||||
assert.Equal(t, "https://rsmon.ru/up", selfcheckTarget("https://rsmon.ru/"))
|
||||
assert.Equal(t, "https://api.example.com/api/v1/up", selfcheckTarget("https://api.example.com/api/v1"))
|
||||
assert.Equal(t, "", selfcheckTarget(""), "empty base URL yields empty target")
|
||||
}
|
||||
|
||||
func TestParseTelegramChatID(t *testing.T) {
|
||||
id, err := parseTelegramChatID("200318758")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(200318758), id)
|
||||
|
||||
_, err = parseTelegramChatID("")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = parseTelegramChatID("not-a-number")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSMTPCredToModelCarriesPlaintextSecret(t *testing.T) {
|
||||
c := wire.SMTPCredential{
|
||||
ID: 11,
|
||||
Name: "primary",
|
||||
Server: "smtp.example.com",
|
||||
Port: 587,
|
||||
Login: "alerts@example.com",
|
||||
Password: "smtp-password-xyz",
|
||||
FromName: "RSMon",
|
||||
FromAddress: "alerts@example.com",
|
||||
}
|
||||
cred := smtpCredToModel(&c)
|
||||
require.NotNil(t, cred)
|
||||
assert.Equal(t, "plain:smtp-password-xyz", cred.SecretEnc)
|
||||
assert.Equal(t, int64(11), cred.ID)
|
||||
assert.Equal(t, models_credentialKindSMTP(), cred.Kind)
|
||||
|
||||
got, err := cred.GetSecret()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "smtp-password-xyz", got,
|
||||
"worker-side credential must yield plaintext via models.GetSecret")
|
||||
}
|
||||
|
||||
func TestTelegramCredToModelCarriesPlaintextToken(t *testing.T) {
|
||||
c := wire.TelegramCredential{
|
||||
ID: 22,
|
||||
Name: "main-bot",
|
||||
BotName: "rsmon_alerts_bot",
|
||||
Token: "bot-token-9876543210:ABCDEFG",
|
||||
APIURL: "https://api.telegram.org",
|
||||
}
|
||||
cred := telegramCredToModel(c)
|
||||
require.NotNil(t, cred)
|
||||
assert.Equal(t, "plain:bot-token-9876543210:ABCDEFG", cred.SecretEnc)
|
||||
assert.Equal(t, int64(22), cred.ID)
|
||||
assert.Equal(t, models_credentialKindTelegram(), cred.Kind)
|
||||
|
||||
got, err := cred.GetSecret()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "bot-token-9876543210:ABCDEFG", got)
|
||||
}
|
||||
|
||||
// models_credentialKindSMTP/Telegram are local shims to avoid an import-cycle
|
||||
// in this isolated test file. The real constants live in app/models and are
|
||||
// asserted at runtime through the wire translation functions.
|
||||
func models_credentialKindSMTP() string { return "smtp" }
|
||||
func models_credentialKindTelegram() string { return "telegram" }
|
||||
|
||||
func TestApplyInitStoresSystemContacts(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
|
||||
assert.Empty(t, r.SystemContacts(), "no system contacts before init")
|
||||
|
||||
want := []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com", Name: "ops"},
|
||||
{ID: 2, Kind: "telegram_group", Value: "200318758", Name: "alerts"},
|
||||
}
|
||||
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 2,
|
||||
SystemContacts: want,
|
||||
})
|
||||
|
||||
got := r.SystemContacts()
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, want[0].ID, got[0].ID)
|
||||
assert.Equal(t, "email", got[0].Kind)
|
||||
assert.Equal(t, "ops@example.com", got[0].Value)
|
||||
assert.Equal(t, "telegram_group", got[1].Kind)
|
||||
assert.Equal(t, "200318758", got[1].Value)
|
||||
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "w-1", Concurrency: 2})
|
||||
assert.Empty(t, r.SystemContacts(),
|
||||
"subsequent init with empty SystemContacts should replace cache")
|
||||
}
|
||||
|
||||
func TestRunSelfcheckOnce_FailureStartsDownStateNoAlert(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.config.URL = srv.URL
|
||||
creds := &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp.example.com", Port: 587, Login: "a@a", FromAddress: "a@a", Password: "x"}},
|
||||
}
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: creds,
|
||||
SystemContacts: []wire.SystemContact{{ID: 1, Kind: "email", Value: "ops@example.com"}},
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
require.NotNil(t, state.consensus.downSince,
|
||||
"first down probe must stamp downSince")
|
||||
assert.False(t, state.consensus.alertActive,
|
||||
"no alert expected before selfcheckConsensusWait elapses")
|
||||
}
|
||||
|
||||
func TestRunSelfcheckOnce_AlertFiredAfterConsensusWait(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.config.URL = "http://127.0.0.1:1"
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
downSince := time.Now().Add(-selfcheckConsensusWait - time.Second)
|
||||
state.consensus.downSince = &downSince
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.True(t, state.consensus.alertActive,
|
||||
"alert must be active after selfcheckConsensusWait elapses")
|
||||
}
|
||||
|
||||
func TestRunSelfcheckOnce_RecoveryClearsAlert(t *testing.T) {
|
||||
srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvOK.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
state.consensus.alertActive = true
|
||||
downSince := time.Now().Add(-20 * time.Minute)
|
||||
state.consensus.downSince = &downSince
|
||||
|
||||
r.config.URL = srvOK.URL
|
||||
// First up tick: marks the up consensus start; recovery alert
|
||||
// waits for selfcheckConsensusWait. Assert the alert is still
|
||||
// active so we can distinguish "up just arrived" from "up held".
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.True(t, state.consensus.alertActive,
|
||||
"recovery alert should not fire on the first up tick")
|
||||
assert.Equal(t, consensusUp, state.consensus.lastVerdict)
|
||||
|
||||
// Tick again with the up verdict now held past
|
||||
// selfcheckConsensusWait so the recovery alert fires.
|
||||
state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second))
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.False(t, state.consensus.alertActive,
|
||||
"recovery alert must clear alertActive after selfcheckConsensusWait up")
|
||||
assert.Nil(t, state.consensus.downSince)
|
||||
}
|
||||
|
||||
func TestRunSelfcheckOnce_RequiresSelfVote(t *testing.T) {
|
||||
// When self has not yet produced a verdict (nil), the
|
||||
// consensus must refuse to call the cluster down regardless of
|
||||
// what peers report.
|
||||
up := true
|
||||
peers := []peerObservation{{WorkerID: "p1", Up: true, ObservedAt: time.Now()}}
|
||||
verdict := decideConsensus(nil, peers, 2)
|
||||
assert.Equal(t, consensusNoQuorum, verdict,
|
||||
"missing self vote must prevent a down consensus")
|
||||
|
||||
verdict = decideConsensus(&up, peers, 2)
|
||||
assert.Equal(t, consensusUp, verdict,
|
||||
"self up + peer up must reach consensus up")
|
||||
}
|
||||
|
||||
func TestSendSystemAlert_NoCredentials(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
SystemContacts: []wire.SystemContact{{ID: 1, Kind: "email", Value: "ops@example.com"}},
|
||||
})
|
||||
|
||||
r.sendSystemAlert(t.Context(), true, "test message")
|
||||
}
|
||||
|
||||
func TestSendSystemAlert_NoContacts(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
})
|
||||
|
||||
r.sendSystemAlert(t.Context(), true, "test message")
|
||||
}
|
||||
|
||||
func TestWorkerInit_SystemContactsJSONRoundtrip(t *testing.T) {
|
||||
init := wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 4,
|
||||
SystemContacts: []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com", Name: "ops"},
|
||||
{ID: 2, Kind: "telegram_private", Value: "200318758", Name: "alerts"},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(raw), `"system_contacts":`)
|
||||
|
||||
var decoded wire.WorkerInit
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.Len(t, decoded.SystemContacts, 2)
|
||||
assert.Equal(t, init.SystemContacts[0], decoded.SystemContacts[0])
|
||||
assert.Equal(t, init.SystemContacts[1], decoded.SystemContacts[1])
|
||||
}
|
||||
|
||||
func TestWorkerInit_PeersJSONRoundtrip(t *testing.T) {
|
||||
init := wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Concurrency: 4,
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402", RegionCode: "local"},
|
||||
{WorkerID: "w-3", URL: "http://127.0.0.1:27403", RegionCode: "local", Login: "ops", Password: "x"},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
body := string(raw)
|
||||
assert.Contains(t, body, `"peers":[`)
|
||||
assert.Contains(t, body, `"worker_id":"w-2"`)
|
||||
assert.Contains(t, body, `"url":"http://127.0.0.1:27402"`)
|
||||
assert.Contains(t, body, `"login":"ops"`)
|
||||
assert.Contains(t, body, `"password":"x"`)
|
||||
|
||||
var decoded wire.WorkerInit
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
require.Len(t, decoded.Peers, 2)
|
||||
assert.Equal(t, init.Peers[0], decoded.Peers[0])
|
||||
assert.Equal(t, init.Peers[1], decoded.Peers[1])
|
||||
}
|
||||
|
||||
func TestWorkerInit_PeersOmittedWhenEmpty(t *testing.T) {
|
||||
init := wire.WorkerInit{WorkerID: "w-1", Concurrency: 1}
|
||||
raw, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(raw), `"peers"`,
|
||||
"empty peers slice must be omitted so legacy workers stay wire-compatible")
|
||||
}
|
||||
|
||||
// TestRunSelfcheckOnce_ConfiguredPeersNoFreshPeer_NoAlert verifies that
|
||||
// in a multi-worker install a transient local probe failure (or
|
||||
// success) does NOT fire an alert or reset the state machine when
|
||||
// peers are configured but have not produced a fresh observation yet.
|
||||
// The first peer poll lands 2s after startup, and after that on the
|
||||
// peer-poll interval, so the early ticks are a real "lone self vote"
|
||||
// window in production.
|
||||
func TestRunSelfcheckOnce_ConfiguredPeersNoFreshPeer_NoAlert(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.config.URL = srv.URL
|
||||
creds := &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
}
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: creds,
|
||||
SystemContacts: []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com"},
|
||||
},
|
||||
// Two peers configured but neither has produced a
|
||||
// fresh observation yet — the selfcheck is the lone
|
||||
// voter.
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402"},
|
||||
{WorkerID: "w-3", URL: "http://127.0.0.1:27403"},
|
||||
},
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
|
||||
assert.Nil(t, state.consensus.downSince,
|
||||
"with configured peers and no fresh peer observations, the down timer must not start")
|
||||
assert.False(t, state.consensus.alertActive,
|
||||
"no alert can fire without a peer-backed down verdict")
|
||||
}
|
||||
|
||||
// TestRunSelfcheckOnce_ConfiguredPeersWithFreshDownPeer_FiresAfterWait
|
||||
// exercises the happy path: self and one peer both see the master as
|
||||
// down, the consensus verdict is down, and after selfcheckConsensusWait
|
||||
// the alert fires exactly once.
|
||||
func TestRunSelfcheckOnce_ConfiguredPeersWithFreshDownPeer_FiresAfterWait(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.config.URL = srv.URL
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
SystemContacts: []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com"},
|
||||
},
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402"},
|
||||
},
|
||||
})
|
||||
|
||||
// Seed a fresh down observation from the peer as if its
|
||||
// selfcheck tick has just completed.
|
||||
r.peerCache.put(peerObservation{
|
||||
WorkerID: "w-2",
|
||||
Up: false,
|
||||
ObservedAt: time.Now(),
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
|
||||
// First tick: stamps downSince, does not yet fire.
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
require.NotNil(t, state.consensus.downSince,
|
||||
"peer-backed down verdict must stamp downSince on first tick")
|
||||
assert.False(t, state.consensus.alertActive,
|
||||
"alert must not fire before selfcheckConsensusWait elapses")
|
||||
|
||||
// Second tick, simulate the 5-minute wait having elapsed
|
||||
// by rewinding downSince past the wait threshold. This
|
||||
// avoids waiting wall-clock time in a unit test.
|
||||
state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second))
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.True(t, state.consensus.alertActive,
|
||||
"down consensus held for selfcheckConsensusWait must fire the alert exactly once")
|
||||
}
|
||||
|
||||
func TestRunSelfcheckOnce_NonLeaderSkipsDuplicateAlert(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.config.URL = srv.URL
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "worker-local-2",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
SystemContacts: []wire.SystemContact{{ID: 1, Kind: "email", Value: "ops@example.com"}},
|
||||
Peers: []wire.PeerInfo{{WorkerID: "worker-local-1", URL: "http://127.0.0.1:27401"}},
|
||||
})
|
||||
r.peerCache.put(peerObservation{WorkerID: "worker-local-1", Up: false, ObservedAt: time.Now()})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second))
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
|
||||
assert.True(t, state.consensus.alertActive, "non-leader still marks the incident handled locally")
|
||||
assert.Empty(t, r.RecentNotifications(10), "non-leader must not deliver duplicate system-contact notifications")
|
||||
}
|
||||
|
||||
func TestFormatSelfcheckLeaderMessage_IncludesOldAndNewLeader(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "worker-local-2", RegionCode: "local", Concurrency: 1})
|
||||
|
||||
out := r.formatSelfcheckLeaderMessage("worker-local-1", "http://localhost:7401/up", consensusDown, 0, 2, 2, 2, 2, 1,
|
||||
"worker-local-2", []string{"worker-local-2", "worker-local-3"})
|
||||
|
||||
assert.Contains(t, out, "Изменился мастер воркер оповещений: worker-local-1 -> worker-local-2")
|
||||
assert.Contains(t, out, "Notification leader: worker-local-2")
|
||||
assert.Contains(t, out, "Voters: worker-local-2,worker-local-3")
|
||||
}
|
||||
|
||||
// TestRunSelfcheckOnce_RecoveryRequiresHeldUpConsensus verifies that
|
||||
// after a peer-backed down alert, recovery only fires after the up
|
||||
// verdict has itself been held for selfcheckConsensusWait, and that
|
||||
// the recovery flow respects the multi-worker gate (an isolated up
|
||||
// tick with no fresh peer must NOT clear the alert).
|
||||
func TestRunSelfcheckOnce_RecoveryRequiresHeldUpConsensus(t *testing.T) {
|
||||
srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvOK.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
SystemContacts: []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com"},
|
||||
},
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402"},
|
||||
},
|
||||
})
|
||||
|
||||
// Pretend the peer also sees the master as up — the only
|
||||
// way the up verdict is allowed past the multi-worker gate.
|
||||
r.peerCache.put(peerObservation{
|
||||
WorkerID: "w-2",
|
||||
Up: true,
|
||||
ObservedAt: time.Now(),
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
state.consensus.alertActive = true
|
||||
state.consensus.downSince = ptrTime(time.Now().Add(-20 * time.Minute))
|
||||
r.config.URL = srvOK.URL
|
||||
|
||||
// First up tick: stamps the recovery timer; alert stays
|
||||
// active because the up verdict is not yet held for
|
||||
// selfcheckConsensusWait.
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.True(t, state.consensus.alertActive,
|
||||
"recovery alert must not fire on the first up tick")
|
||||
assert.Equal(t, consensusUp, state.consensus.lastVerdict)
|
||||
|
||||
// Simulate the 5-minute recovery wait having elapsed.
|
||||
state.consensus.downSince = ptrTime(time.Now().Add(-selfcheckConsensusWait - time.Second))
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.False(t, state.consensus.alertActive,
|
||||
"recovery alert must clear alertActive after selfcheckConsensusWait up consensus")
|
||||
assert.Nil(t, state.consensus.downSince)
|
||||
}
|
||||
|
||||
// TestRunSelfcheckOnce_RecoveryIsolatedUpDoesNotClearAlert verifies
|
||||
// that an isolated up tick (configured peers, no fresh peer
|
||||
// observation) cannot unilaterally clear an active alert. The
|
||||
// alert must remain active until a peer-backed up verdict is held.
|
||||
func TestRunSelfcheckOnce_RecoveryIsolatedUpDoesNotClearAlert(t *testing.T) {
|
||||
srvOK := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srvOK.Close()
|
||||
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
SystemContacts: []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com"},
|
||||
},
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402"},
|
||||
},
|
||||
})
|
||||
// Note: no fresh peer observation is seeded — the selfcheck
|
||||
// is the lone voter in this scenario.
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
state.consensus.alertActive = true
|
||||
state.consensus.downSince = ptrTime(time.Now().Add(-20 * time.Minute))
|
||||
r.config.URL = srvOK.URL
|
||||
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
|
||||
assert.True(t, state.consensus.alertActive,
|
||||
"isolated up tick must not clear an active alert when peers are configured")
|
||||
}
|
||||
|
||||
// TestRunSelfcheckOnce_NoQuorumDoesNotResetDownTimer verifies that a
|
||||
// NoQuorum verdict neither stamps nor advances the 5-minute down
|
||||
// timer. A brief blip in peer reachability must not page or reset
|
||||
// progress toward the consensus wait.
|
||||
func TestRunSelfcheckOnce_NoQuorumDoesNotResetDownTimer(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
Credentials: &wire.NotificationCredentials{
|
||||
SMTP: []wire.SMTPCredential{{ID: 1, Name: "primary", Server: "smtp", Port: 25, Login: "a", FromAddress: "a", Password: "x"}},
|
||||
},
|
||||
SystemContacts: []wire.SystemContact{
|
||||
{ID: 1, Kind: "email", Value: "ops@example.com"},
|
||||
},
|
||||
Peers: []wire.PeerInfo{
|
||||
{WorkerID: "w-2", URL: "http://127.0.0.1:27402"},
|
||||
},
|
||||
})
|
||||
// Peer observation is older than peerStatusMaxAge so the
|
||||
// selfcheck is alone in the vote.
|
||||
r.peerCache.put(peerObservation{
|
||||
WorkerID: "w-2",
|
||||
Up: false,
|
||||
ObservedAt: time.Now().Add(-10 * time.Minute),
|
||||
})
|
||||
|
||||
state := &selfcheckState{consensus: &consensusState{}}
|
||||
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
assert.Nil(t, state.consensus.downSince,
|
||||
"first NoQuorum must not stamp the down timer")
|
||||
assert.False(t, state.consensus.alertActive)
|
||||
|
||||
// A subsequent NoQuorum must also not advance the timer;
|
||||
// the existing stamp (from a previous verdict) is held.
|
||||
previous := ptrTime(time.Now().Add(-3 * time.Minute))
|
||||
state.consensus.downSince = previous
|
||||
r.runSelfcheckOnce(t.Context(), state)
|
||||
require.NotNil(t, state.consensus.downSince)
|
||||
assert.Equal(t, *previous, *state.consensus.downSince,
|
||||
"NoQuorum must not advance the down timer")
|
||||
}
|
||||
|
||||
func TestFormatSelfcheckMessage_IncludesWorkerAndConsensus(t *testing.T) {
|
||||
r := newTestRunner(t, 4, 1, func(interface{}) interface{} { return []wire.CheckResultReport{} })
|
||||
r.applyInit(&wire.WorkerInit{WorkerID: "worker-local-1", RegionCode: "local", Concurrency: 1})
|
||||
|
||||
out := r.formatSelfcheckMessage(selfcheckDownMessage, "http://localhost:7401/up", consensusDown, 0, 3, 3, 2, 2, 2,
|
||||
"worker-local-1", []string{"worker-local-1", "worker-local-2", "worker-local-3"})
|
||||
|
||||
assert.Contains(t, out, "Нет связи с основным api")
|
||||
assert.Contains(t, out, "Worker: worker-local-1")
|
||||
assert.Contains(t, out, "Region: local")
|
||||
assert.Contains(t, out, "Target: http://localhost:7401/up")
|
||||
assert.Contains(t, out, "Raft status: lightweight peer quorum")
|
||||
assert.Contains(t, out, "Notification leader: worker-local-1")
|
||||
assert.Contains(t, out, "Consensus: down")
|
||||
assert.Contains(t, out, "Votes: up=0 down=3 total=3 min_votes=2")
|
||||
assert.Contains(t, out, "Workers seen: 3 current votes, 2 fresh peers of 2 configured peers")
|
||||
assert.Contains(t, out, "Voters: worker-local-1,worker-local-2,worker-local-3")
|
||||
assert.Contains(t, out, "Hold time: 5m0s")
|
||||
}
|
||||
|
||||
func TestConsensusNotificationLeader(t *testing.T) {
|
||||
voters := consensusVoters("worker-local-2", []peerObservation{{WorkerID: "worker-local-3"}, {WorkerID: "worker-local-1"}})
|
||||
assert.Equal(t, []string{"worker-local-1", "worker-local-2", "worker-local-3"}, voters)
|
||||
assert.Equal(t, "worker-local-1", consensusNotificationLeader(voters))
|
||||
}
|
||||
|
||||
func TestConsensusState_NotificationLeaderChanged(t *testing.T) {
|
||||
state := &consensusState{}
|
||||
old, changed := state.notificationLeaderChanged("worker-local-1")
|
||||
assert.False(t, changed)
|
||||
assert.Empty(t, old)
|
||||
|
||||
old, changed = state.notificationLeaderChanged("worker-local-1")
|
||||
assert.False(t, changed)
|
||||
assert.Empty(t, old)
|
||||
|
||||
old, changed = state.notificationLeaderChanged("worker-local-2")
|
||||
assert.True(t, changed)
|
||||
assert.Equal(t, "worker-local-1", old)
|
||||
}
|
||||
|
||||
func ptrTime(t time.Time) *time.Time { return &t }
|
||||
271
internal/distworker/server_metrics.go
Обычный файл
271
internal/distworker/server_metrics.go
Обычный файл
@@ -0,0 +1,271 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
type serverMetricSample struct {
|
||||
at time.Time
|
||||
cpuTotal, cpuIdle uint64
|
||||
rx, tx int64
|
||||
}
|
||||
|
||||
var serverMetricSamples = struct {
|
||||
sync.Mutex
|
||||
byServer map[int64]serverMetricSample
|
||||
}{byServer: make(map[int64]serverMetricSample)}
|
||||
|
||||
const serverMetricInterval = 5 * time.Second
|
||||
|
||||
const (
|
||||
maxServerMetricProcesses = 20
|
||||
maxServerMetricNetworks = 16
|
||||
)
|
||||
|
||||
// serverMetricLoop collects only on a worker assigned to a Server. It uses
|
||||
// the real Linux /proc and statfs collector; unsupported platforms return no
|
||||
// report rather than fabricated values. The worker has no control-plane DB
|
||||
// access and forwards snapshots on its authenticated websocket.
|
||||
func (r *Runner) serverMetricLoop() {
|
||||
ticker := time.NewTicker(serverMetricInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
serverID := r.serverID.Load()
|
||||
if serverID == 0 || r.metricResults == nil {
|
||||
continue
|
||||
}
|
||||
report, ok := collectServerMetric(serverID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case r.metricResults <- report:
|
||||
case <-r.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectServerMetric(serverID int64) (wire.ServerMetricReport, bool) {
|
||||
memTotal, memAvailable, load1, load5, load15, uptime, ok := readLinuxHostMetrics("/proc")
|
||||
if !ok {
|
||||
return wire.ServerMetricReport{}, false
|
||||
}
|
||||
memUsed := memTotal - memAvailable
|
||||
var rxTotal, txTotal int64
|
||||
networks := make([]wire.NetworkMetric, 0)
|
||||
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 10 {
|
||||
name := strings.TrimSuffix(fields[0], ":")
|
||||
if name == "lo" {
|
||||
continue
|
||||
}
|
||||
rx, tx := parseI64(fields[1]), parseI64(fields[9])
|
||||
rxTotal += rx
|
||||
txTotal += tx
|
||||
networks = append(networks, wire.NetworkMetric{Interface: name, RxBytes: rx, TxBytes: tx})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(networks, func(i, j int) bool {
|
||||
return networks[i].RxBytes+networks[i].TxBytes > networks[j].RxBytes+networks[j].TxBytes
|
||||
})
|
||||
if len(networks) > maxServerMetricNetworks {
|
||||
networks = networks[:maxServerMetricNetworks]
|
||||
}
|
||||
processCount := countProcesses("/proc")
|
||||
processes := collectProcesses("/proc")
|
||||
now := time.Now()
|
||||
cpuPercent, netRx, netTx := sampledRates(serverID, now, rxTotal, txTotal)
|
||||
diskUsed, diskTotal, diskOK := rootDiskUsage()
|
||||
var diskUsedPtr, diskTotalPtr *int64
|
||||
if diskOK {
|
||||
diskUsedPtr, diskTotalPtr = &diskUsed, &diskTotal
|
||||
}
|
||||
return wire.ServerMetricReport{
|
||||
ServerID: serverID, CPUPercent: cpuPercent, MemUsed: &memUsed, MemTotal: &memTotal,
|
||||
DiskUsed: diskUsedPtr, DiskTotal: diskTotalPtr, NetRx: netRx, NetTx: netTx, HostUptimeSec: &uptime,
|
||||
Load1: &load1, Load5: &load5, Load15: &load15, ProcessCount: &processCount,
|
||||
Processes: processes, Networks: networks,
|
||||
}, true
|
||||
}
|
||||
|
||||
// sampledRates turns monotonic /proc counters into percent and bytes/second.
|
||||
// A first sample, a clock anomaly, or a counter reset intentionally has no rate.
|
||||
func sampledRates(serverID int64, now time.Time, rx, tx int64) (*float64, *int64, *int64) {
|
||||
total, idle, ok := readCPUCounters("/proc/stat")
|
||||
if !ok {
|
||||
return nil, nil, nil
|
||||
}
|
||||
serverMetricSamples.Lock()
|
||||
defer serverMetricSamples.Unlock()
|
||||
previous, hasPrevious := serverMetricSamples.byServer[serverID]
|
||||
serverMetricSamples.byServer[serverID] = serverMetricSample{at: now, cpuTotal: total, cpuIdle: idle, rx: rx, tx: tx}
|
||||
if !hasPrevious || !now.After(previous.at) || total <= previous.cpuTotal || idle < previous.cpuIdle || rx < previous.rx || tx < previous.tx {
|
||||
return nil, nil, nil
|
||||
}
|
||||
cpu := float64((total-previous.cpuTotal)-(idle-previous.cpuIdle)) * 100 / float64(total-previous.cpuTotal)
|
||||
seconds := now.Sub(previous.at).Seconds()
|
||||
if seconds <= 0 {
|
||||
return &cpu, nil, nil
|
||||
}
|
||||
rxRate, txRate := int64(float64(rx-previous.rx)/seconds), int64(float64(tx-previous.tx)/seconds)
|
||||
return &cpu, &rxRate, &txRate
|
||||
}
|
||||
|
||||
func readCPUCounters(path string) (uint64, uint64, bool) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
return 0, 0, false
|
||||
}
|
||||
var total uint64
|
||||
for _, field := range fields[1:] {
|
||||
value, err := strconv.ParseUint(field, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
total += value
|
||||
}
|
||||
idle, _ := strconv.ParseUint(fields[4], 10, 64)
|
||||
if len(fields) > 5 {
|
||||
iowait, _ := strconv.ParseUint(fields[5], 10, 64)
|
||||
idle += iowait
|
||||
}
|
||||
return total, idle, total > 0
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func rootDiskUsage() (int64, int64, bool) {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &stat); err != nil || stat.Blocks == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
total := int64(stat.Blocks * uint64(stat.Bsize))
|
||||
free := int64(stat.Bavail * uint64(stat.Bsize))
|
||||
return total - free, total, true
|
||||
}
|
||||
|
||||
func collectProcesses(procRoot string) []wire.ProcessMetric {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
processes := make([]wire.ProcessMetric, 0, maxServerMetricProcesses)
|
||||
for _, entry := range entries {
|
||||
pid, err := strconv.Atoi(entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
status, err := os.ReadFile(filepath.Join(procRoot, entry.Name(), "status"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name, rss := "", int64(0)
|
||||
for _, line := range strings.Split(string(status), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "Name:":
|
||||
name = fields[1]
|
||||
case "VmRSS:":
|
||||
rss = parseI64(fields[1]) * 1024
|
||||
}
|
||||
}
|
||||
if name != "" {
|
||||
processes = append(processes, wire.ProcessMetric{PID: pid, Name: name, MemoryRSS: rss})
|
||||
}
|
||||
}
|
||||
sort.Slice(processes, func(i, j int) bool { return processes[i].MemoryRSS > processes[j].MemoryRSS })
|
||||
if len(processes) > maxServerMetricProcesses {
|
||||
processes = processes[:maxServerMetricProcesses]
|
||||
}
|
||||
return processes
|
||||
}
|
||||
|
||||
func countProcesses(procRoot string) int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, entry := range entries {
|
||||
if _, err := strconv.Atoi(entry.Name()); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func readLinuxHostMetrics(root string) (total, available int64, load1, load5, load15 float64, uptime int64, ok bool) {
|
||||
mem, err := os.ReadFile(filepath.Join(root, "meminfo"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, line := range strings.Split(string(mem), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
total = parseI64(fields[1]) * 1024
|
||||
case "MemAvailable:":
|
||||
available = parseI64(fields[1]) * 1024
|
||||
}
|
||||
}
|
||||
load, err := os.ReadFile(filepath.Join(root, "loadavg"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(string(load))
|
||||
if len(fields) < 3 {
|
||||
return
|
||||
}
|
||||
load1, _ = strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ = strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ = strconv.ParseFloat(fields[2], 64)
|
||||
up, err := os.ReadFile(filepath.Join(root, "uptime"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fields = strings.Fields(string(up))
|
||||
if len(fields) == 0 {
|
||||
return
|
||||
}
|
||||
seconds, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
uptime = int64(seconds)
|
||||
return total, available, load1, load5, load15, uptime, total > 0
|
||||
}
|
||||
|
||||
func parseI64(value string) int64 { out, _ := strconv.ParseInt(value, 10, 64); return out }
|
||||
55
internal/distworker/server_metrics_test.go
Обычный файл
55
internal/distworker/server_metrics_test.go
Обычный файл
@@ -0,0 +1,55 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadLinuxHostMetrics(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "meminfo"), []byte("MemTotal: 100 kB\nMemAvailable: 25 kB\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "loadavg"), []byte("1.5 2.5 3.5 1/1 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "uptime"), []byte("42.9 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total, available, one, five, fifteen, uptime, ok := readLinuxHostMetrics(root)
|
||||
if !ok || total != 102400 || available != 25600 || one != 1.5 || five != 2.5 || fifteen != 3.5 || uptime != 42 {
|
||||
t.Fatalf("unexpected host metric parse: %d %d %g %g %g %d %t", total, available, one, five, fifteen, uptime, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectProcessesLimitsAndSortsByRSS(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for i := 1; i <= maxServerMetricProcesses+2; i++ {
|
||||
dir := filepath.Join(root, strconv.Itoa(i))
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := fmt.Sprintf("Name:\tproc%d\nVmRSS:\t%d kB\n", i, i)
|
||||
if err := os.WriteFile(filepath.Join(dir, "status"), []byte(status), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
processes := collectProcesses(root)
|
||||
if len(processes) != maxServerMetricProcesses || processes[0].PID != maxServerMetricProcesses+2 {
|
||||
t.Fatalf("unexpected bounded process snapshot: %#v", processes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCPUCounters(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stat")
|
||||
if err := os.WriteFile(path, []byte("cpu 10 2 8 70 10 0 0 0\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
total, idle, ok := readCPUCounters(path)
|
||||
if !ok || total != 100 || idle != 80 {
|
||||
t.Fatalf("unexpected cpu counters: %d %d %t", total, idle, ok)
|
||||
}
|
||||
}
|
||||
27
internal/distworker/types.go
Обычный файл
27
internal/distworker/types.go
Обычный файл
@@ -0,0 +1,27 @@
|
||||
// Package distworker provides the distributed worker implementation.
|
||||
// This file aliases wire format types for convenience.
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// notifyResultEnvelope pairs a notification task with its report so the
|
||||
// writer goroutine can log context alongside the result.
|
||||
type notifyResultEnvelope struct {
|
||||
task wire.NotificationTask
|
||||
report wire.NotificationResultReport
|
||||
}
|
||||
|
||||
// RegisterRequest and related types are wire type aliases for convenience.
|
||||
type (
|
||||
RegisterRequest = wire.RegisterRequest
|
||||
RegisterResponse = wire.RegisterResponse
|
||||
HeartbeatRequest = wire.HeartbeatRequest
|
||||
CheckJob = wire.CheckJob
|
||||
JobsResponse = wire.JobsResponse
|
||||
CheckResultReport = wire.CheckResultReport
|
||||
ResultsRequest = wire.ResultsRequest
|
||||
NotificationTask = wire.NotificationTask
|
||||
NotificationResultReport = wire.NotificationResultReport
|
||||
)
|
||||
397
internal/influx/influx.go
Обычный файл
397
internal/influx/influx.go
Обычный файл
@@ -0,0 +1,397 @@
|
||||
// Package influx provides functionality.
|
||||
package influx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAddr = "http://localhost:8428"
|
||||
// VictoriaMetrics uses MetricsQL, not Flux
|
||||
// Data model: metric names are {measurement}_{field}
|
||||
)
|
||||
|
||||
var (
|
||||
initOnce sync.Once
|
||||
client *http.Client
|
||||
// addr defaults to defaultAddr at declaration so callers (and
|
||||
// tests) can override it before the first lazy init runs.
|
||||
addr = defaultAddr
|
||||
)
|
||||
|
||||
// ensureInit performs one-time setup of the HTTP client and TSDB
|
||||
// address. It is called from every public function so that binaries
|
||||
// which only import this package transitively (e.g. the distributed
|
||||
// worker, which never reads or writes TSDB points) do not pay the
|
||||
// init cost or emit a misleading "TSDB client initialized" log line.
|
||||
// INFLUX_URL is read here so the address tracks the env var across
|
||||
// process restarts without requiring explicit init from the caller.
|
||||
//
|
||||
// The env var is honored only when `addr` still equals the default.
|
||||
// This lets tests (and any explicit caller) pre-set `addr` to a
|
||||
// mock URL before the first public call fires; without this guard,
|
||||
// CI runs where INFLUX_URL is exported in .env.ci.example would
|
||||
// overwrite a test's mock server URL the moment initOnce fires,
|
||||
// causing every QueryVM/* test to silently target a real TSDB.
|
||||
func ensureInit() {
|
||||
initOnce.Do(func() {
|
||||
client = &http.Client{Timeout: 30 * time.Second}
|
||||
if addr == defaultAddr {
|
||||
if addrEnv := os.Getenv("INFLUX_URL"); addrEnv != "" {
|
||||
addr = addrEnv
|
||||
}
|
||||
}
|
||||
log.Println("TSDB client initialized for:", addr)
|
||||
})
|
||||
}
|
||||
|
||||
// VMExportResponse represents VictoriaMetrics export response
|
||||
type VMExportResponse struct {
|
||||
Metric map[string]string `json:"metric"`
|
||||
Values []json.Number `json:"values"`
|
||||
Timestamps []int64 `json:"timestamps"`
|
||||
}
|
||||
|
||||
// QueryVM performs a MetricsQL query against VictoriaMetrics
|
||||
// QueryVM performs an export query against VictoriaMetrics.
|
||||
// selector is a time series selector like `chttp_took{check="1146"}`.
|
||||
// start is the RFC3339 or Unix timestamp for the beginning of the time range (can be empty).
|
||||
func QueryVM(selector, start string) ([]VMExportResponse, error) {
|
||||
return QueryVMMany([]string{selector}, start)
|
||||
}
|
||||
|
||||
// QueryVMMany exports several selectors in one request.
|
||||
func QueryVMMany(selectors []string, start string) ([]VMExportResponse, error) {
|
||||
ensureInit()
|
||||
u, err := url.Parse(addr + "/api/v1/export")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", u.String(), http.NoBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
for _, selector := range selectors {
|
||||
q.Add("match[]", selector)
|
||||
}
|
||||
if start != "" {
|
||||
q.Add("start", start)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("query failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var results []VMExportResponse
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
for {
|
||||
var result VMExportResponse
|
||||
if err := decoder.Decode(&result); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// MetricCheck identifies the duration series for a check.
|
||||
type MetricCheck struct {
|
||||
Metric string
|
||||
CheckID int64
|
||||
}
|
||||
|
||||
// GetLastMany fetches a page's checks in one bounded VictoriaMetrics export.
|
||||
func GetLastMany(checks []MetricCheck, hours int) (map[int64][]InfluxData, error) {
|
||||
out := make(map[int64][]InfluxData, len(checks))
|
||||
if len(checks) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if len(checks) > 500 {
|
||||
checks = checks[:500]
|
||||
}
|
||||
selectors := make([]string, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
selectors = append(selectors, fmt.Sprintf(`%s_took{check="%d"}`, check.Metric, check.CheckID))
|
||||
}
|
||||
results, err := QueryVMMany(selectors, fmt.Sprintf("%d", time.Now().Add(-time.Duration(hours)*time.Hour).Unix()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, result := range results {
|
||||
checkID, err := strconv.ParseInt(result.Metric["check"], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
state := result.Metric["state"]
|
||||
if state == "" {
|
||||
state = "UNK"
|
||||
}
|
||||
for i, ts := range result.Timestamps {
|
||||
var duration int64
|
||||
if i < len(result.Values) {
|
||||
duration, _ = result.Values[i].Int64()
|
||||
}
|
||||
out[checkID] = append(out[checkID], InfluxData{Time: time.Unix(ts/1000, (ts%1000)*1e6), Duration: duration, State: state})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InfluxData provides functionality. //nolint:revive // stutter intentional for clarity
|
||||
type InfluxData struct {
|
||||
Time time.Time `json:"time"`
|
||||
Duration int64 `json:"duration"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warnings string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// GetLast provides functionality.
|
||||
func GetLast(metric string, check int64, hours int) ([]InfluxData, error) {
|
||||
ensureInit()
|
||||
// VictoriaMetrics export API: match[] selector + start time
|
||||
selector := fmt.Sprintf(`%s_took{check="%d"}`, metric, check)
|
||||
start := fmt.Sprintf("%d", time.Now().Add(-time.Duration(hours)*time.Hour).Unix())
|
||||
|
||||
log.Println("TSDB query:", selector, "start:", start)
|
||||
results, err := QueryVM(selector, start)
|
||||
if err != nil {
|
||||
spew.Dump(err)
|
||||
log.Println("TSDB query error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("Got %d result series from TSDB", len(results))
|
||||
|
||||
influxData := make([]InfluxData, 0)
|
||||
|
||||
// Process each time series (VictoriaMetrics returns one series per unique tag combination)
|
||||
for _, result := range results {
|
||||
state := result.Metric["state"]
|
||||
errorMsg := result.Metric["error"]
|
||||
warnings := result.Metric["warnings"]
|
||||
|
||||
for i, ts := range result.Timestamps {
|
||||
// Convert milliseconds to time.Time
|
||||
t := time.Unix(ts/1000, (ts%1000)*1e6)
|
||||
|
||||
// Parse the value
|
||||
var duration int64
|
||||
if i < len(result.Values) {
|
||||
if f, err := result.Values[i].Int64(); err == nil {
|
||||
duration = f
|
||||
}
|
||||
}
|
||||
|
||||
data := InfluxData{
|
||||
Time: t,
|
||||
Duration: duration,
|
||||
State: state,
|
||||
Error: errorMsg,
|
||||
Warnings: warnings,
|
||||
}
|
||||
if data.State == "" {
|
||||
data.State = "UNK"
|
||||
}
|
||||
influxData = append(influxData, data)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by time descending (newest first)
|
||||
for i := 0; i < len(influxData); i++ {
|
||||
for j := i + 1; j < len(influxData); j++ {
|
||||
if influxData[i].Time.Before(influxData[j].Time) {
|
||||
influxData[i], influxData[j] = influxData[j], influxData[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return influxData, nil
|
||||
}
|
||||
|
||||
// escapeTagValue escapes special characters in influx line protocol tag keys/values.
|
||||
// Characters that must be escaped: comma, equals, space.
|
||||
func escapeTagValue(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, " ", `\ `)
|
||||
s = strings.ReplaceAll(s, ",", `\,`)
|
||||
s = strings.ReplaceAll(s, "=", `\=`)
|
||||
return s
|
||||
}
|
||||
|
||||
// formatInfluxLine formats data as InfluxDB line protocol
|
||||
func formatInfluxLine(measurement string, tags map[string]string, fields map[string]interface{}, ts time.Time) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Write measurement
|
||||
buf.WriteString(measurement)
|
||||
|
||||
// Write tags
|
||||
tagKeys := make([]string, 0, len(tags))
|
||||
for k := range tags {
|
||||
tagKeys = append(tagKeys, k)
|
||||
}
|
||||
// Sort tags for consistency
|
||||
for i := 0; i < len(tagKeys); i++ {
|
||||
for j := i + 1; j < len(tagKeys); j++ {
|
||||
if tagKeys[i] > tagKeys[j] {
|
||||
tagKeys[i], tagKeys[j] = tagKeys[j], tagKeys[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range tagKeys {
|
||||
buf.WriteByte(',')
|
||||
buf.WriteString(escapeTagValue(k))
|
||||
buf.WriteByte('=')
|
||||
buf.WriteString(escapeTagValue(tags[k]))
|
||||
}
|
||||
|
||||
buf.WriteByte(' ')
|
||||
|
||||
// Write fields
|
||||
fieldKeys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
fieldKeys = append(fieldKeys, k)
|
||||
}
|
||||
firstField := true
|
||||
for _, k := range fieldKeys {
|
||||
if !firstField {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
firstField = false
|
||||
buf.WriteString(k)
|
||||
buf.WriteByte('=')
|
||||
|
||||
switch v := fields[k].(type) {
|
||||
case int64:
|
||||
buf.WriteString(strconv.FormatInt(v, 10) + "i")
|
||||
case int:
|
||||
buf.WriteString(strconv.FormatInt(int64(v), 10) + "i")
|
||||
case float64:
|
||||
buf.WriteString(strconv.FormatFloat(v, 'f', -1, 64))
|
||||
case bool:
|
||||
if v {
|
||||
buf.WriteString("true")
|
||||
} else {
|
||||
buf.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
buf.WriteByte('"')
|
||||
buf.WriteString(strings.ReplaceAll(v, "\"", "\\\""))
|
||||
buf.WriteByte('"')
|
||||
default:
|
||||
buf.WriteString(strconv.FormatFloat(0, 'f', -1, 64))
|
||||
}
|
||||
}
|
||||
|
||||
// Write timestamp (nanoseconds)
|
||||
buf.WriteByte(' ')
|
||||
buf.WriteString(strconv.FormatInt(ts.UnixNano(), 10))
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// WriteOne provides functionality.
|
||||
func WriteOne(metric string, tags map[string]string, fields map[string]interface{}) error {
|
||||
ensureInit()
|
||||
// Format as InfluxDB line protocol
|
||||
line := formatInfluxLine(metric, tags, fields, time.Now())
|
||||
|
||||
// Write to VictoriaMetrics /api/v2/write endpoint
|
||||
u, err := url.Parse(addr + "/api/v2/write")
|
||||
if err != nil {
|
||||
log.Println("TSDB write error (URL parse):", err)
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u.String(), bytes.NewBufferString(line))
|
||||
if err != nil {
|
||||
log.Println("TSDB write error (request):", err)
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Println("TSDB write error:", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Printf("TSDB write failed with status %d: %s\n", resp.StatusCode, string(body))
|
||||
return fmt.Errorf("write failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck performs a simple health check against VictoriaMetrics
|
||||
func HealthCheck() error {
|
||||
ensureInit()
|
||||
u, err := url.Parse(addr + "/health")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", u.String(), http.NoBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("health check failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryDB is deprecated and kept for compatibility
|
||||
// Use QueryVM for MetricsQL queries instead
|
||||
func QueryDB(query string) ([]VMExportResponse, error) {
|
||||
// This is a compatibility function for old code
|
||||
// Note: Flux queries are NOT supported by VictoriaMetrics
|
||||
// This function tries to do a simple query instead
|
||||
log.Println("Warning: QueryDB called with Flux query, VictoriaMetrics uses MetricsQL")
|
||||
log.Println("Query:", query)
|
||||
|
||||
// Try a simple health check instead
|
||||
return nil, HealthCheck()
|
||||
}
|
||||
358
internal/influx/influx_test.go
Обычный файл
358
internal/influx/influx_test.go
Обычный файл
@@ -0,0 +1,358 @@
|
||||
package influx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEscapeTagValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"OK", "OK"},
|
||||
{"simple", "simple"},
|
||||
{"has space", `has\ space`},
|
||||
{"has,comma", `has\,comma`},
|
||||
{"has=equals", `has\=equals`},
|
||||
{`has\backslash`, `has\\backslash`},
|
||||
// Real-world error messages that were causing TSDB write failures
|
||||
{`response check: expected keyword Каталония not found`, `response\ check:\ expected\ keyword\ Каталония\ not\ found`},
|
||||
{`request exec: Get "https://example.ru": dial tcp: lookup example.ru: no such host`, `request\ exec:\ Get\ "https://example.ru":\ dial\ tcp:\ lookup\ example.ru:\ no\ such\ host`},
|
||||
{`redirect: https://example.ru/path`, `redirect:\ https://example.ru/path`},
|
||||
{`Bad status code: 200 (expected 403)`, `Bad\ status\ code:\ 200\ (expected\ 403)`},
|
||||
{"", ""},
|
||||
// Multiple special chars together
|
||||
{`a=b,c d\e`, `a\=b\,c\ d\\e`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := escapeTagValue(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("escapeTagValue(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatInfluxLine(t *testing.T) {
|
||||
ts := time.Unix(0, 1771961128540561786)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
measurement string
|
||||
tags map[string]string
|
||||
fields map[string]interface{}
|
||||
wantPrefix string // Check the line starts with this (before timestamp)
|
||||
}{
|
||||
{
|
||||
name: "simple OK check",
|
||||
measurement: "chttp",
|
||||
tags: map[string]string{"check": "457", "code": "200", "state": "OK", "warnings": ""},
|
||||
fields: map[string]interface{}{"took": int64(294)},
|
||||
wantPrefix: "chttp,check=457,code=200,state=OK,warnings= took=294i",
|
||||
},
|
||||
{
|
||||
name: "check with error containing spaces and colons",
|
||||
measurement: "chttp",
|
||||
tags: map[string]string{"check": "791", "code": "200", "error": "response check: expected keyword not found", "state": "ERR", "warnings": ""},
|
||||
fields: map[string]interface{}{"took": int64(294)},
|
||||
wantPrefix: `chttp,check=791,code=200,error=response\ check:\ expected\ keyword\ not\ found,state=ERR,warnings= took=294i`,
|
||||
},
|
||||
{
|
||||
name: "check with redirect URL in warnings",
|
||||
measurement: "chttp",
|
||||
tags: map[string]string{"check": "672", "code": "301", "state": "WARN", "warnings": "redirect: https://example.ru/"},
|
||||
fields: map[string]interface{}{"took": int64(276)},
|
||||
wantPrefix: `chttp,check=672,code=301,state=WARN,warnings=redirect:\ https://example.ru/ took=276i`,
|
||||
},
|
||||
{
|
||||
name: "check with equals in error",
|
||||
measurement: "chttp",
|
||||
tags: map[string]string{"check": "100", "state": "ERR", "error": "key=value problem"},
|
||||
fields: map[string]interface{}{"took": int64(0)},
|
||||
wantPrefix: `chttp,check=100,error=key\=value\ problem,state=ERR took=0i`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := formatInfluxLine(tt.measurement, tt.tags, tt.fields, ts)
|
||||
// The line should end with the timestamp
|
||||
wantSuffix := " 1771961128540561786"
|
||||
want := tt.wantPrefix + wantSuffix
|
||||
if got != want {
|
||||
t.Errorf("formatInfluxLine() =\n %q\nwant:\n %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryVM_UsesMatchParam(t *testing.T) {
|
||||
var receivedQuery string
|
||||
var receivedStart string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedQuery = r.URL.Query().Get("match[]")
|
||||
receivedStart = r.URL.Query().Get("start")
|
||||
|
||||
// Verify it's NOT using the old "query" param
|
||||
if q := r.URL.Query().Get("query"); q != "" {
|
||||
t.Errorf("QueryVM sent deprecated 'query' param: %s", q)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Return empty JSON-lines response
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Override addr for test
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
selector := `chttp_took{check="123"}`
|
||||
_, err := QueryVM(selector, "1771960000")
|
||||
if err != nil {
|
||||
t.Fatalf("QueryVM returned error: %v", err)
|
||||
}
|
||||
|
||||
if receivedQuery != selector {
|
||||
t.Errorf("match[] = %q, want %q", receivedQuery, selector)
|
||||
}
|
||||
if receivedStart != "1771960000" {
|
||||
t.Errorf("start = %q, want %q", receivedStart, "1771960000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryVM_ParsesResponse(t *testing.T) {
|
||||
response := `{"metric":{"__name__":"chttp_took","check":"123","state":"OK"},"values":[294,305],"timestamps":[1771961128000,1771961188000]}
|
||||
{"metric":{"__name__":"chttp_took","check":"123","state":"ERR","error":"timeout"},"values":[0],"timestamps":[1771961248000]}`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
results, err := QueryVM(`chttp_took{check="123"}`, "")
|
||||
if err != nil {
|
||||
t.Fatalf("QueryVM returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Metric["state"] != "OK" {
|
||||
t.Errorf("first result state = %q, want OK", results[0].Metric["state"])
|
||||
}
|
||||
if len(results[0].Values) != 2 {
|
||||
t.Errorf("first result values count = %d, want 2", len(results[0].Values))
|
||||
}
|
||||
if results[1].Metric["error"] != "timeout" {
|
||||
t.Errorf("second result error = %q, want timeout", results[1].Metric["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLast(t *testing.T) {
|
||||
response := `{"metric":{"__name__":"chttp_took","check":"123","state":"OK","error":"","warnings":""},"values":[294,305],"timestamps":[1771961128000,1771961188000]}
|
||||
{"metric":{"__name__":"chttp_took","check":"123","state":"ERR","error":"timeout","warnings":""},"values":[0],"timestamps":[1771961248000]}`
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify correct params
|
||||
match := r.URL.Query().Get("match[]")
|
||||
if match != `chttp_took{check="123"}` {
|
||||
t.Errorf("match[] = %q, want chttp_took{check=\"123\"}", match)
|
||||
}
|
||||
start := r.URL.Query().Get("start")
|
||||
if start == "" {
|
||||
t.Error("expected start parameter")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
data, err := GetLast("chttp", 123, 6)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLast returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(data) != 3 {
|
||||
t.Fatalf("expected 3 data points, got %d", len(data))
|
||||
}
|
||||
|
||||
// Should be sorted newest first
|
||||
if data[0].State != "ERR" {
|
||||
t.Errorf("first (newest) data point state = %q, want ERR", data[0].State)
|
||||
}
|
||||
if data[0].Duration != 0 {
|
||||
t.Errorf("first data point duration = %d, want 0", data[0].Duration)
|
||||
}
|
||||
|
||||
if data[2].State != "OK" {
|
||||
t.Errorf("last (oldest) data point state = %q, want OK", data[2].State)
|
||||
}
|
||||
if data[2].Duration != 294 {
|
||||
t.Errorf("last data point duration = %d, want 294", data[2].Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOne_EscapesSpecialChars(t *testing.T) {
|
||||
var receivedBody string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
tags := map[string]string{
|
||||
"check": "791",
|
||||
"code": "200",
|
||||
"error": "response check: expected keyword not found",
|
||||
"state": "ERR",
|
||||
"warnings": "",
|
||||
}
|
||||
fields := map[string]interface{}{
|
||||
"took": int64(294),
|
||||
}
|
||||
|
||||
err := WriteOne("chttp", tags, fields)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteOne returned error: %v", err)
|
||||
}
|
||||
|
||||
// The body should have properly escaped tag values
|
||||
if strings.Contains(receivedBody, "error=response check:") {
|
||||
t.Error("error tag value was not escaped - spaces should be escaped")
|
||||
}
|
||||
if !strings.Contains(receivedBody, `error=response\ check:\ expected\ keyword\ not\ found`) {
|
||||
t.Errorf("expected escaped error tag, got: %s", receivedBody)
|
||||
}
|
||||
|
||||
// Should contain the field
|
||||
if !strings.Contains(receivedBody, "took=294i") {
|
||||
t.Errorf("body doesn't contain took=294i: %s", receivedBody)
|
||||
}
|
||||
|
||||
// Should start with measurement name
|
||||
if !strings.HasPrefix(receivedBody, "chttp,") {
|
||||
t.Errorf("body doesn't start with chttp,: %s", receivedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOne_SendsToCorrectEndpoint(t *testing.T) {
|
||||
var receivedPath string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
err := WriteOne("chttp", map[string]string{"check": "1"}, map[string]interface{}{"took": int64(100)})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteOne returned error: %v", err)
|
||||
}
|
||||
|
||||
if receivedPath != "/api/v2/write" {
|
||||
t.Errorf("WriteOne sent to %q, want /api/v2/write", receivedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryVM_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
io.WriteString(w, "missing `match[]` arg")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
_, err := QueryVM("bad_query", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "400") {
|
||||
t.Errorf("error should contain status code 400: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLast_RejectsHyphenatedMetric(t *testing.T) {
|
||||
var receivedMatch string
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedMatch = r.URL.Query().Get("match[]")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
oldAddr := addr
|
||||
addr = server.URL
|
||||
defer func() { addr = oldAddr }()
|
||||
|
||||
_, err := GetLast("cllm_http", 123, 6)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLast returned error: %v", err)
|
||||
}
|
||||
|
||||
if receivedMatch != `cllm_http_took{check="123"}` {
|
||||
t.Errorf("match[] = %q, want cllm_http_took{check=\"123\"}", receivedMatch)
|
||||
}
|
||||
|
||||
if strings.Contains(receivedMatch, "-") {
|
||||
t.Errorf("match[] should not contain hyphen: %q", receivedMatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMExportResponseParsing(t *testing.T) {
|
||||
jsonStr := `{"metric":{"__name__":"chttp_took","check":"456","state":"WARN","warnings":"redirect: https://example.com/"},"values":[276],"timestamps":[1771961130522365547]}`
|
||||
|
||||
var resp VMExportResponse
|
||||
err := json.Unmarshal([]byte(jsonStr), &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
|
||||
if resp.Metric["check"] != "456" {
|
||||
t.Errorf("check = %q, want 456", resp.Metric["check"])
|
||||
}
|
||||
if resp.Metric["warnings"] != "redirect: https://example.com/" {
|
||||
t.Errorf("warnings = %q", resp.Metric["warnings"])
|
||||
}
|
||||
if len(resp.Values) != 1 {
|
||||
t.Fatalf("expected 1 value, got %d", len(resp.Values))
|
||||
}
|
||||
v, _ := resp.Values[0].Int64()
|
||||
if v != 276 {
|
||||
t.Errorf("value = %d, want 276", v)
|
||||
}
|
||||
}
|
||||
46
internal/netaddr/cidr.go
Обычный файл
46
internal/netaddr/cidr.go
Обычный файл
@@ -0,0 +1,46 @@
|
||||
// Package netaddr provides network address utilities for RSMon.
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Cidr is a wrapper for transferring CIDR values back and forth easily.
|
||||
type Cidr struct {
|
||||
Cidr net.IPNet
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (c *Cidr) Scan(value interface{}) error {
|
||||
c.Cidr.IP = nil
|
||||
c.Cidr.Mask = nil
|
||||
c.Valid = false
|
||||
if value == nil {
|
||||
c.Valid = false
|
||||
return nil
|
||||
}
|
||||
cidrAsBytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("could not convert scanned value to bytes")
|
||||
}
|
||||
_, parsedIPNet, parseErr := net.ParseCIDR(string(cidrAsBytes))
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
c.Valid = true
|
||||
c.Cidr.IP = parsedIPNet.IP
|
||||
c.Cidr.Mask = parsedIPNet.Mask
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface. Note if c.Valid is false
|
||||
// or c.Cidr.IP is nil the database column value will be set to NULL.
|
||||
func (c Cidr) Value() (driver.Value, error) {
|
||||
if !c.Valid || c.Cidr.IP == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(c.Cidr.String()), nil
|
||||
}
|
||||
116
internal/netaddr/cidr_test.go
Обычный файл
116
internal/netaddr/cidr_test.go
Обычный файл
@@ -0,0 +1,116 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestCidr(t *testing.T) {
|
||||
db := openTestConn(t)
|
||||
defer db.Close()
|
||||
|
||||
cidr := Cidr{}
|
||||
|
||||
// Test scanning NULL values
|
||||
err := db.QueryRow("SELECT NULL::cidr").Scan(&cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cidr.Valid {
|
||||
t.Fatalf("expected null result")
|
||||
}
|
||||
|
||||
// Test setting NULL values
|
||||
err = db.QueryRow("SELECT $1::cidr", cidr).Scan(&cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("re-query null value failed: %s", err.Error())
|
||||
}
|
||||
if cidr.Valid {
|
||||
t.Fatalf("expected null result")
|
||||
}
|
||||
|
||||
// test encoding in query params, then decoding during Scan
|
||||
testBidirectional := func(c Cidr, label string) {
|
||||
err = db.QueryRow("SELECT $1::cidr", c).Scan(&cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("re-query %s cidr failed: %s", label, err.Error())
|
||||
}
|
||||
if !cidr.Valid {
|
||||
t.Fatalf("expected non-null value, got null for %s", label)
|
||||
}
|
||||
if !net.IP.Equal(c.Cidr.IP, cidr.Cidr.IP) {
|
||||
t.Fatalf("expected IP addresses to match, but did not for %s - %s %s", label, c.Cidr.IP.String(), cidr.Cidr.IP.String())
|
||||
}
|
||||
if !bytes.Equal(c.Cidr.Mask, cidr.Cidr.Mask) {
|
||||
t.Fatalf("expected net masks to match, but did not for %s", label)
|
||||
}
|
||||
}
|
||||
|
||||
// a few example CIDRs to test out
|
||||
_, exampleCidr, err := net.ParseCIDR("135.104.0.0/32")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building simple IP example - %s", err.Error())
|
||||
}
|
||||
simpleIP4 := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(simpleIP4, "Simple IPv4")
|
||||
|
||||
_, exampleCidr, err = net.ParseCIDR("0.0.0.0/24")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building Zero IP example - %s", err.Error())
|
||||
}
|
||||
zeroIP4Subnet := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(zeroIP4Subnet, "Zero IPv4 Subnet")
|
||||
|
||||
_, exampleCidr, err = net.ParseCIDR("135.104.0.0/24")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building simple IPv4 subnet example - %s", err.Error())
|
||||
}
|
||||
simpleIP4Subnet := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(simpleIP4Subnet, "Simple IPv4 Subnet")
|
||||
|
||||
_, exampleCidr, err = net.ParseCIDR("::1/128")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building simple IPv6 loopback example - %s", err.Error())
|
||||
}
|
||||
ip6Loopback := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(ip6Loopback, "IPv6 Loopback")
|
||||
|
||||
_, exampleCidr, err = net.ParseCIDR("abcd:2345::/65")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building simple IPv6 subnet example - %s", err.Error())
|
||||
}
|
||||
ip6Subnet := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(ip6Subnet, "IPv6 Subnet #1")
|
||||
|
||||
_, exampleCidr, err = net.ParseCIDR("abcd:2300::/24")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building simple IPv6 subnet #2 example - %s", err.Error())
|
||||
}
|
||||
ip6Subnet2 := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(ip6Subnet2, "IPv6 Subnet #2")
|
||||
|
||||
_, exampleCidr, err = net.ParseCIDR("2001:DB8::1/48")
|
||||
if err != nil {
|
||||
t.Fatalf("Fatal error while building simple IPv6 subnet #3 example - %s", err.Error())
|
||||
}
|
||||
ip6Subnet3 := Cidr{Cidr: *exampleCidr, Valid: true}
|
||||
testBidirectional(ip6Subnet3, "IPv6 Subnet #3")
|
||||
|
||||
// Error handling
|
||||
|
||||
// Bad argument
|
||||
cidr = Cidr{}
|
||||
err = cidr.Scan(456)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-byte[] argument to Scan")
|
||||
}
|
||||
|
||||
cidr = Cidr{}
|
||||
err = cidr.Scan([]byte(""))
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error for invalid CIDR")
|
||||
}
|
||||
}
|
||||
39
internal/netaddr/inet.go
Обычный файл
39
internal/netaddr/inet.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Inet is a wrapper for transferring Inet values back and forth easily.
|
||||
type Inet struct {
|
||||
Inet net.IP
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (i *Inet) Scan(value interface{}) error {
|
||||
i.Inet = nil
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
ipAsBytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("could not convert scanned value to bytes")
|
||||
}
|
||||
parsedIP := net.ParseIP(string(ipAsBytes))
|
||||
if parsedIP == nil {
|
||||
return nil
|
||||
}
|
||||
i.Inet = parsedIP
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface. Note if
|
||||
// i.IP is nil the database column value will be set to NULL.
|
||||
func (i Inet) Value() (driver.Value, error) {
|
||||
if i.Inet == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(i.Inet.String()), nil
|
||||
}
|
||||
67
internal/netaddr/inet_test.go
Обычный файл
67
internal/netaddr/inet_test.go
Обычный файл
@@ -0,0 +1,67 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestInet(t *testing.T) {
|
||||
db := openTestConn(t)
|
||||
defer db.Close()
|
||||
|
||||
inet := Inet{}
|
||||
|
||||
// Test scanning NULL values
|
||||
err := db.QueryRow("SELECT NULL::inet").Scan(&inet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inet.Inet != nil {
|
||||
t.Fatalf("expected null result")
|
||||
}
|
||||
|
||||
// Test setting NULL values
|
||||
err = db.QueryRow("SELECT $1::inet", inet).Scan(&inet)
|
||||
if err != nil {
|
||||
t.Fatalf("re-query null value failed: %s", err.Error())
|
||||
}
|
||||
if inet.Inet != nil {
|
||||
t.Fatalf("expected null result")
|
||||
}
|
||||
|
||||
// test encoding in query params, then decoding during Scan
|
||||
testBidirectional := func(i Inet, label string) {
|
||||
err = db.QueryRow("SELECT $1::inet", i).Scan(&inet)
|
||||
if err != nil {
|
||||
t.Fatalf("re-query %s inet failed: %s", label, err.Error())
|
||||
}
|
||||
if inet.Inet == nil {
|
||||
t.Fatalf("expected non-null value, got null for %s", label)
|
||||
}
|
||||
if !net.IP.Equal(i.Inet, inet.Inet) {
|
||||
t.Fatalf("expected IP addresses to match, but did not for %s - %s %s", label, i.Inet.String(), inet.Inet.String())
|
||||
}
|
||||
}
|
||||
|
||||
testBidirectional(Inet{Inet: net.ParseIP("192.168.0.1")}, "Simple IPv4")
|
||||
testBidirectional(Inet{Inet: net.ParseIP("::1")}, "Loopback IPv6")
|
||||
testBidirectional(Inet{Inet: net.ParseIP("abcd:2345::")}, "Loopback IPv6")
|
||||
|
||||
// Bad argument
|
||||
inet = Inet{}
|
||||
err = inet.Scan(456)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-byte[] argument to Scan")
|
||||
}
|
||||
|
||||
inet = Inet{}
|
||||
err = inet.Scan([]byte(""))
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error for empty string - %s", err.Error())
|
||||
}
|
||||
if inet.Inet != nil {
|
||||
t.Fatalf("Unexpected not null for empty/non-IP string string")
|
||||
}
|
||||
}
|
||||
43
internal/netaddr/macaddr.go
Обычный файл
43
internal/netaddr/macaddr.go
Обычный файл
@@ -0,0 +1,43 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Macaddr is a wrapper for transferring Macaddr values back and forth easily.
|
||||
type Macaddr struct {
|
||||
Macaddr net.HardwareAddr
|
||||
Valid bool
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (m *Macaddr) Scan(value interface{}) error {
|
||||
m.Macaddr = nil
|
||||
m.Valid = false
|
||||
if value == nil {
|
||||
m.Valid = false
|
||||
return nil
|
||||
}
|
||||
macaddrAsBytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return errors.New("could not convert scanned value to bytes")
|
||||
}
|
||||
parsedMacaddr, parseErr := net.ParseMAC(string(macaddrAsBytes))
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
m.Valid = true
|
||||
m.Macaddr = parsedMacaddr
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface. Note if m.Valid is false
|
||||
// or m.Macaddr is nil the database column value will be set to NULL.
|
||||
func (m Macaddr) Value() (driver.Value, error) {
|
||||
if !m.Valid || m.Macaddr == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return []byte(m.Macaddr.String()), nil
|
||||
}
|
||||
64
internal/netaddr/macaddr_test.go
Обычный файл
64
internal/netaddr/macaddr_test.go
Обычный файл
@@ -0,0 +1,64 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestMacaddr(t *testing.T) {
|
||||
db := openTestConn(t)
|
||||
defer db.Close()
|
||||
|
||||
macaddr := Macaddr{}
|
||||
|
||||
// Test scanning NULL values
|
||||
err := db.QueryRow("SELECT NULL::macaddr").Scan(&macaddr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if macaddr.Valid {
|
||||
t.Fatalf("expected null result")
|
||||
}
|
||||
|
||||
// Test setting NULL values
|
||||
err = db.QueryRow("SELECT $1::macaddr", macaddr).Scan(&macaddr)
|
||||
if err != nil {
|
||||
t.Fatalf("re-query null value failed: %s", err.Error())
|
||||
}
|
||||
if macaddr.Valid {
|
||||
t.Fatalf("expected null result")
|
||||
}
|
||||
|
||||
// test encoding in query params, then decoding during Scan
|
||||
testBidirectional := func(m Macaddr, label string) {
|
||||
err = db.QueryRow("SELECT $1::macaddr", m).Scan(&macaddr)
|
||||
if err != nil {
|
||||
t.Fatalf("re-query %s macaddr failed: %s", label, err.Error())
|
||||
}
|
||||
if !macaddr.Valid {
|
||||
t.Fatalf("expected non-null value, got null for %s", label)
|
||||
}
|
||||
if !bytes.Equal(m.Macaddr, macaddr.Macaddr) {
|
||||
t.Fatalf("expected MAC addresses to match, but did not for %s", label)
|
||||
}
|
||||
}
|
||||
|
||||
simpleMac := Macaddr{Macaddr: net.HardwareAddr{1, 0x23, 0x45, 0x67, 0x89, 0xab}, Valid: true}
|
||||
testBidirectional(simpleMac, "Simple MAC Address")
|
||||
|
||||
// Bad argument
|
||||
macaddr = Macaddr{}
|
||||
err = macaddr.Scan(456)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for non-byte[] argument to Scan")
|
||||
}
|
||||
|
||||
macaddr = Macaddr{}
|
||||
err = macaddr.Scan([]byte(""))
|
||||
if err == nil {
|
||||
t.Fatalf("Expected error for invalid Macaddr")
|
||||
}
|
||||
}
|
||||
35
internal/netaddr/main_test.go
Обычный файл
35
internal/netaddr/main_test.go
Обычный файл
@@ -0,0 +1,35 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// TestMain loads .env.test before any test runs so DATABASE_* env vars
|
||||
// are populated in this test binary. The netaddr package is intentionally
|
||||
// low-level and does not import config/env; this TestMain gives it the
|
||||
// same environment the rest of the test suite sees without pulling in
|
||||
// the app-wide env init.
|
||||
func TestMain(m *testing.M) {
|
||||
loadEnvIfPresent(".env.test")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func loadEnvIfPresent(name string) {
|
||||
candidates := []string{name}
|
||||
if cwd := os.Getenv("CWD"); cwd != "" {
|
||||
candidates = append(candidates, filepath.Join(cwd, name))
|
||||
}
|
||||
if dir, err := os.Getwd(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(dir, name))
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
_ = godotenv.Load(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
39
internal/netaddr/testutil.go
Обычный файл
39
internal/netaddr/testutil.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package netaddr
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
|
||||
_ "github.com/lib/pq" // postgres driver
|
||||
)
|
||||
|
||||
type Fatalistic interface {
|
||||
Fatal(args ...interface{})
|
||||
}
|
||||
|
||||
func openTestConn(t Fatalistic) *sql.DB {
|
||||
host := getEnv("DATABASE_HOST", getEnv("DB_HOST", "localhost"))
|
||||
if os.Getenv("CI") != "" && os.Getenv("DATABASE_HOST") == "" && os.Getenv("DB_HOST") == "" {
|
||||
host = "postgres"
|
||||
}
|
||||
port := getEnv("DATABASE_PORT", getEnv("DB_PORT", "35432"))
|
||||
user := getEnv("DATABASE_USER", getEnv("DB_USER", "rsmon"))
|
||||
password := getEnv("DATABASE_PASSWORD", getEnv("DB_PASSWORD", "rsmon"))
|
||||
dbname := getEnv("DATABASE_NAME", getEnv("DB_NAME", "rsmon_test"))
|
||||
conn, err := sql.Open(
|
||||
"postgres",
|
||||
"host="+host+" port="+port+" user="+user+" password="+password+" dbname="+dbname+" sslmode=disable",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
88
internal/notifier/email.go
Обычный файл
88
internal/notifier/email.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
// Package notifier provides functionality.
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// SendEmail provides functionality.
|
||||
func SendEmail(to, subject, body string) error {
|
||||
p := bluemonday.StripTagsPolicy()
|
||||
cred, err := firstSMTPCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fromAddr := &mail.Address{Name: cred.fromName, Address: cred.fromAddress}
|
||||
from := fromAddr.String()
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
// m.SetBody("text/html", body)
|
||||
m.AddAlternative("text/plain", p.Sanitize(body))
|
||||
m.AddAlternative("text/html", body)
|
||||
|
||||
d := gomail.NewDialer(
|
||||
cred.server,
|
||||
cred.port,
|
||||
cred.login,
|
||||
cred.password,
|
||||
)
|
||||
if cred.insecureSkipVerify {
|
||||
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
return d.DialAndSend(m)
|
||||
}
|
||||
|
||||
type smtpCredential struct {
|
||||
server string
|
||||
port int
|
||||
login string
|
||||
password string
|
||||
fromName string
|
||||
fromAddress string
|
||||
insecureSkipVerify bool
|
||||
}
|
||||
|
||||
func firstSMTPCredential() (*smtpCredential, error) {
|
||||
creds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, errors.New("smtp credential is not configured")
|
||||
}
|
||||
c := &creds[0]
|
||||
password, err := c.GetSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smtp credential secret: %w", err)
|
||||
}
|
||||
out := &smtpCredential{password: password, insecureSkipVerify: c.InsecureSkipVerify}
|
||||
if c.Server != nil {
|
||||
out.server = *c.Server
|
||||
}
|
||||
if c.Port != nil {
|
||||
out.port = *c.Port
|
||||
}
|
||||
if c.Login != nil {
|
||||
out.login = *c.Login
|
||||
}
|
||||
if c.FromName != nil {
|
||||
out.fromName = *c.FromName
|
||||
}
|
||||
if c.FromAddr != nil {
|
||||
out.fromAddress = *c.FromAddr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
77
internal/notifier/notifier.go
Обычный файл
77
internal/notifier/notifier.go
Обычный файл
@@ -0,0 +1,77 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Tunables for the periodic notifier loops. The values match what the legacy
|
||||
// Start() function used so behavior is unchanged.
|
||||
var (
|
||||
interval = 5 * time.Second
|
||||
expInterval = 2 * time.Hour
|
||||
deletionInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
// StartScheduler launches the three periodic loops that used to be triggered
|
||||
// by the retired notifier.Start singleton: the notification producer (Run),
|
||||
// the expiry-alert producer (RunExp), and the pending-deletion sweep. Phase 3
|
||||
// of docs/plans/worker-notifier-mvp.md replaces the in-process notifier loop
|
||||
// with the worker-driven task queue; this scheduler keeps the producer
|
||||
// running on its existing cadence so the tasks table stays populated.
|
||||
//
|
||||
// The loops respect ctx.Done() so a graceful shutdown can unwind them, and
|
||||
// each tick is wrapped in recover() so a transient bug in one producer does
|
||||
// not tear down the whole scheduler.
|
||||
//
|
||||
// Reaper: StartTaskReaper lives in app/models/task_reaper.go and runs the
|
||||
// leased->queued recycling on a separate 30s tick.
|
||||
func StartScheduler(ctx context.Context) {
|
||||
go scheduleLoop(ctx, interval, Run, "Run")
|
||||
go scheduleLoop(ctx, expInterval, RunExp, "RunExp")
|
||||
go scheduleLoop(ctx, deletionInterval, RunPendingDeletions, "RunPendingDeletions")
|
||||
}
|
||||
|
||||
// scheduleLoop runs fn immediately and then on every tick. Any panic from
|
||||
// fn is recovered and logged so the loop keeps running.
|
||||
func scheduleLoop(ctx context.Context, tick time.Duration, fn func(), name string) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("notifier: scheduler %s goroutine recovered from panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
|
||||
safeRun(name, fn)
|
||||
ticker := time.NewTicker(tick)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
safeRun(name, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeRun invokes fn with a panic recovery guard. Each tick is wrapped so a
|
||||
// single bad tick cannot kill the loop. The loop goroutine itself has its own
|
||||
// recover() (see scheduleLoop) for paranoia.
|
||||
func safeRun(name string, fn func()) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("notifier: %s recovered from panic: %v", name, r)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// RunPendingDeletions hard-deletes users whose 7-day grace period has elapsed.
|
||||
func RunPendingDeletions() {
|
||||
if _, err := models.ProcessPendingDeletions(); err != nil {
|
||||
log.Printf("notifier: process pending deletions: %v", err)
|
||||
}
|
||||
}
|
||||
166
internal/notifier/producer.go
Обычный файл
166
internal/notifier/producer.go
Обычный файл
@@ -0,0 +1,166 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// langEN is the wire-side default language tag used when a Message carries
|
||||
// no language hint of its own. Centralized so the literal does not appear
|
||||
// three or more times across this package (goconst).
|
||||
const langEN = "en"
|
||||
|
||||
// ContactKindToMethod maps the legacy Contact.Kind enum used by the sender onto
|
||||
// the worker notification_method enum introduced in
|
||||
// docs/plans/worker-notifier-mvp.md section 4.3. sms/voice remain placeholders
|
||||
// until phase 4.
|
||||
//
|
||||
//nolint:goconst // match arm values must be the wire-method enum literals
|
||||
func ContactKindToMethod(kind string) string {
|
||||
switch kind {
|
||||
case "email":
|
||||
return "email"
|
||||
case "telegram_private", "telegram_group":
|
||||
return "telegram"
|
||||
case "webhook":
|
||||
return "webhook"
|
||||
case "mattermost":
|
||||
return "mattermost"
|
||||
case "sms":
|
||||
return "sms"
|
||||
case "voice":
|
||||
return "voice"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RenderNotificationContent pre-renders subject + bodies for one Message using
|
||||
// the existing internal/sender/get_content.go helpers. The result is what the
|
||||
// worker binary consumes directly so it does not need access to Message/Event
|
||||
// rows, workdays, or NotificationDayStart logic on the data plane.
|
||||
//
|
||||
// Returns the four bodies (subject, text, markdown, html). The caller is
|
||||
// responsible for passing them through to EnqueueNotificationTask.
|
||||
func RenderNotificationContent(msg *models.Message, now time.Time) (subject, bodyText, bodyMarkdown, bodyHTML string, err error) {
|
||||
if msg == nil {
|
||||
return "", "", "", "", errors.New("notifier: nil message")
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.New("notifier: render panicked")
|
||||
}
|
||||
}()
|
||||
sbuf, tbuf, mbuf, hbuf := notifyrender.GetContent(msg, now)
|
||||
return sbuf.String(), tbuf.String(), mbuf.String(), hbuf.String(), nil
|
||||
}
|
||||
|
||||
// EnqueueNotificationTaskFromMessage is the producer-side hook called from
|
||||
// performEvents (or its replacement). It builds a wire.NotificationTask from the
|
||||
// freshly created Message and enqueues one Task row keyed by the stable
|
||||
// (notification, contact, first-event) idempotency key.
|
||||
//
|
||||
// If the producer's authorization precheck fails the function returns an error:
|
||||
// worker notification tasks are now the only delivery path.
|
||||
func EnqueueNotificationTaskFromMessage(n *models.Notification, c *models.Contact, msg *models.Message) (*models.Task, error) {
|
||||
return enqueueNotificationTaskFromMessageTx(models.DB(), n, c, msg)
|
||||
}
|
||||
|
||||
// enqueueNotificationTaskFromMessageTx keeps message creation and durable task
|
||||
// production in the caller's notifier transaction.
|
||||
func enqueueNotificationTaskFromMessageTx(tx *gorm.DB, n *models.Notification, c *models.Contact, msg *models.Message) (*models.Task, error) {
|
||||
if msg == nil || n == nil || c == nil {
|
||||
return nil, errors.New("notifier: nil message/notification/contact")
|
||||
}
|
||||
if len(msg.Events) == 0 {
|
||||
return nil, errors.New("notifier: message has no events (exp messages go through a separate path)")
|
||||
}
|
||||
|
||||
method := ContactKindToMethod(c.Kind)
|
||||
if method == "" {
|
||||
log.Printf("notifier: unknown contact kind %q for contact %d, skipping enqueue", c.Kind, c.ID)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
subject, bodyText, bodyMarkdown, bodyHTML, err := RenderNotificationContent(msg, now)
|
||||
if err != nil {
|
||||
log.Printf("notifier: render content failed for message %d: %v", msg.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checkID := msg.CheckID
|
||||
monitorID := msg.Events[0].MonitorID
|
||||
task := wire.NotificationTask{
|
||||
AccountID: n.AccountID,
|
||||
MessageID: msg.ID,
|
||||
NotificationID: n.ID,
|
||||
EventIDs: eventIDs(msg),
|
||||
CheckID: checkID,
|
||||
MonitorID: &monitorID,
|
||||
Method: method,
|
||||
Contact: wire.NotificationContact{
|
||||
ID: c.ID,
|
||||
Kind: c.Kind,
|
||||
Value: c.Value,
|
||||
Name: c.Name,
|
||||
},
|
||||
Subject: subject,
|
||||
BodyText: bodyText,
|
||||
BodyMarkdown: bodyMarkdown,
|
||||
BodyHTML: bodyHTML,
|
||||
Language: langEN,
|
||||
MessageKind: msg.Kind,
|
||||
}
|
||||
payload, err := json.Marshal(task)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contactID := c.ID
|
||||
monitorPtr := task.MonitorID
|
||||
checkPtr := task.CheckID
|
||||
messagePtr := msg.ID
|
||||
|
||||
taskRow, err := models.EnqueueNotificationTaskTx(tx, &models.EnqueueNotificationTaskInput{
|
||||
AccountID: n.AccountID,
|
||||
NotificationID: n.ID,
|
||||
ContactID: contactID,
|
||||
MessageID: &messagePtr,
|
||||
MonitorID: monitorPtr,
|
||||
CheckID: checkPtr,
|
||||
EventIDs: task.EventIDs,
|
||||
Method: method,
|
||||
Subject: subject,
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
BodyMarkdown: bodyMarkdown,
|
||||
Language: task.Language,
|
||||
MessageKind: msg.Kind,
|
||||
NotBefore: now,
|
||||
Payload: payload,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf(
|
||||
"notifier: task enqueued id=%d job=%s kind=notification account=%d method=%s notification=%d contact=%d event=%d idempotency=%s",
|
||||
taskRow.ID, taskRow.JobID, n.AccountID, method, n.ID, c.ID, task.EventIDs[0], taskRow.IdempotencyKey,
|
||||
)
|
||||
return taskRow, nil
|
||||
}
|
||||
|
||||
func eventIDs(msg *models.Message) []int64 {
|
||||
out := make([]int64, 0, len(msg.Events))
|
||||
for _, e := range msg.Events { //nolint:gocritic // range copy is acceptable here
|
||||
out = append(out, e.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
219
internal/notifier/producer_test.go
Обычный файл
219
internal/notifier/producer_test.go
Обычный файл
@@ -0,0 +1,219 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.Init()
|
||||
}
|
||||
|
||||
// TestContactKindToMethod is the table that drives producer + selector +
|
||||
// executor dispatch.
|
||||
func TestContactKindToMethod(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"email": "email",
|
||||
"telegram_private": "telegram",
|
||||
"telegram_group": "telegram",
|
||||
"webhook": "webhook",
|
||||
"mattermost": "mattermost",
|
||||
"sms": "sms",
|
||||
"voice": "voice",
|
||||
"": "",
|
||||
"unknown": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
assert.Equal(t, want, ContactKindToMethod(in), "kind=%q", in)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnqueueNotificationTaskFromMessage_SeedsTaskWithPreRenderedBody
|
||||
// exercises the producer end-to-end against the test DB. It seeds an account,
|
||||
// notification, contact, and event; calls EnqueueNotificationTaskFromMessage;
|
||||
// and checks the resulting Task row has the pre-rendered subject/body in the
|
||||
// payload (i.e. the worker does not need to know templating).
|
||||
func TestEnqueueNotificationTaskFromMessage_SeedsTaskWithPreRenderedBody(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
models.DB().Exec(
|
||||
"INSERT INTO regions (code, name, enabled, created_at, updated_at) VALUES (?, ?, true, now(), now())",
|
||||
"test", "test",
|
||||
)
|
||||
|
||||
plan := models.Plan{Name: "producer-test"}
|
||||
require.NoError(t, models.DB().Create(&plan).Error)
|
||||
user := models.User{Name: "u", Email: producerStringPtr("u@example.com"), Timezone: "UTC"}
|
||||
require.NoError(t, models.DB().Create(&user).Error)
|
||||
account := models.Account{Name: "a", Timezone: "UTC", Language: "en", PlanID: &plan.ID}
|
||||
require.NoError(t, models.DB().Create(&account).Error)
|
||||
|
||||
group := models.Group{Name: "g", AccountID: account.ID}
|
||||
require.NoError(t, models.DB().Create(&group).Error)
|
||||
|
||||
monitor := models.Monitor{
|
||||
Name: producerStringPtr("m"),
|
||||
Host: "example.com",
|
||||
GroupID: group.ID,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, models.DB().Create(&monitor).Error)
|
||||
|
||||
notification := models.Notification{
|
||||
Name: "default", AccountID: account.ID, Enabled: true,
|
||||
NotifyDown: true, NotifyRestore: true,
|
||||
}
|
||||
require.NoError(t, models.DB().Create(¬ification).Error)
|
||||
|
||||
contact := models.Contact{Name: "ops", Kind: "email", Value: "ops@example.com", AccountID: &account.ID}
|
||||
require.NoError(t, models.DB().Create(&contact).Error)
|
||||
|
||||
start := time.Now().Add(-time.Minute)
|
||||
event := models.Event{
|
||||
MonitorID: monitor.ID,
|
||||
StartTime: &start,
|
||||
State: "current",
|
||||
Errors: 5,
|
||||
}
|
||||
require.NoError(t, models.DB().Create(&event).Error)
|
||||
|
||||
msg := models.Message{
|
||||
NotificationID: notification.ID,
|
||||
ContactID: contact.ID,
|
||||
Events: []models.Event{event},
|
||||
Kind: "down",
|
||||
State: "queued",
|
||||
}
|
||||
require.NoError(t, models.DB().Create(&msg).Error)
|
||||
|
||||
w := &models.WorkerNode{
|
||||
WorkerID: "worker-producer",
|
||||
RegionCode: "test",
|
||||
Status: "active",
|
||||
AuthToken: "tok",
|
||||
Concurrency: 4,
|
||||
LastSeen: producerTimePtr(time.Now()),
|
||||
Capabilities: datatypes.JSON([]byte(
|
||||
`{"check_types":["http"],"task_envelope":true,"notification_methods":["email"],"notification_accounts":[]}`,
|
||||
)),
|
||||
}
|
||||
require.NoError(t, models.DB().Create(w).Error)
|
||||
|
||||
// Load the message back with the scope GetContent expects (Monitor + Group
|
||||
// + Notification preloaded). The sender's GetContent panics on nil fields.
|
||||
loaded := models.Message{}
|
||||
require.NoError(t, models.MessageScope(models.DB()).First(&loaded, msg.ID).Error)
|
||||
require.Len(t, loaded.Events, 1)
|
||||
require.NotNil(t, loaded.Events[0].Monitor)
|
||||
|
||||
row, err := EnqueueNotificationTaskFromMessage(¬ification, &contact, &loaded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, row, "expected a Task row from the producer")
|
||||
assert.Equal(t, models.TaskKindNotification, row.Kind)
|
||||
assert.Equal(t, models.TaskStateQueued, row.State)
|
||||
assert.NotEmpty(t, row.Payload)
|
||||
assert.Equal(t, account.ID, row.AccountID)
|
||||
assert.Equal(t, &contact.ID, row.ContactID)
|
||||
require.NotNil(t, row.MessageID)
|
||||
assert.Equal(t, loaded.ID, *row.MessageID)
|
||||
assert.Equal(t, models.NotificationIdempotencyKey(notification.ID, contact.ID, event.ID), row.IdempotencyKey)
|
||||
|
||||
var payload map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(row.Payload, &payload))
|
||||
assert.Equal(t, "email", payload["method"])
|
||||
assert.Equal(t, "down", payload["message_kind"])
|
||||
assert.NotEmpty(t, payload["subject"])
|
||||
}
|
||||
|
||||
// TestEnqueueNotificationTaskFromMessage_Idempotent exercises the producer's
|
||||
// idempotency contract: a second call with the same (notification, contact,
|
||||
// event) tuple must not create a second Task row.
|
||||
func TestEnqueueNotificationTaskFromMessage_Idempotent(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
models.DB().Exec(
|
||||
"INSERT INTO regions (code, name, enabled, created_at, updated_at) VALUES (?, ?, true, now(), now())",
|
||||
"test", "test",
|
||||
)
|
||||
plan := models.Plan{Name: "p"}
|
||||
require.NoError(t, models.DB().Create(&plan).Error)
|
||||
user := models.User{Name: "u", Email: producerStringPtr("u@example.com"), Timezone: "UTC"}
|
||||
require.NoError(t, models.DB().Create(&user).Error)
|
||||
account := models.Account{Name: "a", Timezone: "UTC", Language: "en", PlanID: &plan.ID}
|
||||
require.NoError(t, models.DB().Create(&account).Error)
|
||||
group := models.Group{Name: "g", AccountID: account.ID}
|
||||
require.NoError(t, models.DB().Create(&group).Error)
|
||||
monitor := models.Monitor{
|
||||
Name: producerStringPtr("m"),
|
||||
Host: "example.com",
|
||||
GroupID: group.ID,
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, models.DB().Create(&monitor).Error)
|
||||
notification := models.Notification{
|
||||
Name: "default", AccountID: account.ID, Enabled: true,
|
||||
NotifyDown: true, NotifyRestore: true,
|
||||
}
|
||||
require.NoError(t, models.DB().Create(¬ification).Error)
|
||||
contact := models.Contact{Name: "ops", Kind: "email", Value: "ops@example.com", AccountID: &account.ID}
|
||||
require.NoError(t, models.DB().Create(&contact).Error)
|
||||
start := time.Now().Add(-time.Minute)
|
||||
event := models.Event{MonitorID: monitor.ID, StartTime: &start, State: "current", Errors: 5}
|
||||
require.NoError(t, models.DB().Create(&event).Error)
|
||||
|
||||
w := &models.WorkerNode{
|
||||
WorkerID: "worker-idem",
|
||||
RegionCode: "test",
|
||||
Status: "active",
|
||||
AuthToken: "tok",
|
||||
Concurrency: 4,
|
||||
LastSeen: producerTimePtr(time.Now()),
|
||||
Capabilities: datatypes.JSON([]byte(
|
||||
`{"check_types":["http"],"task_envelope":true,"notification_methods":["email"],"notification_accounts":[]}`,
|
||||
)),
|
||||
}
|
||||
require.NoError(t, models.DB().Create(w).Error)
|
||||
|
||||
msg := models.Message{
|
||||
NotificationID: notification.ID,
|
||||
ContactID: contact.ID,
|
||||
Events: []models.Event{event},
|
||||
Kind: "down",
|
||||
State: "queued",
|
||||
}
|
||||
require.NoError(t, models.DB().Create(&msg).Error)
|
||||
|
||||
loaded := models.Message{}
|
||||
require.NoError(t, models.MessageScope(models.DB()).First(&loaded, msg.ID).Error)
|
||||
|
||||
first, err := EnqueueNotificationTaskFromMessage(¬ification, &contact, &loaded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
|
||||
second, err := EnqueueNotificationTaskFromMessage(¬ification, &contact, &loaded)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second)
|
||||
assert.Equal(t, first.ID, second.ID, "second producer call must reuse the first task row")
|
||||
|
||||
var count int64
|
||||
require.NoError(t, models.DB().Model(&models.Task{}).
|
||||
Where("idempotency_key = ?", first.IdempotencyKey).
|
||||
Count(&count).Error)
|
||||
assert.EqualValues(t, 1, count)
|
||||
}
|
||||
|
||||
func producerStringPtr(s string) *string { return &s }
|
||||
|
||||
func producerTimePtr(value time.Time) *time.Time { return &value }
|
||||
|
||||
// guard against uuid being accidentally dropped from the imports.
|
||||
var _ = uuid.New
|
||||
370
internal/notifier/run.go
Обычный файл
370
internal/notifier/run.go
Обычный файл
@@ -0,0 +1,370 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
eventStateEnded = "ended"
|
||||
eventStateCurrent = "current"
|
||||
messageKindDown = "down"
|
||||
messageKindUp = "up"
|
||||
)
|
||||
|
||||
// DEBUG provides functionality.
|
||||
const DEBUG = false
|
||||
|
||||
// Run starts the notification scheduler loop.
|
||||
func Run() {
|
||||
_ = models.LogCheck("notify")
|
||||
|
||||
events := make([]models.Event, 0)
|
||||
|
||||
tx := models.DB().Begin()
|
||||
|
||||
q := tx
|
||||
// q = q.Set("gorm:query_option", "FOR UPDATE")
|
||||
err := models.EventScope(q).Find(&events).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
|
||||
type SendItem struct {
|
||||
Notification models.Notification
|
||||
Contact models.Contact
|
||||
Events []models.Event
|
||||
}
|
||||
|
||||
eventsByNotification := make(map[int64]map[int64]*SendItem, 0)
|
||||
|
||||
hasPossible := make(map[int64]bool)
|
||||
|
||||
eventIDs := make(map[int64]bool, 0)
|
||||
contactIDs := make(map[int64]bool, 0)
|
||||
alreadySentDown := make(map[int64]map[int64]bool, 0)
|
||||
alreadySentUp := make(map[int64]map[int64]bool, 0)
|
||||
for _, e := range events { //nolint:gocritic // range copy is acceptable here
|
||||
eventIDs[e.ID] = true
|
||||
if e.Monitor == nil {
|
||||
println("event has no monitor")
|
||||
e.State = "broken"
|
||||
err := tx.Save(&e).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if e.Monitor.Group == nil {
|
||||
println("monitor has no group")
|
||||
e.State = "broken"
|
||||
err := tx.Save(&e).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, n := range e.Monitor.Group.Notifications { //nolint:gocritic // range copy is acceptable here
|
||||
for _, c := range n.Contacts { //nolint:gocritic // range copy is acceptable here
|
||||
contactIDs[c.ID] = true
|
||||
alreadySentDown[c.ID] = make(map[int64]bool, 0)
|
||||
alreadySentUp[c.ID] = make(map[int64]bool, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sentMessages := make([]models.Message, 0)
|
||||
|
||||
eventIDsSlice := make([]int64, 0)
|
||||
for k := range eventIDs {
|
||||
eventIDsSlice = append(eventIDsSlice, k)
|
||||
}
|
||||
contactIDsSlice := make([]int64, 0)
|
||||
for k := range contactIDs {
|
||||
contactIDsSlice = append(contactIDsSlice, k)
|
||||
}
|
||||
|
||||
err = tx.Preload("Events").
|
||||
// Where("kind = ?", "down").
|
||||
Where("id IN (SELECT message_id FROM event_messages WHERE event_id IN (?))", eventIDsSlice).
|
||||
Where("contact_id IN (?)", contactIDsSlice).Find(&sentMessages).
|
||||
Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
|
||||
for _, m := range sentMessages { //nolint:gocritic // range copy is acceptable here
|
||||
for _, evt := range m.Events { //nolint:gocritic // range copy is acceptable here
|
||||
switch m.Kind {
|
||||
case messageKindDown:
|
||||
alreadySentDown[m.ContactID][evt.ID] = true
|
||||
case messageKindUp:
|
||||
alreadySentUp[m.ContactID][evt.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range events { //nolint:gocritic // range copy is acceptable here
|
||||
if e.Monitor == nil {
|
||||
println("event has no monitor")
|
||||
continue
|
||||
}
|
||||
if e.Monitor.Group == nil {
|
||||
println("monitor has no group")
|
||||
continue
|
||||
}
|
||||
underMaintenance, maintenanceErr := models.MonitorUnderMaintenance(e.MonitorID, time.Now().UTC())
|
||||
if maintenanceErr != nil {
|
||||
log.Printf("notifier: maintenance lookup for monitor %d: %v", e.MonitorID, maintenanceErr)
|
||||
} else if underMaintenance {
|
||||
// Keep the event pending. Marking it old here would silently drop a
|
||||
// failure that remains unresolved after the maintenance window ends.
|
||||
hasPossible[e.ID] = true
|
||||
continue
|
||||
}
|
||||
|
||||
if e.State == eventStateEnded {
|
||||
hasPossible[e.ID] = false
|
||||
} else {
|
||||
hasPossible[e.ID] = true
|
||||
}
|
||||
|
||||
if DEBUG {
|
||||
log.Println("run event", e.Inspect())
|
||||
}
|
||||
for _, n := range e.Monitor.Group.Notifications { //nolint:gocritic // range copy is acceptable here
|
||||
if !n.Enabled {
|
||||
if DEBUG {
|
||||
log.Println("event", e.ID, "notification", n.ID, "Enabled = false")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if e.State == eventStateCurrent {
|
||||
if !n.NotifyDown {
|
||||
if DEBUG {
|
||||
log.Println("event", e.ID, "notification", n.ID, "NotifyDown = false")
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else if e.State == eventStateEnded {
|
||||
if !n.NotifyRestore {
|
||||
if DEBUG {
|
||||
log.Println("event", e.ID, "notification", n.ID, "NotifyRestore = false")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := eventsByNotification[n.ID]; !ok {
|
||||
eventsByNotification[n.ID] = make(map[int64]*SendItem, 0)
|
||||
}
|
||||
|
||||
for _, c := range n.Contacts { //nolint:gocritic // range copy is acceptable here
|
||||
if e.State == eventStateCurrent {
|
||||
if _, sent := alreadySentDown[c.ID][e.ID]; sent {
|
||||
if DEBUG {
|
||||
log.Println("event", e.ID, "notification", n.ID, "already sent")
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else if e.State == eventStateEnded {
|
||||
if _, sent := alreadySentDown[c.ID][e.ID]; !sent {
|
||||
if DEBUG {
|
||||
log.Println("event", e.ID, "dont notify up", n.ID, "- no down was sent")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if _, sent := alreadySentUp[c.ID][e.ID]; sent {
|
||||
if DEBUG {
|
||||
log.Println("event", e.ID, "notification", n.ID, "already sent")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, ok := eventsByNotification[n.ID][c.ID]; !ok {
|
||||
si := SendItem{
|
||||
Notification: n,
|
||||
Contact: c,
|
||||
Events: make([]models.Event, 0),
|
||||
}
|
||||
// log.Println("create", n.ID, c.ID)
|
||||
// spew.Dump(si)
|
||||
eventsByNotification[n.ID][c.ID] = &si
|
||||
}
|
||||
sendItem := eventsByNotification[n.ID][c.ID]
|
||||
sendItem.Events = append(sendItem.Events, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, eventsByContact := range eventsByNotification {
|
||||
for _, sendItem := range eventsByContact {
|
||||
n := sendItem.Notification
|
||||
c := sendItem.Contact
|
||||
|
||||
tn := time.Now()
|
||||
|
||||
requredEvents := make([]models.Event, 0)
|
||||
possibleEvents := make([]models.Event, 0)
|
||||
laterEvents := make([]models.Event, 0)
|
||||
|
||||
for _, e := range sendItem.Events { //nolint:gocritic // range copy is acceptable here
|
||||
dur := e.GetDuration(tn)
|
||||
var delay int64
|
||||
if n.AlertDelay != nil {
|
||||
delay = *n.AlertDelay
|
||||
} else {
|
||||
delay = 300
|
||||
}
|
||||
|
||||
if !n.EnabledNow(&tn) {
|
||||
if DEBUG {
|
||||
log.Println("notification", n.ID, "is not enabled at this time")
|
||||
}
|
||||
laterEvents = append(laterEvents, e)
|
||||
}
|
||||
|
||||
if e.State == eventStateCurrent && e.Errors > 4 { //nolint:gocritic // complex condition chain
|
||||
if DEBUG {
|
||||
log.Println("min errors count to force send reached:", e.Errors)
|
||||
}
|
||||
requredEvents = append(requredEvents, e)
|
||||
} else if e.State == eventStateCurrent && e.Errors < 2 {
|
||||
if DEBUG {
|
||||
log.Println("event possbile to notify in aggregation, but errs count not reached:", e.Errors)
|
||||
}
|
||||
possibleEvents = append(possibleEvents, e)
|
||||
} else if e.State == eventStateEnded && e.Oks > 4 {
|
||||
if DEBUG {
|
||||
log.Println("min oks count to force send reached:", e.Oks)
|
||||
}
|
||||
requredEvents = append(requredEvents, e)
|
||||
} else if e.State == eventStateEnded && e.Oks < 2 {
|
||||
if DEBUG {
|
||||
log.Println("event possbile to notify in aggregation, but oks count not reached:", e.Oks)
|
||||
}
|
||||
possibleEvents = append(possibleEvents, e)
|
||||
} else if dur < delay {
|
||||
if DEBUG {
|
||||
log.Println("event possbile to notify in aggregation, but alert_delay not reached: delay", delay, "duration", dur, "so", (delay - dur), "left") //nolint:lll
|
||||
}
|
||||
possibleEvents = append(possibleEvents, e)
|
||||
} else {
|
||||
if DEBUG {
|
||||
log.Println("event required to notify, alert_delay reached: delay", delay, "duration", dur, "so", (delay - dur), "left") //nolint:lll
|
||||
}
|
||||
requredEvents = append(requredEvents, e)
|
||||
}
|
||||
}
|
||||
if len(requredEvents) > 0 {
|
||||
performEvents(tx, &n, &c, append(requredEvents, possibleEvents...))
|
||||
} else {
|
||||
if len(possibleEvents) > 0 || len(laterEvents) > 0 {
|
||||
// log.Println("notification", n.ID, "no required events, but will send later")
|
||||
for _, evt := range possibleEvents { //nolint:gocritic // range copy is acceptable here
|
||||
hasPossible[evt.ID] = true
|
||||
}
|
||||
for _, evt := range laterEvents { //nolint:gocritic // range copy is acceptable here
|
||||
hasPossible[evt.ID] = true
|
||||
}
|
||||
} else {
|
||||
log.Println("notification", n.ID, "no events left")
|
||||
}
|
||||
}
|
||||
|
||||
// spew.Dump(sendItem.Notification)
|
||||
// spew.Dump(sendItem.Contact)
|
||||
// spew.Dump(sendItem.Events)
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range events { //nolint:gocritic // range copy is acceptable here
|
||||
if !hasPossible[e.ID] {
|
||||
// log.Println("event has no possible notifications left to send, mark as done")
|
||||
e.State = "old"
|
||||
err := tx.Save(&e).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
}
|
||||
|
||||
func performEvents(tx *gorm.DB, n *models.Notification, c *models.Contact, events []models.Event) {
|
||||
eventIDs := make([]int64, 0, len(events))
|
||||
for _, evt := range events { //nolint:gocritic // range copy is acceptable here
|
||||
eventIDs = append(eventIDs, evt.ID)
|
||||
}
|
||||
log.Println("performing events:", n.ID, c.ID, eventIDs)
|
||||
|
||||
eventsByKind := make(map[string][]models.Event, 0)
|
||||
for _, evt := range events { //nolint:gocritic // range copy is acceptable here
|
||||
var kind string
|
||||
switch evt.State {
|
||||
case eventStateCurrent:
|
||||
kind = messageKindDown
|
||||
case eventStateEnded:
|
||||
kind = messageKindUp
|
||||
default:
|
||||
log.Println("unknown event state: " + evt.State)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
if _, ok := eventsByKind[kind]; !ok {
|
||||
eventsByKind[kind] = make([]models.Event, 0)
|
||||
}
|
||||
eventsByKind[kind] = append(eventsByKind[kind], evt)
|
||||
}
|
||||
|
||||
for kind, evts := range eventsByKind {
|
||||
// spew.Dump(kind, evts)
|
||||
message := models.Message{
|
||||
NotificationID: n.ID,
|
||||
ContactID: c.ID,
|
||||
Events: evts,
|
||||
Kind: kind,
|
||||
State: models.TaskStateQueued,
|
||||
}
|
||||
err := tx.Save(&message).Error
|
||||
if err != nil {
|
||||
// panic(err)
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Worker notification tasks are the only delivery path. If enqueue fails,
|
||||
// keep the message as an explicit error instead of relying on the retired
|
||||
// in-process sender loop.
|
||||
if _, err := enqueueNotificationTaskFromMessageTx(tx, n, c, &message); err != nil {
|
||||
errText := err.Error()
|
||||
log.Printf("notifier: enqueue task for message %d failed: %v", message.ID, err)
|
||||
if saveErr := tx.Model(&message).Updates(map[string]interface{}{"state": "error", "error": &errText}).Error; saveErr != nil {
|
||||
log.Printf("notifier: mark message %d error failed: %v", message.ID, saveErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
internal/notifier/run_exp.go
Обычный файл
117
internal/notifier/run_exp.go
Обычный файл
@@ -0,0 +1,117 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// RunExp provides functionality.
|
||||
//
|
||||
// A panic in any single check's notification must not kill the scheduler
|
||||
// goroutine. The defensive recover() keeps the 2h tick alive even if
|
||||
// RunExpCheck trips over a bad row or a stale schema reference.
|
||||
func RunExp() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("notifier: RunExp recovered from panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
_ = models.LogCheck("exp")
|
||||
|
||||
checks := make([]models.Check, 0)
|
||||
|
||||
err := models.ExpScope(models.DB()).Find(&checks).Error
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
for i := range checks {
|
||||
RunExpCheck(&checks[i])
|
||||
}
|
||||
}
|
||||
|
||||
// RunExpCheck provides functionality.
|
||||
//
|
||||
// A panic in any per-row work (notifier, contact lookup, message write) is
|
||||
// contained here so one bad row cannot take down the whole RunExp scheduler.
|
||||
// The panic is logged with the check id and the loop continues.
|
||||
func RunExpCheck(c *models.Check) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("notifier: RunExpCheck recovered from panic on check %d: %v", c.ID, r)
|
||||
}
|
||||
}()
|
||||
|
||||
if c.Monitor == nil {
|
||||
log.Println("!BUG! check", c.ID, "has no Monitor (or not preloaded). Monitor ID: ", c.MonitorID, " Not running.")
|
||||
log.Println()
|
||||
return
|
||||
}
|
||||
if !c.Monitor.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
if c.Monitor.Group == nil {
|
||||
log.Println("!BUG! check", c.ID, "has no Monitor (or not preloaded). Monitor ID: ", c.Monitor.ID, ", group id:", c.Monitor.GroupID, " Not running.") //nolint:lll
|
||||
return
|
||||
}
|
||||
if c.Monitor.Group.Notifications == nil {
|
||||
log.Println("!BUG! check", c.ID, "has no .Monitor.Group.Notifications (or not preloaded). Not running.")
|
||||
return
|
||||
}
|
||||
|
||||
for i := range c.Monitor.Group.Notifications {
|
||||
n := &c.Monitor.Group.Notifications[i]
|
||||
if n.BeforeExpiration == nil {
|
||||
continue
|
||||
}
|
||||
if c.Expires == nil {
|
||||
// should not happen
|
||||
continue
|
||||
}
|
||||
if c.Kind == "whois" && !n.NotifyWHOIS {
|
||||
continue
|
||||
}
|
||||
if c.Kind == "ssl" && !n.NotifySSL {
|
||||
continue
|
||||
}
|
||||
|
||||
// notify delay not reached
|
||||
notifyOn := time.Now().Add(time.Second * time.Duration(*n.BeforeExpiration))
|
||||
if c.Expires.After(notifyOn) {
|
||||
continue
|
||||
}
|
||||
|
||||
createExpMessage(c, n)
|
||||
}
|
||||
}
|
||||
|
||||
func createExpMessage(c *models.Check, n *models.Notification) {
|
||||
for _, contact := range n.GetContacts() { //nolint:gocritic // range copy is acceptable here
|
||||
message := models.Message{
|
||||
CheckID: &c.ID,
|
||||
NotificationID: n.ID,
|
||||
ContactID: contact.ID,
|
||||
Kind: "exp",
|
||||
}
|
||||
|
||||
models.DB().
|
||||
Where(message).
|
||||
Where("created_at > ?", time.Now().Add(-time.Hour*24*14)).
|
||||
Find(&message)
|
||||
if message.ID > 0 {
|
||||
continue
|
||||
}
|
||||
message.State = models.TaskStateQueued
|
||||
err := models.DB().Save(&message).Error
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
160
internal/notifier/run_exp_test.go
Обычный файл
160
internal/notifier/run_exp_test.go
Обычный файл
@@ -0,0 +1,160 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
"rsgit.ru/rsmon/rsmon/spec/factories"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.Init()
|
||||
}
|
||||
|
||||
func TestRunExp(t *testing.T) {
|
||||
log.Println("TestRunExp")
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
var err error
|
||||
contact, notification, monitor := factories.MonitorWithNotification()
|
||||
|
||||
check := factories.PersistedCheck(&monitor, "ssl")
|
||||
assert.Equal(t, "ssl", check.Kind, "check kind should be ssl")
|
||||
assert.Equal(t, monitor.ID, check.MonitorID, "check should have correct monitor id")
|
||||
|
||||
exp := time.Now().Add(14 * 24 * time.Hour)
|
||||
check.Expires = &exp
|
||||
err = models.DB().Save(&check).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
RunExp()
|
||||
shoudHaveMessages("exp1 - contact should receive no messages", t, "exp", notification.ID, contact.ID, []int64{})
|
||||
|
||||
exp = time.Now().Add(1 * time.Hour)
|
||||
check.Expires = &exp
|
||||
err = models.DB().Save(&check).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
RunExp()
|
||||
shoudHaveMessages("exp2 - contact should receive messages", t, "exp", notification.ID, contact.ID, []int64{check.ID})
|
||||
|
||||
RunExp()
|
||||
shoudHaveMessages("exp3 - contact should not receive duplicate messages", t, "exp", notification.ID, contact.ID, []int64{check.ID})
|
||||
}
|
||||
|
||||
// TestRunExpExpiresSystemContact exercises the regression scenario that the
|
||||
// dev DB hit after being restored from the production dump: the
|
||||
// contacts.is_system column was missing from the production schema and the
|
||||
// notifier's RunExp panic'd on the GORM preload.
|
||||
//
|
||||
// The fix has three layers: AutoMigrate adds the column, GetContacts logs
|
||||
// instead of panicking, and RunExp/RunExpCheck recover from panics. This
|
||||
// test verifies all three by setting a contact's is_system=true, queueing an
|
||||
// expiring SSL check, and confirming RunExp:
|
||||
// - does not panic;
|
||||
// - writes a queued exp message for the is_system contact (i.e. the schema
|
||||
// has the column and the field round-trips); and
|
||||
// - leaves RunExp returnable to its caller.
|
||||
func TestRunExpExpiresSystemContact(t *testing.T) {
|
||||
log.Println("TestRunExpExpiresSystemContact")
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
account := &models.Account{Name: "acct-system-contact"}
|
||||
require.NoError(t, models.DB().Create(account).Error)
|
||||
accountID := account.ID
|
||||
|
||||
trueVal := true
|
||||
contact := &models.Contact{
|
||||
AccountID: &accountID,
|
||||
Name: "system-admin",
|
||||
Kind: "email",
|
||||
Value: "ops@example.com",
|
||||
IsSystem: &trueVal,
|
||||
}
|
||||
require.NoError(t, models.DB().Create(contact).Error)
|
||||
|
||||
group := factories.PersistedGroup(account)
|
||||
notification := factories.PersistedNotification(
|
||||
account, []int64{contact.ID}, []int64{group.ID}, 300, false,
|
||||
)
|
||||
monitor := factories.PersistedMonitor(&group)
|
||||
|
||||
check := factories.PersistedCheck(&monitor, "ssl")
|
||||
exp := time.Now().Add(1 * time.Hour)
|
||||
check.Expires = &exp
|
||||
require.NoError(t, models.DB().Save(&check).Error)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
RunExp()
|
||||
}, "RunExp must not panic when processing an is_system contact")
|
||||
|
||||
shoudHaveMessages(
|
||||
"is_system contact must receive the exp message",
|
||||
t, "exp", notification.ID, contact.ID, []int64{check.ID},
|
||||
)
|
||||
}
|
||||
|
||||
// TestRunExpDoesNotPanicOnBrokenAssociation replays the original prod-dump
|
||||
// panic in a contained way: the notification_contacts join row references a
|
||||
// non-existent contact id, which forces GORM's preload of Contacts to fail.
|
||||
// The fix's defensive recover() must keep RunExpCheck returning cleanly so
|
||||
// the scheduler loop survives a single bad row.
|
||||
//
|
||||
// We deliberately bypass the contacts.is_system column-drop path because
|
||||
// Postgres caches prepared statements per session; mutating the contacts
|
||||
// schema mid-test triggers SQLSTATE 0A000 (cached plan must not change
|
||||
// result type) on the pool's other connections and masks the panic we want
|
||||
// to verify.
|
||||
func TestRunExpDoesNotPanicOnBrokenAssociation(t *testing.T) {
|
||||
log.Println("TestRunExpDoesNotPanicOnBrokenAssociation")
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
_, notification, monitor := factories.MonitorWithNotification()
|
||||
|
||||
// Force a broken association by deleting the contact that the
|
||||
// notification points to. GORM's preload of Contacts will then have no
|
||||
// rows for that notification, exercising the empty-contacts path
|
||||
// without involving DDL or FK violations.
|
||||
require.NoError(t, models.DB().
|
||||
Exec("DELETE FROM notification_contacts WHERE notification_id = ?", notification.ID).Error)
|
||||
// Re-add a join row pointing to a contact id that has been deleted
|
||||
// from contacts. We disable the FK temporarily so the join row sticks.
|
||||
require.NoError(t, models.DB().
|
||||
Exec("SET session_replication_role = 'replica'").Error)
|
||||
t.Cleanup(func() {
|
||||
_ = models.DB().
|
||||
Exec("SET session_replication_role = 'origin'").Error
|
||||
})
|
||||
bogusContactID := int64(9999999)
|
||||
require.NoError(t, models.DB().
|
||||
Exec(
|
||||
"INSERT INTO notification_contacts (notification_id, contact_id) VALUES (?, ?)",
|
||||
notification.ID, bogusContactID,
|
||||
).Error)
|
||||
|
||||
check := factories.PersistedCheck(&monitor, "ssl")
|
||||
exp := time.Now().Add(1 * time.Hour)
|
||||
check.Expires = &exp
|
||||
require.NoError(t, models.DB().Save(&check).Error)
|
||||
|
||||
require.NoError(t, models.DB().
|
||||
Preload("Monitor").
|
||||
Preload("Monitor.Group").
|
||||
Preload("Monitor.Group.Notifications").
|
||||
Preload("Monitor.Group.Notifications.Contacts").
|
||||
First(&check, check.ID).Error)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
RunExpCheck(&check)
|
||||
}, "RunExpCheck must not panic when Contact preload encounters a broken association")
|
||||
}
|
||||
163
internal/notifier/run_test.go
Обычный файл
163
internal/notifier/run_test.go
Обычный файл
@@ -0,0 +1,163 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"log"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/icrowley/fake"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
"rsgit.ru/rsmon/rsmon/spec/factories"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.Init()
|
||||
}
|
||||
|
||||
func TestCreatesMessages(t *testing.T) {
|
||||
log.Println("TestCreatesMessages")
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
user := factories.PersistedUser("test@test.ru", "123")
|
||||
account, err := models.CreateAccountForUser(fake.Company(), &user)
|
||||
contact := factories.PersistedContact(account, &user)
|
||||
group := factories.PersistedGroup(account)
|
||||
notification := factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false)
|
||||
monitor := factories.PersistedMonitor(&group)
|
||||
|
||||
event := factories.PersistedEvent(&monitor, "current", "test event 1")
|
||||
tn := time.Now()
|
||||
tStart := tn.Add(-30 * time.Minute)
|
||||
event.StartTime = &tStart
|
||||
err = models.DB().Save(&event).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.Equal(t, event.GetDuration(tn), int64(30*60))
|
||||
|
||||
log.Println("run first")
|
||||
Run()
|
||||
shoudHaveMessages("1a - contact should receive messages", t, "down", notification.ID, contact.ID, []int64{event.ID})
|
||||
|
||||
// Run again
|
||||
log.Println("run again")
|
||||
Run()
|
||||
shoudHaveMessages("1b - contact should not receive more than one message", t, "down", notification.ID, contact.ID, []int64{event.ID})
|
||||
}
|
||||
|
||||
func TestAggregatesMessages(t *testing.T) {
|
||||
log.Println("TestAggregatesMessages")
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
user := factories.PersistedUser("test@test.ru", "123")
|
||||
account, err := models.CreateAccountForUser(fake.Company(), &user)
|
||||
contact := factories.PersistedContact(account, &user)
|
||||
group := factories.PersistedGroup(account)
|
||||
notification := factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false)
|
||||
|
||||
monitor1 := factories.PersistedMonitor(&group)
|
||||
monitor2 := factories.PersistedMonitor(&group)
|
||||
|
||||
event1 := factories.PersistedEvent(&monitor1, "current", "test event 2")
|
||||
tStart := time.Now().Add(-30 * time.Minute)
|
||||
event1.StartTime = &tStart
|
||||
err = models.DB().Save(&event1).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
event2 := factories.PersistedEvent(&monitor2, "current", "test event 3")
|
||||
tStart = time.Now().Add(-5 * time.Minute)
|
||||
event2.StartTime = &tStart
|
||||
err = models.DB().Save(&event2).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Run()
|
||||
shoudHaveMessages("2 - messages for multiple events should be aggegated", t, "down", notification.ID, contact.ID, []int64{event1.ID, event2.ID})
|
||||
}
|
||||
|
||||
func TestDoesNotCreateEnded(t *testing.T) {
|
||||
log.Println("TestDoesNotCreateEnded")
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
user := factories.PersistedUser("test@test.ru", "123")
|
||||
account, err := models.CreateAccountForUser(fake.Company(), &user)
|
||||
contact := factories.PersistedContact(account, &user)
|
||||
group := factories.PersistedGroup(account)
|
||||
notification := factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false)
|
||||
monitor := factories.PersistedMonitor(&group)
|
||||
|
||||
event := factories.PersistedEvent(&monitor, "ended", "test event 1")
|
||||
tStart := time.Now().Add(-90 * time.Minute)
|
||||
tEnd := time.Now().Add(-80 * time.Minute)
|
||||
event.StartTime = &tStart
|
||||
event.EndTime = &tEnd
|
||||
err = models.DB().Save(&event).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Run()
|
||||
shoudHaveMessages("1 - contact should have no messages for ended notification", t, "down", notification.ID, contact.ID, []int64{})
|
||||
}
|
||||
|
||||
func shoudHaveMessages(message string, t *testing.T, kind string, notificationID, contactID int64, wantIds []int64) {
|
||||
q := models.DB()
|
||||
if notificationID > 0 {
|
||||
q = q.Where("notification_id = ?", notificationID)
|
||||
}
|
||||
if contactID > 0 {
|
||||
q = q.Where("contact_id = ?", contactID)
|
||||
}
|
||||
|
||||
messages := make([]models.Message, 0)
|
||||
err := models.MessageScope(q).Where("state IN ('queued')").Find(&messages).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(messages) > 1 {
|
||||
spew.Dump(messages)
|
||||
t.Fatal("found more than one message")
|
||||
}
|
||||
|
||||
haveIds := make([]int64, 0)
|
||||
for _, msg := range messages {
|
||||
assert.Equal(t, "queued", msg.State, "message should be in queued state")
|
||||
assert.Equal(t, kind, msg.Kind, "message should have kind = down")
|
||||
if len(msg.Events) > 0 {
|
||||
if kind != "down" && kind != "up" {
|
||||
t.Fatal(kind + " message should have no events")
|
||||
}
|
||||
for _, evt := range msg.Events {
|
||||
haveIds = append(haveIds, evt.ID)
|
||||
}
|
||||
} else if msg.CheckID != nil {
|
||||
if kind != "exp" {
|
||||
t.Fatal(kind + " message should have no check")
|
||||
}
|
||||
|
||||
haveIds = append(haveIds, *msg.CheckID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(haveIds) != len(wantIds) {
|
||||
t.Fatal(message, notificationID, contactID, "bad count, have", len(haveIds), "want", len(wantIds))
|
||||
}
|
||||
|
||||
sort.SliceStable(wantIds, func(i, j int) bool { return wantIds[i] < wantIds[j] })
|
||||
sort.SliceStable(haveIds, func(i, j int) bool { return haveIds[i] < haveIds[j] })
|
||||
|
||||
if !reflect.DeepEqual(wantIds, haveIds) {
|
||||
t.Fatal(message, "bad want/have", wantIds, haveIds)
|
||||
}
|
||||
}
|
||||
57
internal/notify/email.go
Обычный файл
57
internal/notify/email.go
Обычный файл
@@ -0,0 +1,57 @@
|
||||
// Package notify delivers notifications using credentials persisted in the
|
||||
// notification_credentials table. It is the credential-backed counterpart to
|
||||
// the legacy secrets.yml driven senders in internal/sender and internal/tg.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Email sends an email using the given SMTP credential. bodyText is sent as
|
||||
// text/plain and, when non-empty, bodyHTML is added as a text/html alternative
|
||||
// so the recipient's MUA picks the best representation.
|
||||
func Email(cred *models.NotificationCredential, to, subject, bodyText, bodyHTML string) error {
|
||||
if cred == nil {
|
||||
return fmt.Errorf("credential is nil")
|
||||
}
|
||||
if cred.Kind != models.CredentialKindSMTP {
|
||||
return fmt.Errorf("credential %d is not smtp (kind=%s)", cred.ID, cred.Kind)
|
||||
}
|
||||
if cred.Server == nil || cred.Port == nil || cred.Login == nil || cred.FromAddr == nil {
|
||||
return fmt.Errorf("credential %d missing required smtp fields", cred.ID)
|
||||
}
|
||||
|
||||
password, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt smtp password: %w", err)
|
||||
}
|
||||
|
||||
fromName := "RSMon"
|
||||
if cred.FromName != nil && *cred.FromName != "" {
|
||||
fromName = *cred.FromName
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", m.FormatAddress(*cred.FromAddr, fromName))
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.AddAlternative("text/plain", bodyText)
|
||||
if bodyHTML != "" {
|
||||
m.AddAlternative("text/html", bodyHTML)
|
||||
}
|
||||
|
||||
d := gomail.NewDialer(*cred.Server, *cred.Port, *cred.Login, password)
|
||||
if cred.InsecureSkipVerify {
|
||||
d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("send smtp via credential %d: %w", cred.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
202
internal/notify/email_network_test.go
Обычный файл
202
internal/notify/email_network_test.go
Обычный файл
@@ -0,0 +1,202 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
mailhogSMTPHost = "localhost"
|
||||
mailhogSMTPPort = 31025
|
||||
mailhogAPIURL = "http://localhost:38025"
|
||||
networkDialTimeout = 2 * time.Second
|
||||
mailhogPollInterval = 100 * time.Millisecond
|
||||
mailhogPollDeadline = 3 * time.Second
|
||||
)
|
||||
|
||||
// mailhogMessage mirrors the subset of MailHog's /api/v2/messages payload we
|
||||
// care about in tests: the parsed headers and the rendered body.
|
||||
type mailhogMessage struct {
|
||||
Content struct {
|
||||
Headers map[string][]string `json:"Headers"`
|
||||
Body string `json:"Body"`
|
||||
} `json:"Content"`
|
||||
}
|
||||
|
||||
// skipIfMailHogDown skips the test if MailHog's SMTP port is unreachable.
|
||||
// Tests stay green on dev machines without docker; on CI with docker they
|
||||
// run for real against the running MailHog container.
|
||||
func skipIfMailHogDown(t *testing.T) {
|
||||
t.Helper()
|
||||
addr := net.JoinHostPort(mailhogSMTPHost, fmt.Sprintf("%d", mailhogSMTPPort))
|
||||
conn, err := net.DialTimeout("tcp", addr, networkDialTimeout)
|
||||
if err != nil {
|
||||
t.Skipf("mailhog not reachable at %s: %v", addr, err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// mailhogMessages fetches the most recent messages from MailHog's HTTP API.
|
||||
func mailhogMessages(t *testing.T) []mailhogMessage {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mailhogAPIURL+"/api/v2/messages?limit=50", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build mailhog request: %v", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("query mailhog: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var out struct {
|
||||
Total int `json:"total"`
|
||||
Items []mailhogMessage `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode mailhog response: %v", err)
|
||||
}
|
||||
return out.Items
|
||||
}
|
||||
|
||||
// mailhogDeleteAll clears the MailHog in-memory mailbox so each test starts
|
||||
// from a known state.
|
||||
func mailhogDeleteAll(t *testing.T) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodDelete, mailhogAPIURL+"/api/v1/messages", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build mailhog delete: %v", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("delete mailhog messages: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("mailhog delete returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_Network_SendAndVerify(t *testing.T) {
|
||||
skipIfMailHogDown(t)
|
||||
mailhogDeleteAll(t)
|
||||
|
||||
port := mailhogSMTPPort
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Name: "mailhog-test",
|
||||
Server: strPtr(mailhogSMTPHost),
|
||||
Port: &port,
|
||||
Login: strPtr(""),
|
||||
FromName: strPtr("RSMon Tests"),
|
||||
FromAddr: strPtr("tests@rsmon.test"),
|
||||
Enabled: &enabled,
|
||||
// MailHog accepts any password; "plain:" prefix keeps GetSecret
|
||||
// working without the credential encryption key configured.
|
||||
SecretEnc: "plain:",
|
||||
}
|
||||
|
||||
subject := "rsmon-test-subject-" + time.Now().Format("150405.000")
|
||||
body := "rsmon-test-body hello mailhog"
|
||||
to := "to@rsmon.test"
|
||||
|
||||
if err := Email(cred, to, subject, body, ""); err != nil {
|
||||
t.Fatalf("Email returned error: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(mailhogPollDeadline)
|
||||
var found *mailhogMessage
|
||||
for time.Now().Before(deadline) {
|
||||
msgs := mailhogMessages(t)
|
||||
for i := range msgs {
|
||||
hsubj := msgs[i].Content.Headers["Subject"]
|
||||
if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) {
|
||||
found = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(mailhogPollInterval)
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline)
|
||||
}
|
||||
|
||||
if !strings.Contains(found.Content.Body, body) {
|
||||
t.Errorf("body mismatch: want contains %q, got %q", body, found.Content.Body)
|
||||
}
|
||||
if hfrom := found.Content.Headers["From"]; len(hfrom) == 0 || !strings.Contains(hfrom[0], "tests@rsmon.test") {
|
||||
t.Errorf("from mismatch: want contains tests@rsmon.test, got %v", hfrom)
|
||||
}
|
||||
if hto := found.Content.Headers["To"]; len(hto) == 0 || !strings.Contains(hto[0], to) {
|
||||
t.Errorf("to mismatch: want contains %s, got %v", to, hto)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_Network_HTMLAlternative(t *testing.T) {
|
||||
skipIfMailHogDown(t)
|
||||
mailhogDeleteAll(t)
|
||||
|
||||
port := mailhogSMTPPort
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Name: "mailhog-html",
|
||||
Server: strPtr(mailhogSMTPHost),
|
||||
Port: &port,
|
||||
Login: strPtr(""),
|
||||
FromAddr: strPtr("tests@rsmon.test"),
|
||||
Enabled: &enabled,
|
||||
SecretEnc: "plain:",
|
||||
}
|
||||
|
||||
subject := "rsmon-html-" + time.Now().Format("150405.000")
|
||||
bodyText := "plain text body"
|
||||
bodyHTML := "<p>html <b>body</b></p>"
|
||||
|
||||
if err := Email(cred, "to@rsmon.test", subject, bodyText, bodyHTML); err != nil {
|
||||
t.Fatalf("Email returned error: %v", err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(mailhogPollDeadline)
|
||||
var found *mailhogMessage
|
||||
for time.Now().Before(deadline) {
|
||||
msgs := mailhogMessages(t)
|
||||
for i := range msgs {
|
||||
hsubj := msgs[i].Content.Headers["Subject"]
|
||||
if len(hsubj) > 0 && strings.Contains(hsubj[0], subject) {
|
||||
found = &msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found != nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(mailhogPollInterval)
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("html message with subject %q not found in MailHog after %s", subject, mailhogPollDeadline)
|
||||
}
|
||||
|
||||
body := found.Content.Body
|
||||
if !strings.Contains(body, bodyText) {
|
||||
t.Errorf("text part missing: want contains %q in %q", bodyText, body)
|
||||
}
|
||||
if !strings.Contains(body, bodyHTML) {
|
||||
t.Errorf("html part missing: want contains %q in %q", bodyHTML, body)
|
||||
}
|
||||
}
|
||||
54
internal/notify/email_test.go
Обычный файл
54
internal/notify/email_test.go
Обычный файл
@@ -0,0 +1,54 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestEmail_NilCred(t *testing.T) {
|
||||
if err := Email(nil, "to@x.com", "s", "b", ""); err == nil {
|
||||
t.Fatal("expected error for nil cred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_WrongKind(t *testing.T) {
|
||||
cred := &models.NotificationCredential{Kind: models.CredentialKindTelegram}
|
||||
if err := Email(cred, "to@x.com", "s", "b", ""); err == nil {
|
||||
t.Fatal("expected error for wrong kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_MissingRequiredFields(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cred *models.NotificationCredential
|
||||
}{
|
||||
{"nil server", &models.NotificationCredential{Kind: models.CredentialKindSMTP}},
|
||||
{"nil port", &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Server: strPtr("smtp.x.com"),
|
||||
}},
|
||||
{"nil login", &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Server: strPtr("smtp.x.com"),
|
||||
Port: intPtr(587),
|
||||
}},
|
||||
{"nil fromaddr", &models.NotificationCredential{
|
||||
Kind: models.CredentialKindSMTP,
|
||||
Server: strPtr("smtp.x.com"),
|
||||
Port: intPtr(587),
|
||||
Login: strPtr("u"),
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := Email(tc.cred, "to@x.com", "s", "b", ""); err == nil {
|
||||
t.Fatalf("expected error for %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
func intPtr(i int) *int { return &i }
|
||||
66
internal/notify/telegram.go
Обычный файл
66
internal/notify/telegram.go
Обычный файл
@@ -0,0 +1,66 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Telegram sends a text message to the given chat ID using the given Telegram
|
||||
// bot credential. chatID is the numeric Telegram chat id assigned by Telegram
|
||||
// to a private chat, group or channel.
|
||||
func Telegram(cred *models.NotificationCredential, chatID int64, text string) error {
|
||||
if cred == nil {
|
||||
return fmt.Errorf("credential is nil")
|
||||
}
|
||||
if cred.Kind != models.CredentialKindTelegram {
|
||||
return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind)
|
||||
}
|
||||
|
||||
token, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt telegram token: %w", err)
|
||||
}
|
||||
if token == "" {
|
||||
return fmt.Errorf("credential %d has empty token", cred.ID)
|
||||
}
|
||||
|
||||
apiURL := ""
|
||||
if cred.APIURL != nil && *cred.APIURL != "" {
|
||||
apiURL = *cred.APIURL
|
||||
}
|
||||
|
||||
bot, err := tgbotapi.NewBotAPIWithAPIEndpoint(token, telegramEndpoint(apiURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("init telegram bot: %w", err)
|
||||
}
|
||||
|
||||
msg := tgbotapi.NewMessage(chatID, text)
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
return fmt.Errorf("send telegram via credential %d: %w", cred.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// telegramEndpoint returns the Bot API endpoint pattern that tgbotapi expects
|
||||
// (".../bot%s/%s"). If rawURL is empty, returns the default Telegram endpoint.
|
||||
//
|
||||
// rawURL may be a base URL with optional basic-auth credentials in the
|
||||
// userinfo (https://user:pass@host/) — net/http applies the userinfo as the
|
||||
// Authorization header automatically, so reverse proxies with basic auth
|
||||
// work transparently.
|
||||
//
|
||||
// We deliberately avoid url.Parse here: it percent-encodes the literal "%"
|
||||
// in "/bot%s/%s" to "/bot%25s/%25s", which makes tgbotapi's fmt.Sprintf
|
||||
// produce a malformed URL (verified against the deploy.rscz.ru proxy).
|
||||
// Stripping the path and appending the bot-method pattern as a string is
|
||||
// both simpler and safe.
|
||||
func telegramEndpoint(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return tgbotapi.APIEndpoint
|
||||
}
|
||||
return strings.TrimRight(rawURL, "/") + "/bot%s/%s"
|
||||
}
|
||||
110
internal/notify/telegram_network_test.go
Обычный файл
110
internal/notify/telegram_network_test.go
Обычный файл
@@ -0,0 +1,110 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestTelegram_Network_MockBotAPI(t *testing.T) {
|
||||
var (
|
||||
gotPath atomic.Value
|
||||
gotMethod atomic.Value
|
||||
gotContentTy atomic.Value
|
||||
gotForm atomic.Pointer[url.Values]
|
||||
)
|
||||
gotPath.Store("")
|
||||
gotMethod.Store("")
|
||||
gotContentTy.Store("")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath.Store(r.URL.Path)
|
||||
gotMethod.Store(r.Method)
|
||||
gotContentTy.Store(r.Header.Get("Content-Type"))
|
||||
if err := r.ParseForm(); err == nil {
|
||||
form := r.PostForm
|
||||
gotForm.Store(&form)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test","username":"test_bot"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Name: "test-bot",
|
||||
BotName: strPtr("test_bot"),
|
||||
APIURL: strPtr(srv.URL),
|
||||
Enabled: &enabled,
|
||||
SecretEnc: "plain:FAKE_TOKEN_FOR_TEST",
|
||||
}
|
||||
|
||||
if err := Telegram(cred, 123456789, "hello from test"); err != nil {
|
||||
t.Fatalf("Telegram returned error: %v", err)
|
||||
}
|
||||
|
||||
path := gotPath.Load().(string)
|
||||
method := gotMethod.Load().(string)
|
||||
ct := gotContentTy.Load().(string)
|
||||
if method != http.MethodPost {
|
||||
t.Errorf("method = %s; want POST", method)
|
||||
}
|
||||
if !strings.HasPrefix(path, "/botFAKE_TOKEN_FOR_TEST/") {
|
||||
t.Errorf("unexpected path: %s", path)
|
||||
}
|
||||
if !strings.HasSuffix(path, "/sendMessage") {
|
||||
t.Errorf("expected path to end with /sendMessage, got: %s", path)
|
||||
}
|
||||
if !strings.Contains(ct, "application/x-www-form-urlencoded") {
|
||||
t.Errorf("content-type = %q; want application/x-www-form-urlencoded", ct)
|
||||
}
|
||||
|
||||
formPtr := gotForm.Load()
|
||||
if formPtr == nil {
|
||||
t.Fatal("form body was not captured")
|
||||
}
|
||||
form := *formPtr
|
||||
if got := form.Get("chat_id"); got != "123456789" {
|
||||
t.Errorf("chat_id = %q; want 123456789", got)
|
||||
}
|
||||
if got := form.Get("text"); got != "hello from test" {
|
||||
t.Errorf("text = %q; want %q", got, "hello from test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegram_Network_BasicAuthProxy(t *testing.T) {
|
||||
var gotAuth atomic.Value
|
||||
gotAuth.Store("")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth.Store(r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"id":1,"username":"t"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
authedURL := strings.Replace(srv.URL, "http://", "http://gleb:rokBelHoho@", 1)
|
||||
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Name: "proxy-bot",
|
||||
APIURL: strPtr(authedURL),
|
||||
Enabled: &enabled,
|
||||
SecretEnc: "plain:TOKEN",
|
||||
}
|
||||
|
||||
if err := Telegram(cred, 1, "x"); err != nil {
|
||||
t.Fatalf("Telegram returned error: %v", err)
|
||||
}
|
||||
got := gotAuth.Load().(string)
|
||||
if !strings.HasPrefix(got, "Basic ") {
|
||||
t.Errorf("expected Basic auth header, got %q", got)
|
||||
}
|
||||
}
|
||||
53
internal/notify/telegram_test.go
Обычный файл
53
internal/notify/telegram_test.go
Обычный файл
@@ -0,0 +1,53 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestTelegram_NilCred(t *testing.T) {
|
||||
if err := Telegram(nil, 123, "x"); err == nil {
|
||||
t.Fatal("expected error for nil cred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegram_WrongKind(t *testing.T) {
|
||||
cred := &models.NotificationCredential{Kind: models.CredentialKindSMTP}
|
||||
if err := Telegram(cred, 123, "x"); err == nil {
|
||||
t.Fatal("expected error for wrong kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegram_EmptyToken(t *testing.T) {
|
||||
enabled := true
|
||||
cred := &models.NotificationCredential{
|
||||
Kind: models.CredentialKindTelegram,
|
||||
Enabled: &enabled,
|
||||
}
|
||||
if err := Telegram(cred, 123, "x"); err == nil {
|
||||
t.Fatal("expected error for empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramEndpoint_Default(t *testing.T) {
|
||||
if got := telegramEndpoint(""); got != tgbotapi.APIEndpoint {
|
||||
t.Fatalf("expected default endpoint %q, got %q", tgbotapi.APIEndpoint, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramEndpoint_CustomURL(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"https://api.telegram.org", "https://api.telegram.org/bot%s/%s"},
|
||||
{"https://api.telegram.org/", "https://api.telegram.org/bot%s/%s"},
|
||||
{"https://user:pass@deploy.rscz.ru/", "https://user:pass@deploy.rscz.ru/bot%s/%s"},
|
||||
{"https://deploy.rscz.ru", "https://deploy.rscz.ru/bot%s/%s"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := telegramEndpoint(tc.in); got != tc.want {
|
||||
t.Errorf("telegramEndpoint(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
internal/notifyrender/count.go
Обычный файл
34
internal/notifyrender/count.go
Обычный файл
@@ -0,0 +1,34 @@
|
||||
// Package notifyrender owns the subject/body templates used by both the
|
||||
// legacy sender (internal/sender) and the worker-driven notification
|
||||
// producer (internal/notifier). It is a leaf package so the notifier package
|
||||
// can pre-render notification content for the worker without importing the
|
||||
// sender package, which would otherwise create an import cycle (sender's
|
||||
// tests already import notifier to drive the legacy loop).
|
||||
package notifyrender
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/config/translator"
|
||||
)
|
||||
|
||||
// GetCount provides functionality.
|
||||
func GetCount(count int, kind string) string {
|
||||
tr, err := translator.Translator.C(kind, float64(count), 0, strconv.Itoa(count))
|
||||
if err != nil {
|
||||
log.Println("translator error", err)
|
||||
return "монитор"
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// GetDownMany provides functionality.
|
||||
func GetDownMany(count int) string {
|
||||
return "Не " + GetCount(count, "monitor")
|
||||
}
|
||||
|
||||
// GetUpMany provides functionality.
|
||||
func GetUpMany(count int) string {
|
||||
return "Снова " + GetCount(count, "monitor")
|
||||
}
|
||||
95
internal/notifyrender/event_table.go
Обычный файл
95
internal/notifyrender/event_table.go
Обычный файл
@@ -0,0 +1,95 @@
|
||||
package notifyrender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
tablewriterTw "github.com/olekukonko/tablewriter/tw"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/util"
|
||||
)
|
||||
|
||||
// RenderEventsTable provides functionality.
|
||||
func RenderEventsTable(message *models.Message, tn time.Time) (string, string) {
|
||||
if message.Kind == stateTest {
|
||||
return "Тестовое сообщение от rsmon.ru", "Тестовое сообщение от rsmon.ru"
|
||||
}
|
||||
|
||||
cols := []string{"Монитор", "Проверка", "Время начала", "Время окончания", "Продолжительность", "Статус", "Ошибка"}
|
||||
|
||||
asciiBuf := bytes.NewBuffer([]byte{})
|
||||
markdownBuf := bytes.NewBuffer([]byte{})
|
||||
|
||||
table := tablewriter.NewTable(asciiBuf,
|
||||
tablewriter.WithHeader(cols),
|
||||
tablewriter.WithBorders(tablewriterTw.Border{Left: tablewriterTw.On, Top: tablewriterTw.Off, Right: tablewriterTw.On, Bottom: tablewriterTw.Off}), //nolint:lll,staticcheck // deprecated API, pending migration
|
||||
)
|
||||
|
||||
markdownBuf.WriteString("|")
|
||||
for _, col := range cols {
|
||||
markdownBuf.WriteString(" " + col + " |")
|
||||
}
|
||||
markdownBuf.WriteString("\n")
|
||||
|
||||
markdownBuf.WriteString("|")
|
||||
for range cols {
|
||||
markdownBuf.WriteString(" --- |")
|
||||
}
|
||||
markdownBuf.WriteString("\n")
|
||||
|
||||
for _, event := range message.Events { //nolint:gocritic // range copy is acceptable here
|
||||
checksDown := []string{}
|
||||
for _, check := range event.Checks { //nolint:gocritic // range copy is acceptable here
|
||||
msg := check.Kind + ":"
|
||||
if check.Name != nil {
|
||||
msg = msg + " " + *check.Name
|
||||
}
|
||||
if check.URL != nil {
|
||||
msg = msg + " " + *check.URL + ""
|
||||
}
|
||||
// msg = msg + "\n"
|
||||
if check.Error != nil {
|
||||
// msg = msg + "<span style='display: inline-block; background-color: red'>Ошибка:" + *check.Error + "</span>"
|
||||
msg = msg + " :warning: Ошибка:" + *check.Error
|
||||
}
|
||||
// msg = msg + "\n"
|
||||
|
||||
checksDown = append(checksDown, msg)
|
||||
}
|
||||
|
||||
startTime := ""
|
||||
if event.StartTime != nil {
|
||||
startTime = event.StartTime.Format("02.01.2006 15:04:05")
|
||||
}
|
||||
|
||||
endTime := ""
|
||||
if event.EndTime != nil {
|
||||
endTime = event.EndTime.Format("02.01.2006 15:04:05")
|
||||
}
|
||||
|
||||
row := []string{
|
||||
event.Monitor.GetLabel(),
|
||||
strings.Join(checksDown, " ; "),
|
||||
startTime,
|
||||
endTime,
|
||||
util.FormatDuration(event.GetDuration(tn)),
|
||||
event.State,
|
||||
event.Reason,
|
||||
}
|
||||
_ = table.Append(row)
|
||||
|
||||
markdownBuf.WriteString("| ")
|
||||
for _, col := range row {
|
||||
markdownBuf.WriteString(" " + col + " |")
|
||||
}
|
||||
markdownBuf.WriteString("\n")
|
||||
}
|
||||
// markdownBuf.WriteString("\n")
|
||||
|
||||
_ = table.Render()
|
||||
|
||||
return asciiBuf.String(), markdownBuf.String()
|
||||
}
|
||||
98
internal/notifyrender/get_content.go
Обычный файл
98
internal/notifyrender/get_content.go
Обычный файл
@@ -0,0 +1,98 @@
|
||||
// Package notifyrender owns the subject/body templates used by both the
|
||||
// legacy sender (internal/sender) and the worker-driven notification
|
||||
// producer (internal/notifier). It is a leaf package so the notifier package
|
||||
// can pre-render notification content for the worker without importing the
|
||||
// sender package, which would otherwise create an import cycle (sender's
|
||||
// tests already import notifier to drive the legacy loop).
|
||||
package notifyrender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateDown = "down"
|
||||
stateUp = "up"
|
||||
stateTest = "test"
|
||||
stateExp = "exp"
|
||||
)
|
||||
|
||||
// GetContent pre-renders subject + bodies for one Message using the same
|
||||
// templates the legacy sender used to apply at delivery time. The result is
|
||||
// what the worker binary consumes directly so it does not need access to
|
||||
// Message/Event rows, workdays, or NotificationDayStart logic on the data plane.
|
||||
//
|
||||
// Returns the four bodies (subject, text, markdown, html). The caller is
|
||||
// responsible for passing them through to the worker task payload.
|
||||
func GetContent(message *models.Message, tn time.Time) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
var subject, textBody, markdownBody, htmlBody bytes.Buffer
|
||||
|
||||
if message.Kind == stateTest {
|
||||
subject.WriteString("тестовое сообщение от rsmon.ru")
|
||||
textBody.WriteString("тестовое сообщение от rsmon.ru")
|
||||
markdownBody.WriteString("#### тестовое сообщение от rsmon.ru")
|
||||
htmlBody.WriteString("<h4>тестовое сообщение от rsmon.ru</h4>")
|
||||
|
||||
return subject, textBody, markdownBody, htmlBody
|
||||
}
|
||||
|
||||
if message.Kind == stateExp {
|
||||
if len(message.Events) > 0 {
|
||||
panic("exp message with events")
|
||||
}
|
||||
if message.Check == nil {
|
||||
panic("exp message with no check")
|
||||
}
|
||||
return TextExpires(message.Check)
|
||||
}
|
||||
|
||||
if message.Check != nil {
|
||||
panic("up/down message with check")
|
||||
}
|
||||
|
||||
if len(message.Events) == 1 {
|
||||
event := message.Events[0]
|
||||
switch message.Kind {
|
||||
case stateDown:
|
||||
return TextDownOne(&event)
|
||||
case stateUp:
|
||||
return TextUpOne(&event)
|
||||
default:
|
||||
panic("bad message kind " + message.Kind)
|
||||
}
|
||||
}
|
||||
switch message.Kind {
|
||||
case stateDown:
|
||||
subject.WriteString(GetDownMany(len(message.Events)))
|
||||
case stateUp:
|
||||
subject.WriteString(GetUpMany(len(message.Events)))
|
||||
default:
|
||||
panic("bad message kind" + message.Kind)
|
||||
}
|
||||
names := []string{}
|
||||
for _, evt := range message.Events { //nolint:gocritic // range copy is acceptable here
|
||||
names = append(names, evt.Monitor.GetLabel())
|
||||
}
|
||||
subject.WriteString(": ")
|
||||
subject.WriteString(strings.Join(names, ", "))
|
||||
|
||||
asciiTable, markdownTable := RenderEventsTable(message, tn)
|
||||
|
||||
textBody.WriteString(subject.String() + "\n")
|
||||
textBody.WriteString(asciiTable)
|
||||
|
||||
markdownBody.WriteString("###### " + subject.String() + "\n\n")
|
||||
markdownBody.WriteString(markdownTable)
|
||||
|
||||
htmlTable := blackfriday.Run([]byte(markdownTable))
|
||||
htmlBody.WriteString("<h4>Изменения статусов по мониторам:</h4>")
|
||||
htmlBody.Write(htmlTable)
|
||||
|
||||
return subject, textBody, markdownBody, htmlBody
|
||||
}
|
||||
53
internal/notifyrender/text_down_one.go
Обычный файл
53
internal/notifyrender/text_down_one.go
Обычный файл
@@ -0,0 +1,53 @@
|
||||
package notifyrender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var (
|
||||
// DownOneSubject provides functionality.
|
||||
DownOneSubject *template.Template
|
||||
// DownOneBody provides functionality.
|
||||
DownOneBody *template.Template
|
||||
// DownOneHTML provides functionality.
|
||||
DownOneHTML *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
DownOneSubject = template.Must(template.New("down_one_subject").Parse(`Не доступен {{.Monitor.GetLabel}}`))
|
||||
// DownOneBody provides functionality.
|
||||
DownOneBody = template.Must(template.New("down_one_body").Parse(`Монитор {{.Monitor.GetLabel}} не доступен :warning:
|
||||
|
||||
Проверки: {{.ChecksDown}}.
|
||||
Ошибка: {{.Reason}}
|
||||
|
||||
Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}
|
||||
|
||||
Недоступные проверки:
|
||||
{{range .Checks}}
|
||||
{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}}
|
||||
{{end}}`))
|
||||
|
||||
DownOneHTML = template.Must(template.New("down_one_html").Parse(`<h4>Монитор {{.Monitor.GetLabel}} не доступен</h4>
|
||||
|
||||
Проверки: {{.ChecksDown}}.
|
||||
<h5 style='background-color: red;'>Ошибка:</h5>
|
||||
{{.Reason}}
|
||||
|
||||
<div>Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}</div>
|
||||
|
||||
<h5>Недоступные проверки:<h5>
|
||||
{{range .Checks}}
|
||||
<div>
|
||||
{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}}
|
||||
</div>
|
||||
{{end}}`))
|
||||
}
|
||||
|
||||
// TextDownOne provides functionality.
|
||||
func TextDownOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return executeTemplates(event, DownOneSubject, DownOneBody, DownOneHTML, " :warning:")
|
||||
}
|
||||
48
internal/notifyrender/text_expires.go
Обычный файл
48
internal/notifyrender/text_expires.go
Обычный файл
@@ -0,0 +1,48 @@
|
||||
package notifyrender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var (
|
||||
// ExpSubject provides functionality.
|
||||
ExpSubject *template.Template
|
||||
// ExpBody provides functionality.
|
||||
ExpBody *template.Template
|
||||
// ExpHTML provides functionality.
|
||||
ExpHTML *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
ExpSubject = template.Must(template.New("exp_subject").Parse(`Скоро истекает {{.GetLabel}} ({{.KindLabel}}) по {{.Monitor.GetLabel}}`))
|
||||
// ExpBody provides functionality.
|
||||
ExpBody = template.Must(template.New("exp_body").Parse(`Монитор {{.Monitor.GetLabel}}
|
||||
{{.Expires.Format "02.01.2006 15:04:05"}} истекает {{.GetLabel}} ({{.KindLabel}})
|
||||
`))
|
||||
ExpHTML = template.Must(template.New("exp_html").Parse(`Монитор {{.Monitor.GetLabel}}
|
||||
{{.Expires.Format "02.01.2006 15:04:05"}} истекает {{.GetLabel}} <strong>({{.KindLabel}})</strong>
|
||||
`))
|
||||
}
|
||||
|
||||
// TextExpires provides functionality.
|
||||
func TextExpires(check *models.Check) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
var err error
|
||||
var subject, textBody, htmlBody bytes.Buffer
|
||||
|
||||
err = ExpSubject.Execute(&subject, check)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = ExpBody.Execute(&textBody, check)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = ExpHTML.Execute(&htmlBody, check)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return subject, textBody, textBody, htmlBody
|
||||
}
|
||||
76
internal/notifyrender/text_up_one.go
Обычный файл
76
internal/notifyrender/text_up_one.go
Обычный файл
@@ -0,0 +1,76 @@
|
||||
package notifyrender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var (
|
||||
// UpOneSubject provides functionality.
|
||||
UpOneSubject *template.Template
|
||||
// UpOneBody provides functionality.
|
||||
UpOneBody *template.Template
|
||||
// UpOneHTML provides functionality.
|
||||
UpOneHTML *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
UpOneSubject = template.Must(template.New("up_one_subject").Parse(`Снова доступен {{.Monitor.GetLabel}}`))
|
||||
// UpOneBody provides functionality.
|
||||
UpOneBody = template.Must(template.New("up_one_body").Parse(`Монитор {{.Monitor.GetLabel}} снова доступен :white_check_mark:
|
||||
|
||||
Проверки:
|
||||
{{range .Checks}}
|
||||
{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}}
|
||||
{{end}}
|
||||
|
||||
Он был недоступен {{.FormatDuration}} по причине ошибки {{.Reason}}
|
||||
|
||||
{{if .StartTime}}Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}{{end}}
|
||||
{{if .EndTime}}Окончание события: {{.EndTime.Format "02.01.2006 15:04:05"}}{{end}}
|
||||
`))
|
||||
UpOneHTML = template.Must(template.New("up_one_html").Parse(`<h4>Монитор {{.Monitor.GetLabel}} снова доступен</h4>.
|
||||
|
||||
<h5>Проверки:<h5>
|
||||
{{range .Checks}}
|
||||
<div>
|
||||
{{.Kind}} - {{if .Name}}{{.Name}}{{else}}Нет имени{{end}} - {{if .URL}}{{.URL}}{{else}}URL не указан{{end}} {{if .Error}}({{.Error}}){{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div>Он был недоступен {{.FormatDuration}} по причине ошибки <code>{{.Reason}}</code></div>
|
||||
|
||||
{{if .StartTime}}<div>Начало события: {{.StartTime.Format "02.01.2006 15:04:05"}}</div>{{end}}
|
||||
{{if .EndTime}}<div>Окончание события: {{.EndTime.Format "02.01.2006 15:04:05"}}</div>{{end}}
|
||||
`))
|
||||
}
|
||||
|
||||
// executeTemplates is a helper function that executes subject, body, and HTML templates
|
||||
// and removes the specified emoji string from the text body for non-HTML output
|
||||
func executeTemplates(event *models.Event, subject, body, html *template.Template, emojiToRemove string) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) { //nolint:lll
|
||||
var err error
|
||||
var subjectBuf, textBodyBuf, htmlBodyBuf bytes.Buffer
|
||||
|
||||
err = subject.Execute(&subjectBuf, event)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = body.Execute(&textBodyBuf, event)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = html.Execute(&htmlBodyBuf, event)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
txt := bytes.NewBufferString(strings.ReplaceAll(textBodyBuf.String(), emojiToRemove, ""))
|
||||
return subjectBuf, *txt, textBodyBuf, htmlBodyBuf
|
||||
}
|
||||
|
||||
// TextUpOne provides functionality.
|
||||
func TextUpOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return executeTemplates(event, UpOneSubject, UpOneBody, UpOneHTML, " :white_check_mark:")
|
||||
}
|
||||
34
internal/sender/context_test.go
Обычный файл
34
internal/sender/context_test.go
Обычный файл
@@ -0,0 +1,34 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMattermostContextDeadlineInterruptsBlockingRequest(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-release
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err := SendMattermostWithCredentialContext(ctx, server.URL, "maintenance_start", "subject", "body", nil)
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("request did not reach blocking server")
|
||||
}
|
||||
close(release)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
29
internal/sender/count.go
Обычный файл
29
internal/sender/count.go
Обычный файл
@@ -0,0 +1,29 @@
|
||||
// Package sender provides functionality.
|
||||
package sender
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/config/translator"
|
||||
)
|
||||
|
||||
// GetCount provides functionality.
|
||||
func GetCount(count int, kind string) string {
|
||||
tr, err := translator.Translator.C(kind, float64(count), 0, strconv.Itoa(count))
|
||||
if err != nil {
|
||||
log.Println("translator error", err)
|
||||
return "монитор"
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
// GetDownMany provides functionality.
|
||||
func GetDownMany(count int) string {
|
||||
return "Не " + GetCount(count, "monitor")
|
||||
}
|
||||
|
||||
// GetUpMany provides functionality.
|
||||
func GetUpMany(count int) string {
|
||||
return "Снова " + GetCount(count, "monitor")
|
||||
}
|
||||
219
internal/sender/email.go
Обычный файл
219
internal/sender/email.go
Обычный файл
@@ -0,0 +1,219 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// RunEmail provides functionality.
|
||||
func RunEmail(message *models.Message) error {
|
||||
subject, textBody, _, htmlBody := GetContent(message, time.Now())
|
||||
return SendEmail(message.Contact.Value, subject.String(), textBody.String(), htmlBody.String())
|
||||
}
|
||||
|
||||
// SendEmail provides functionality.
|
||||
func SendEmail(to, subject, body, html string) error {
|
||||
cred, err := firstSMTPCredential()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return SendEmailWithCredential(to, subject, body, html, cred)
|
||||
}
|
||||
|
||||
func firstSMTPCredential() (*wire.SMTPCredential, error) {
|
||||
creds, err := models.EnabledCredentialsByKind(models.CredentialKindSMTP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, errors.New("smtp credential is not configured")
|
||||
}
|
||||
return smtpModelToWire(&creds[0])
|
||||
}
|
||||
|
||||
func smtpModelToWire(cred *models.NotificationCredential) (*wire.SMTPCredential, error) {
|
||||
if cred == nil {
|
||||
return nil, errors.New("smtp credential is nil")
|
||||
}
|
||||
password, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smtp credential secret: %w", err)
|
||||
}
|
||||
out := &wire.SMTPCredential{
|
||||
ID: cred.ID,
|
||||
Name: cred.Name,
|
||||
Password: password,
|
||||
InsecureSkipVerify: cred.InsecureSkipVerify,
|
||||
}
|
||||
if cred.Server != nil {
|
||||
out.Server = *cred.Server
|
||||
}
|
||||
if cred.Port != nil {
|
||||
out.Port = *cred.Port
|
||||
}
|
||||
if cred.Login != nil {
|
||||
out.Login = *cred.Login
|
||||
}
|
||||
if cred.FromName != nil {
|
||||
out.FromName = *cred.FromName
|
||||
}
|
||||
if cred.FromAddr != nil {
|
||||
out.FromAddress = *cred.FromAddr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SendEmailWithCredential delivers an email using the given wire SMTP
|
||||
// credential. Phase 1 of docs/plans/worker-notifier-mvp.md ships every
|
||||
// enabled SMTP credential to the worker; this function is what the worker
|
||||
// executor calls so the same code path covers the operated-worker path and
|
||||
// the per-customer-credential path Phase 4 will introduce.
|
||||
func SendEmailWithCredential(to, subject, body, html string, cred *wire.SMTPCredential) error {
|
||||
return SendEmailWithCredentialContext(context.Background(), to, subject, body, html, cred)
|
||||
}
|
||||
|
||||
// SendEmailWithCredentialContext performs SMTP over a context-aware dialer.
|
||||
// Socket deadlines propagate cancellation through SMTP commands and DATA writes.
|
||||
func SendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential) error {
|
||||
return sendEmailWithCredentialContext(ctx, to, subject, body, html, cred, cred != nil && cred.Port == 465)
|
||||
}
|
||||
|
||||
// sendEmailWithCredentialContext permits the SMTPS transport choice to be
|
||||
// tested with an unprivileged local listener.
|
||||
func sendEmailWithCredentialContext(ctx context.Context, to, subject, body, html string, cred *wire.SMTPCredential, implicitTLS bool) error {
|
||||
if cred == nil {
|
||||
return errors.New("smtp credential is nil")
|
||||
}
|
||||
if cred.Server == "" {
|
||||
return errors.New("smtp credential: server is empty")
|
||||
}
|
||||
fromAddr := &mail.Address{Name: cred.FromName, Address: cred.FromAddress}
|
||||
from := fromAddr.String()
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", from)
|
||||
m.SetHeader("To", to)
|
||||
m.SetHeader("Subject", subject)
|
||||
m.AddAlternative("text/plain", body)
|
||||
m.AddAlternative("text/html", html)
|
||||
|
||||
var raw bytes.Buffer
|
||||
if _, err := m.WriteTo(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
dialer := &net.Dialer{}
|
||||
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(cred.Server, fmt.Sprint(cred.Port)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
defer close(done)
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = conn.SetDeadline(time.Now())
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
if implicitTLS {
|
||||
tlsConn := tls.Client(conn, smtpTLSConfig(cred))
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
client, err := smtp.NewClient(conn, cred.Server)
|
||||
if err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
defer client.Quit() //nolint:errcheck
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(smtpTLSConfig(cred)); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
}
|
||||
if cred.Login != "" {
|
||||
if err := client.Auth(smtpAuth(client, cred)); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
}
|
||||
if err := client.Mail(cred.FromAddress); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
writer, err := client.Data()
|
||||
if err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
if _, err := writer.Write(raw.Bytes()); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return smtpContextError(ctx, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func smtpContextError(ctx context.Context, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func smtpTLSConfig(cred *wire.SMTPCredential) *tls.Config {
|
||||
return &tls.Config{ServerName: cred.Server, InsecureSkipVerify: cred.InsecureSkipVerify}
|
||||
}
|
||||
|
||||
func smtpAuth(client *smtp.Client, cred *wire.SMTPCredential) smtp.Auth {
|
||||
_, mechanisms := client.Extension("AUTH")
|
||||
for _, mechanism := range strings.Fields(strings.ToUpper(mechanisms)) {
|
||||
switch mechanism {
|
||||
case "CRAM-MD5":
|
||||
return smtp.CRAMMD5Auth(cred.Login, cred.Password)
|
||||
case "LOGIN":
|
||||
return loginAuth{username: cred.Login, password: cred.Password}
|
||||
case "PLAIN":
|
||||
return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server)
|
||||
}
|
||||
}
|
||||
// Preserve gomail's default when the server does not advertise mechanisms;
|
||||
// smtp.Client returns the server's authoritative AUTH failure.
|
||||
return smtp.PlainAuth("", cred.Login, cred.Password, cred.Server)
|
||||
}
|
||||
|
||||
type loginAuth struct{ username, password string }
|
||||
|
||||
func (a loginAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", nil, nil }
|
||||
func (a loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if !more {
|
||||
return nil, nil
|
||||
}
|
||||
challenge := strings.ToLower(string(fromServer))
|
||||
if strings.Contains(challenge, "username") || strings.Contains(challenge, "user") {
|
||||
return []byte(a.username), nil
|
||||
}
|
||||
return []byte(a.password), nil
|
||||
}
|
||||
273
internal/sender/email_context_test.go
Обычный файл
273
internal/sender/email_context_test.go
Обычный файл
@@ -0,0 +1,273 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/md5" //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
done chan error
|
||||
tls *tls.Config
|
||||
}
|
||||
|
||||
func newFakeSMTPServer(t *testing.T, implicitTLS, startTLS, blockEHLO bool, auth string) *fakeSMTPServer {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
server := &fakeSMTPServer{listener: listener, done: make(chan error, 1), tls: fakeSMTPTLSConfig(t)}
|
||||
go func() { server.done <- server.serve(implicitTLS, startTLS, blockEHLO, auth) }()
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
select {
|
||||
case err := <-server.done:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Error("fake SMTP server did not exit")
|
||||
}
|
||||
})
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *fakeSMTPServer) serve(implicitTLS, startTLS, blockEHLO bool, auth string) error {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
if implicitTLS {
|
||||
conn = tls.Server(conn, s.tls)
|
||||
if err := conn.(*tls.Conn).Handshake(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
if err := smtpReply(writer, "220 fake smtp"); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err := smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(line, "EHLO ") {
|
||||
return errors.New("expected EHLO")
|
||||
}
|
||||
if blockEHLO {
|
||||
_, _ = reader.ReadByte()
|
||||
return nil
|
||||
}
|
||||
capabilities := []string{"250-fake"}
|
||||
if startTLS {
|
||||
capabilities = append(capabilities, "250-STARTTLS")
|
||||
}
|
||||
if auth != "" {
|
||||
capabilities = append(capabilities, "250-AUTH "+auth)
|
||||
}
|
||||
capabilities = append(capabilities, "250 OK")
|
||||
if err := smtpReplies(writer, capabilities...); err != nil {
|
||||
return err
|
||||
}
|
||||
if startTLS {
|
||||
if line, err = smtpCommand(reader); err != nil || line != "STARTTLS" {
|
||||
return errors.New("expected STARTTLS")
|
||||
}
|
||||
if err := smtpReply(writer, "220 ready for TLS"); err != nil {
|
||||
return err
|
||||
}
|
||||
conn = tls.Server(conn, s.tls)
|
||||
if err := conn.(*tls.Conn).Handshake(); err != nil {
|
||||
return err
|
||||
}
|
||||
reader, writer = bufio.NewReader(conn), bufio.NewWriter(conn)
|
||||
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "EHLO ") {
|
||||
return errors.New("expected EHLO after STARTTLS")
|
||||
}
|
||||
capabilities = []string{"250-fake"}
|
||||
if auth != "" {
|
||||
capabilities = append(capabilities, "250-AUTH "+auth)
|
||||
}
|
||||
capabilities = append(capabilities, "250 OK")
|
||||
if err := smtpReplies(writer, capabilities...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := smtpAuthenticate(reader, writer, auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "MAIL FROM:<sender@example.test>") {
|
||||
return errors.New("expected MAIL FROM")
|
||||
}
|
||||
if err := smtpReply(writer, "250 sender ok"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || !strings.HasPrefix(line, "RCPT TO:<recipient@example.test>") {
|
||||
return errors.New("expected RCPT TO")
|
||||
}
|
||||
if err := smtpReply(writer, "250 recipient ok"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || line != "DATA" {
|
||||
return errors.New("expected DATA")
|
||||
}
|
||||
if err := smtpReply(writer, "354 send data"); err != nil {
|
||||
return err
|
||||
}
|
||||
dataLines := 0
|
||||
for {
|
||||
line, err = smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if line == "." {
|
||||
break
|
||||
}
|
||||
dataLines++
|
||||
}
|
||||
if dataLines == 0 {
|
||||
return errors.New("expected non-empty DATA payload")
|
||||
}
|
||||
if err := smtpReply(writer, "250 queued"); err != nil {
|
||||
return err
|
||||
}
|
||||
line, err = smtpCommand(reader)
|
||||
if err != nil || line != "QUIT" {
|
||||
return errors.New("expected QUIT")
|
||||
}
|
||||
return smtpReply(writer, "221 bye")
|
||||
}
|
||||
|
||||
func smtpAuthenticate(reader *bufio.Reader, writer *bufio.Writer, auth string) error {
|
||||
if auth == "" {
|
||||
return nil
|
||||
}
|
||||
line, err := smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(auth, "CRAM-MD5") {
|
||||
if line != "AUTH CRAM-MD5" {
|
||||
return errors.New("expected capability-selected CRAM-MD5 authentication")
|
||||
}
|
||||
challenge := []byte("fake-cram-challenge")
|
||||
if err := smtpReply(writer, "334 "+base64.StdEncoding.EncodeToString(challenge)); err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := smtpCommand(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mac := hmac.New(md5.New, []byte("password")) //nolint:gosec // CRAM-MD5 is an SMTP protocol requirement.
|
||||
_, _ = mac.Write(challenge)
|
||||
if string(decoded) != "user "+fmtHex(mac.Sum(nil)) {
|
||||
return errors.New("invalid CRAM-MD5 response")
|
||||
}
|
||||
return smtpReply(writer, "235 authenticated")
|
||||
}
|
||||
if line != "AUTH LOGIN" {
|
||||
return errors.New("expected capability-selected LOGIN authentication")
|
||||
}
|
||||
if err := smtpReply(writer, "334 VXNlcm5hbWU6"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("user")) {
|
||||
return errors.New("invalid LOGIN username")
|
||||
}
|
||||
if err := smtpReply(writer, "334 UGFzc3dvcmQ6"); err != nil {
|
||||
return err
|
||||
}
|
||||
if line, err = smtpCommand(reader); err != nil || line != base64.StdEncoding.EncodeToString([]byte("password")) {
|
||||
return errors.New("invalid LOGIN password")
|
||||
}
|
||||
return smtpReply(writer, "235 authenticated")
|
||||
}
|
||||
|
||||
func TestSendEmailWithCredentialContextImplicitTLS(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, true, false, false, "CRAM-MD5 PLAIN")
|
||||
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
||||
require.NoError(t, sendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred, true))
|
||||
}
|
||||
|
||||
func TestSendEmailWithCredentialContextSTARTTLSAndLogin(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, false, true, false, "LOGIN PLAIN")
|
||||
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
||||
require.NoError(t, SendEmailWithCredentialContext(context.Background(), "recipient@example.test", "subject", "body", "<b>body</b>", cred))
|
||||
}
|
||||
|
||||
func TestSendEmailWithCredentialContextDeadlineInterruptsSMTPCommand(t *testing.T) {
|
||||
server := newFakeSMTPServer(t, false, false, true, "")
|
||||
cred := fakeSMTPCredential(t, server.listener.Addr().String())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
err := SendEmailWithCredentialContext(ctx, "recipient@example.test", "subject", "body", "<b>body</b>", cred)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func fakeSMTPCredential(t *testing.T, address string) *wire.SMTPCredential {
|
||||
t.Helper()
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
require.NoError(t, err)
|
||||
portNumber, err := net.LookupPort("tcp", port)
|
||||
require.NoError(t, err)
|
||||
return &wire.SMTPCredential{Server: host, Port: portNumber, Login: "user", Password: "password", FromAddress: "sender@example.test", InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
func fakeSMTPTLSConfig(t *testing.T) *tls.Config {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
template := x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "fake smtp"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
certificate, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
||||
require.NoError(t, err)
|
||||
return &tls.Config{Certificates: []tls.Certificate{certificate}}
|
||||
}
|
||||
|
||||
func smtpCommand(reader *bufio.Reader) (string, error) {
|
||||
line, err := reader.ReadString('\n')
|
||||
return strings.TrimRight(line, "\r\n"), err
|
||||
}
|
||||
|
||||
func smtpReply(writer *bufio.Writer, line string) error { return smtpReplies(writer, line) }
|
||||
|
||||
func smtpReplies(writer *bufio.Writer, lines ...string) error {
|
||||
for _, line := range lines {
|
||||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return writer.Flush()
|
||||
}
|
||||
|
||||
func fmtHex(value []byte) string {
|
||||
const hex = "0123456789abcdef"
|
||||
result := make([]byte, len(value)*2)
|
||||
for i, b := range value {
|
||||
result[i*2], result[i*2+1] = hex[b>>4], hex[b&0x0f]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
17
internal/sender/event_table.go
Обычный файл
17
internal/sender/event_table.go
Обычный файл
@@ -0,0 +1,17 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// RenderEventsTable provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.RenderEventsTable directly. This
|
||||
// wrapper is preserved for backwards compatibility until phase 3 retires the
|
||||
// legacy sender loop entirely.
|
||||
func RenderEventsTable(message *models.Message, tn time.Time) (string, string) {
|
||||
return notifyrender.RenderEventsTable(message, tn)
|
||||
}
|
||||
88
internal/sender/event_table_test.go
Обычный файл
88
internal/sender/event_table_test.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/icrowley/fake"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifier"
|
||||
"rsgit.ru/rsmon/rsmon/spec/factories"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.Init()
|
||||
}
|
||||
|
||||
var asciiTableExample = strings.TrimLeft(`
|
||||
┌─────────┬──────────┬─────────────────────┬─────────────────┬───────────────────┬─────────┬──────────────┐
|
||||
│ МОНИТОР │ ПРОВЕРКА │ ВРЕМЯ НАЧАЛА │ ВРЕМЯ ОКОНЧАНИЯ │ ПРОДОЛЖИТЕЛЬНОСТЬ │ СТАТУС │ ОШИБКА │
|
||||
├─────────┼──────────┼─────────────────────┼─────────────────┼───────────────────┼─────────┼──────────────┤
|
||||
│ Tagopia │ │ 02.01.2018 03:04:05 │ │ 24 часа, 0 минут │ current │ test event 1 │
|
||||
└─────────┴──────────┴─────────────────────┴─────────────────┴───────────────────┴─────────┴──────────────┘
|
||||
`, "\n")
|
||||
|
||||
var markdownTableExample = strings.TrimLeft(`
|
||||
| Монитор | Проверка | Время начала | Время окончания | Продолжительность | Статус | Ошибка |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Tagopia | | 02.01.2018 03:04:05 | | 24 часа, 0 минут | current | test event 1 |
|
||||
`, "\n")
|
||||
|
||||
func TestEventTable(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
user := factories.PersistedUser("test@test.ru", "123")
|
||||
account, err := models.CreateAccountForUser(fake.Company(), &user)
|
||||
|
||||
contact := factories.PersistedContact(account, &user)
|
||||
group := factories.PersistedGroup(account)
|
||||
factories.PersistedNotification(account, []int64{contact.ID}, []int64{group.ID}, 300, false)
|
||||
|
||||
monitor := factories.PersistedMonitor(&group)
|
||||
n := "Tagopia"
|
||||
monitor.Name = &n
|
||||
err = models.DB().Save(&monitor).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
event := factories.PersistedEvent(&monitor, "current", "test event 1")
|
||||
tStart := time.Date(2018, time.January, 2, 3, 4, 5, 0, time.Local)
|
||||
tNow := time.Date(2018, time.January, 3, 3, 4, 5, 0, time.Local)
|
||||
|
||||
event.StartTime = &tStart
|
||||
err = models.DB().Save(&event).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
notifier.Run()
|
||||
|
||||
messages := make([]models.Message, 0)
|
||||
err = models.MessageScope(models.DB()).Where("state IN ('queued')").Find(&messages).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
t.Fatal("no message sent")
|
||||
}
|
||||
|
||||
if len(messages) > 1 {
|
||||
spew.Dump(messages)
|
||||
t.Fatal("found more than one message")
|
||||
}
|
||||
|
||||
asciiTable, markdownTable := RenderEventsTable(&messages[0], tNow)
|
||||
|
||||
// fmt.Println(asciiTable)
|
||||
// fmt.Println(markdownTable)
|
||||
|
||||
assert.Equal(t, asciiTableExample, asciiTable, "rendered table should match example")
|
||||
assert.Equal(t, markdownTableExample, markdownTable, "rendered table should match example")
|
||||
}
|
||||
19
internal/sender/get_content.go
Обычный файл
19
internal/sender/get_content.go
Обычный файл
@@ -0,0 +1,19 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// GetContent provides functionality.
|
||||
//
|
||||
// Deprecated: the worker-driven notifier producer now uses
|
||||
// internal/notifyrender directly. This thin wrapper is preserved so the
|
||||
// legacy RunMessage loop still compiles until it is removed in phase 3 of
|
||||
// docs/plans/worker-notifier-mvp.md.
|
||||
func GetContent(message *models.Message, tn time.Time) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.GetContent(message, tn)
|
||||
}
|
||||
187
internal/sender/get_content_test.go
Обычный файл
187
internal/sender/get_content_test.go
Обычный файл
@@ -0,0 +1,187 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/spec/factories"
|
||||
)
|
||||
|
||||
var (
|
||||
subjectExampleDown = "Не доступен test monitor"
|
||||
textBodyExampleDown = strings.TrimLeft(`
|
||||
Монитор test monitor не доступен
|
||||
|
||||
Проверки: [http].
|
||||
Ошибка: test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:04
|
||||
|
||||
Недоступные проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
`, "\n")
|
||||
)
|
||||
|
||||
var markdownBodyExampleDown = strings.TrimLeft(`
|
||||
Монитор test monitor не доступен :warning:
|
||||
|
||||
Проверки: [http].
|
||||
Ошибка: test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:04
|
||||
|
||||
Недоступные проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
`, "\n")
|
||||
|
||||
var htmlBodyExampleDown = strings.TrimLeft(`
|
||||
<h4>Монитор test monitor не доступен</h4>
|
||||
|
||||
Проверки: [http].
|
||||
<h5 style='background-color: red;'>Ошибка:</h5>
|
||||
test error
|
||||
|
||||
<div>Начало события: 03.01.2019 03:04:04</div>
|
||||
|
||||
<h5>Недоступные проверки:<h5>
|
||||
|
||||
<div>
|
||||
http - test check - URL не указан (testerr)
|
||||
</div>
|
||||
`, "\n")
|
||||
|
||||
var (
|
||||
subjectExampleUp = "Снова доступен test monitor"
|
||||
textBodyExampleUp = strings.TrimLeft(`
|
||||
Монитор test monitor снова доступен
|
||||
|
||||
Проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
|
||||
|
||||
Он был недоступен 1 минуту по причине ошибки test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:05
|
||||
Окончание события: 03.01.2019 03:05:05
|
||||
`, "\n")
|
||||
)
|
||||
|
||||
var markdownBodyExampleUp = strings.TrimLeft(`
|
||||
Монитор test monitor снова доступен :white_check_mark:
|
||||
|
||||
Проверки:
|
||||
|
||||
http - test check - URL не указан (testerr)
|
||||
|
||||
|
||||
Он был недоступен 1 минуту по причине ошибки test error
|
||||
|
||||
Начало события: 03.01.2019 03:04:05
|
||||
Окончание события: 03.01.2019 03:05:05
|
||||
`, "\n")
|
||||
|
||||
var htmlBodyExampleUp = strings.TrimLeft(`
|
||||
<h4>Монитор test monitor снова доступен</h4>.
|
||||
|
||||
<h5>Проверки:<h5>
|
||||
|
||||
<div>
|
||||
http - test check - URL не указан (testerr)
|
||||
</div>
|
||||
|
||||
|
||||
<div>Он был недоступен 1 минуту по причине ошибки <code>test error</code></div>
|
||||
|
||||
<div>Начало события: 03.01.2019 03:04:05</div>
|
||||
<div>Окончание события: 03.01.2019 03:05:05</div>
|
||||
`, "\n")
|
||||
|
||||
var (
|
||||
subjectExampleExp = "Скоро истекает test check (SSL сертификат) по test monitor"
|
||||
textBodyExampleExp = strings.TrimLeft(`
|
||||
Монитор test monitor
|
||||
03.01.2019 04:04:05 истекает test check (SSL сертификат)
|
||||
`, "\n")
|
||||
)
|
||||
|
||||
var markdownBodyExampleExp = strings.TrimLeft(`
|
||||
Монитор test monitor
|
||||
03.01.2019 04:04:05 истекает test check (SSL сертификат)
|
||||
`, "\n")
|
||||
|
||||
var htmlBodyExampleExp = strings.TrimLeft(`
|
||||
Монитор test monitor
|
||||
03.01.2019 04:04:05 истекает test check <strong>(SSL сертификат)</strong>
|
||||
`, "\n")
|
||||
|
||||
func TestGetContent(t *testing.T) {
|
||||
tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
message := factories.MessageFactory()
|
||||
|
||||
subject, textBody, markdownBody, htmlBody := GetContent(&message, tn)
|
||||
|
||||
// fmt.Println(subject.String())
|
||||
// fmt.Println(textBody.String())
|
||||
// fmt.Println(markdownBody.String())
|
||||
// fmt.Println(htmlBody.String())
|
||||
|
||||
assert.Equal(t, subjectExampleDown, subject.String(), "rendered subject should match example")
|
||||
assert.Equal(t, textBodyExampleDown, textBody.String(), "rendered textBody should match example")
|
||||
assert.Equal(t, markdownBodyExampleDown, markdownBody.String(), "rendered markdownBody should match example")
|
||||
assert.Equal(t, htmlBodyExampleDown, htmlBody.String(), "rendered htmlBody should match example")
|
||||
}
|
||||
|
||||
func TestGetContentUp(t *testing.T) {
|
||||
tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
ts := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
te := time.Date(2019, time.January, 3, 3, 5, 5, 0, time.UTC)
|
||||
|
||||
message := factories.MessageFactory()
|
||||
message.Kind = "up"
|
||||
message.Events[0].StartTime = &ts
|
||||
message.Events[0].EndTime = &te
|
||||
|
||||
subject, textBody, markdownBody, htmlBody := GetContent(&message, tn)
|
||||
|
||||
// fmt.Println(subject.String())
|
||||
// fmt.Println(textBody.String())
|
||||
// fmt.Println(markdownBody.String())
|
||||
// fmt.Println(htmlBody.String())
|
||||
|
||||
assert.Equal(t, subjectExampleUp, subject.String(), "rendered subject should match example")
|
||||
assert.Equal(t, textBodyExampleUp, textBody.String(), "rendered textBody should match example")
|
||||
assert.Equal(t, markdownBodyExampleUp, markdownBody.String(), "rendered markdownBody should match example")
|
||||
assert.Equal(t, htmlBodyExampleUp, htmlBody.String(), "rendered htmlBody should match example")
|
||||
}
|
||||
|
||||
func TestGetContentExp(t *testing.T) {
|
||||
tn := time.Date(2019, time.January, 3, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
message := factories.ExpMessageFactory()
|
||||
|
||||
in1h := tn.Add(1 * time.Hour)
|
||||
message.Check.Expires = &in1h
|
||||
message.Check.Kind = "ssl"
|
||||
|
||||
subject, textBody, _, htmlBody := GetContent(&message, tn)
|
||||
|
||||
subject, textBody, markdownBody, htmlBody := GetContent(&message, tn)
|
||||
|
||||
// fmt.Println(subject.String())
|
||||
// fmt.Println(textBody.String())
|
||||
// fmt.Println(markdownBody.String())
|
||||
// fmt.Println(htmlBody.String())
|
||||
|
||||
assert.Equal(t, subjectExampleExp, subject.String(), "rendered subject should match example")
|
||||
assert.Equal(t, textBodyExampleExp, textBody.String(), "rendered textBody should match example")
|
||||
assert.Equal(t, markdownBodyExampleExp, markdownBody.String(), "rendered markdownBody should match example")
|
||||
assert.Equal(t, htmlBodyExampleExp, htmlBody.String(), "rendered htmlBody should match example")
|
||||
}
|
||||
85
internal/sender/invite.go
Обычный файл
85
internal/sender/invite.go
Обычный файл
@@ -0,0 +1,85 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var (
|
||||
inviteSubject *template.Template
|
||||
inviteMessage *template.Template
|
||||
inviteHTMLMessage *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
inviteSubject = template.Must(template.New("invite_subject").Parse(`Доступ к мониторингу rsmon.ru`))
|
||||
|
||||
inviteMessage = template.Must(template.New("invite_message").Parse(`
|
||||
Пользователь {{.Inviter.Name}} {{.Inviter.Email}}
|
||||
{{ if not .InviteeID }}пригласил вас получить{{else}}предоставил вам{{end}} доступ к аккаунту rsmon.ru {{.Account.Name}}
|
||||
|
||||
{{ if not .InviteeID }}
|
||||
Чтобы зарегистрироваться, перейдите по ссылке: https://rsmon.ru/by-invite/{{.Token}}
|
||||
{{end}}
|
||||
|
||||
https://rsmon.ru
|
||||
`))
|
||||
|
||||
inviteHTMLMessage = template.Must(template.New("invite_message").Parse(`
|
||||
<p>Пользователь {{.Inviter.Name}} {{.Inviter.Email}}
|
||||
{{ if not .InviteeID }}пригласил вас получить{{else}}предоставил вам{{end}}
|
||||
доступ к аккаунту rsmon.ru <strong>{{.Account.Name}}</strong></p>
|
||||
|
||||
{{ if not .InviteeID }}
|
||||
<p>Чтобы зарегистрироваться, перейдите по ссылке:
|
||||
<a href="https://rsmon.ru/by-invite/{{.Token}}">https://rsmon.ru/by-invite/{{.Token}}</a></p>
|
||||
{{end}}
|
||||
|
||||
<p><a href="https://rsmon.ru">https://rsmon.ru</a></p>
|
||||
`))
|
||||
}
|
||||
|
||||
// Invite provides functionality.
|
||||
func Invite(i *models.Invite) error {
|
||||
// us := models.User{}
|
||||
var err error
|
||||
var subject, message, htmlMessage bytes.Buffer
|
||||
err = inviteSubject.Execute(&subject, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = inviteMessage.Execute(&message, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = inviteHTMLMessage.Execute(&htmlMessage, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = SendEmail(i.Email, subject.String(), message.String(), htmlMessage.String())
|
||||
if err != nil {
|
||||
log.Println("email send fail", err)
|
||||
i.State = "FAIL"
|
||||
serr := models.DB().Save(&i).Error
|
||||
if serr != nil {
|
||||
return errors.Wrap(err, "failed to save invite, and failed to save to DB")
|
||||
}
|
||||
return errors.Wrap(err, "failed to save invite")
|
||||
}
|
||||
if i.State != "OK" {
|
||||
i.State = "SENT"
|
||||
}
|
||||
|
||||
err = models.DB().Omit(clause.Associations).Save(&i).Error
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to add accesses to invited user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
155
internal/sender/mattermost.go
Обычный файл
155
internal/sender/mattermost.go
Обычный файл
@@ -0,0 +1,155 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/wire"
|
||||
)
|
||||
|
||||
// messageKindUp is the canonical string the notifier uses for "monitor
|
||||
// just came back up" events. Lives here as a package-level constant so
|
||||
// the goconst linter stops flagging the literal "up" appearing three
|
||||
// times across the sender package.
|
||||
const messageKindUp = "up"
|
||||
|
||||
// MattermostPayload provides functionality.
|
||||
type MattermostPayload struct {
|
||||
Channel *string `json:"channel,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
IconURL *string `json:"icon_url,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// RunMattermost provides functionality.
|
||||
func RunMattermost(message *models.Message) (*string, error) {
|
||||
_, _, markdownBody, _ := GetContent(message, time.Now())
|
||||
|
||||
s := "@channel "
|
||||
if message.Kind == messageKindUp {
|
||||
s += ":white_check_mark: "
|
||||
} else {
|
||||
s += ":warning: "
|
||||
}
|
||||
s = s + "\n\n" + markdownBody.String()
|
||||
|
||||
color := "green"
|
||||
if message.Kind == "down" {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
un := "RSMon"
|
||||
icon := "https://rsmon.ru/" + color + "_logo.svg"
|
||||
payload := MattermostPayload{
|
||||
Text: &s,
|
||||
Username: &un,
|
||||
IconURL: &icon,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("send mattermost:", message.Contact.Value)
|
||||
// log.Println(string(body))
|
||||
|
||||
resp, err := httpClient.Post(
|
||||
message.Contact.Value,
|
||||
"application/json",
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
str := string(body)
|
||||
return &str, nil
|
||||
}
|
||||
|
||||
// SendMattermostWithCredential delivers a Mattermost notification using the
|
||||
// supplied wire credential block (which carries the default username +
|
||||
// icon URL). The webhook URL is per-task; the credential block only sets
|
||||
// the cosmetic defaults. This mirrors what the legacy RunMattermost did with
|
||||
// secrets + a hardcoded RSMon username / icon.
|
||||
func SendMattermostWithCredential(
|
||||
contactValue, messageKind, subject, markdownBody string,
|
||||
cred *wire.MattermostCredential,
|
||||
) (*string, error) {
|
||||
return SendMattermostWithCredentialContext(context.Background(), contactValue, messageKind, subject, markdownBody, cred)
|
||||
}
|
||||
|
||||
func SendMattermostWithCredentialContext(
|
||||
ctx context.Context, contactValue, messageKind, subject, markdownBody string,
|
||||
cred *wire.MattermostCredential,
|
||||
) (*string, error) {
|
||||
if contactValue == "" {
|
||||
return nil, errors.New("mattermost contact value is empty")
|
||||
}
|
||||
|
||||
s := "@channel "
|
||||
if messageKind == messageKindUp {
|
||||
s += ":white_check_mark: "
|
||||
} else {
|
||||
s += ":warning: "
|
||||
}
|
||||
s = s + "\n\n" + subject + "\n\n" + markdownBody
|
||||
|
||||
color := "green"
|
||||
if messageKind == "down" {
|
||||
color = "red"
|
||||
}
|
||||
|
||||
username := "RSMon"
|
||||
iconURL := "https://rsmon.ru/" + color + "_logo.svg"
|
||||
if cred != nil {
|
||||
if cred.DefaultUsername != "" {
|
||||
username = cred.DefaultUsername
|
||||
}
|
||||
if cred.DefaultIconURL != "" {
|
||||
iconURL = cred.DefaultIconURL
|
||||
}
|
||||
}
|
||||
|
||||
payload := MattermostPayload{
|
||||
Text: &s,
|
||||
Username: &username,
|
||||
IconURL: &iconURL,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, contactValue, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
str := string(respBody)
|
||||
return &str, nil
|
||||
}
|
||||
90
internal/sender/run.go
Обычный файл
90
internal/sender/run.go
Обычный файл
@@ -0,0 +1,90 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Run provides functionality.
|
||||
func Run() {
|
||||
messages := make([]models.Message, 0)
|
||||
|
||||
tx := models.DB().Begin().Set("gorm:association_autoupdate", false)
|
||||
|
||||
q := tx
|
||||
// q = q.Set("gorm:query_option", "FOR UPDATE")
|
||||
err := models.MessageScope(q).Where("state IN ('queued')").Find(&messages).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
log.Println(err)
|
||||
return
|
||||
// panic(err)
|
||||
}
|
||||
|
||||
for _, msg := range messages { //nolint:gocritic // range copy is acceptable here
|
||||
msg.State = "sending"
|
||||
tx.Model(&msg).Update("state", "sending")
|
||||
// spew.Dump(msg.ID)
|
||||
// err = tx.Save(&msg).Error
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
}
|
||||
_ = tx.Commit()
|
||||
|
||||
for _, msg := range messages { //nolint:gocritic // range copy is acceptable here
|
||||
_, _ = RunMessage(&msg)
|
||||
}
|
||||
}
|
||||
|
||||
// RunMessage provides functionality.
|
||||
func RunMessage(message *models.Message) (*string, error) {
|
||||
var err error
|
||||
var response *string
|
||||
|
||||
log.Println("send message", message.ID)
|
||||
switch message.Contact.Kind {
|
||||
case "telegram_group", "telegram_private":
|
||||
err = RunTelegram(message)
|
||||
case "email":
|
||||
err = RunEmail(message)
|
||||
case "webhook":
|
||||
response, err = RunWebhook(message)
|
||||
case "mattermost":
|
||||
response, err = RunMattermost(message)
|
||||
case "sms":
|
||||
err = RunSMS(message)
|
||||
case "voice":
|
||||
err = RunVoice(message)
|
||||
default:
|
||||
err = errors.New("notification kind not implemented: " + message.Contact.Kind)
|
||||
}
|
||||
if response != nil {
|
||||
log.Println("notification done. response:", *response, "error:", err)
|
||||
} else {
|
||||
log.Println("notification done. error:", err)
|
||||
}
|
||||
|
||||
if message.ID != 0 {
|
||||
if response != nil {
|
||||
message.Response = response
|
||||
}
|
||||
if err == nil {
|
||||
message.State = "sent"
|
||||
message.SentAt = time.Now()
|
||||
} else {
|
||||
message.State = "error"
|
||||
et := err.Error()
|
||||
message.Error = &et
|
||||
}
|
||||
err = models.DB().Save(&message).Error
|
||||
if err != nil {
|
||||
log.Println("ERROR:", err)
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
33
internal/sender/sender.go
Обычный файл
33
internal/sender/sender.go
Обычный файл
@@ -0,0 +1,33 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var httpClient *http.Client
|
||||
|
||||
func init() {
|
||||
httpClient = &http.Client{
|
||||
Timeout: time.Second * 60,
|
||||
}
|
||||
}
|
||||
|
||||
// Init sender
|
||||
//
|
||||
// Deprecated: the legacy SMPP init that lived here is commented out. Phase 3
|
||||
// of docs/plans/worker-notifier-mvp.md retires the sender loop entirely.
|
||||
// This stub is preserved so any remaining callers do not fail to compile
|
||||
// during the rollout window.
|
||||
func Init() {
|
||||
// if application.Env == "production" {
|
||||
// InitSMPP()
|
||||
// }
|
||||
}
|
||||
|
||||
// Start sender
|
||||
//
|
||||
// Deprecated: the legacy sender loop is retired. The worker binary is the executor.
|
||||
func Start() {
|
||||
// no-op
|
||||
}
|
||||
41
internal/sender/sms.go
Обычный файл
41
internal/sender/sms.go
Обычный файл
@@ -0,0 +1,41 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ns3777k/go-smsaero/smsaero"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/application"
|
||||
)
|
||||
|
||||
// RunSMS provides functionality.
|
||||
func RunSMS(message *models.Message) error {
|
||||
subject, _, _, _ := GetContent(message, time.Now())
|
||||
return SendSMS(message.Contact.Value, subject.String())
|
||||
}
|
||||
|
||||
// SendSMS provides functionality.
|
||||
func SendSMS(to, message string) error {
|
||||
// return nil
|
||||
// https://smsaero.ru/
|
||||
if application.Env == envProduction {
|
||||
log.Println("sms send to:", to, "message:", message)
|
||||
phonei, err := strconv.Atoi(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := smsaero.NewClient(nil, "glebtv@gmail.com", "U10x9UPWrlLPx5PlgE8orwvVqtS")
|
||||
_, err = client.Send(phonei, message, "rsmon")
|
||||
if err != nil {
|
||||
log.Println("sms result:", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Println("sms debug. to:", to, "message:", message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
53
internal/sender/telegram.go
Обычный файл
53
internal/sender/telegram.go
Обычный файл
@@ -0,0 +1,53 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/tg"
|
||||
)
|
||||
|
||||
// RunTelegram provides functionality.
|
||||
func RunTelegram(message *models.Message) error {
|
||||
subject, _, _, _ := GetContent(message, time.Now())
|
||||
return tg.SendMessage(message.Contact.Value, subject.String())
|
||||
}
|
||||
|
||||
// SendTelegramWithCredential delivers a Telegram message using the given bot
|
||||
// credential. Phase 1 of docs/plans/worker-notifier-mvp.md: the worker holds
|
||||
// the credential in memory after the init/config push and passes it here.
|
||||
func SendTelegramWithCredential(chatID, subject, body string, cred *models.NotificationCredential) error {
|
||||
return SendTelegramWithCredentialContext(context.Background(), chatID, subject, body, cred)
|
||||
}
|
||||
|
||||
// SendTelegramWithCredentialContext prevents handoff after cancellation. The
|
||||
// tgbotapi client used by the legacy sender does not expose a context-aware
|
||||
// Send method, so an already handed-off Telegram request cannot be interrupted.
|
||||
func SendTelegramWithCredentialContext(ctx context.Context, chatID, subject, body string, cred *models.NotificationCredential) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cred == nil {
|
||||
return errors.New("telegram credential is nil")
|
||||
}
|
||||
text := subject
|
||||
if body != "" {
|
||||
text = subject + "\n\n" + body
|
||||
}
|
||||
err := sendTelegramWithBot(chatID, text, cred)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// sendTelegramWithBot is the low-level helper that the legacy RunTelegram
|
||||
// (legacy secrets path) and SendTelegramWithCredential (worker wire path)
|
||||
// both call. It derives an http API URL from the credential's APIURL.
|
||||
func sendTelegramWithBot(chatID, text string, cred *models.NotificationCredential) error {
|
||||
// Use a credential-scoped helper so each Telegram credential can map to a
|
||||
// separate bot token/API URL.
|
||||
return tg.SendMessageWithToken(chatID, text, cred)
|
||||
}
|
||||
13
internal/sender/test_message.go
Обычный файл
13
internal/sender/test_message.go
Обычный файл
@@ -0,0 +1,13 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
func TestMessage(contact *models.Contact) (*string, error) {
|
||||
message := models.Message{
|
||||
Contact: contact,
|
||||
Kind: "test",
|
||||
}
|
||||
return RunMessage(&message)
|
||||
}
|
||||
15
internal/sender/text_down_one.go
Обычный файл
15
internal/sender/text_down_one.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// TextDownOne provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.TextDownOne directly.
|
||||
func TextDownOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.TextDownOne(event)
|
||||
}
|
||||
15
internal/sender/text_expires.go
Обычный файл
15
internal/sender/text_expires.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// TextExpires provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.TextExpires directly.
|
||||
func TextExpires(check *models.Check) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.TextExpires(check)
|
||||
}
|
||||
15
internal/sender/text_up_one.go
Обычный файл
15
internal/sender/text_up_one.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/notifyrender"
|
||||
)
|
||||
|
||||
// TextUpOne provides functionality.
|
||||
//
|
||||
// Deprecated: use internal/notifyrender.TextUpOne directly.
|
||||
func TextUpOne(event *models.Event) (bytes.Buffer, bytes.Buffer, bytes.Buffer, bytes.Buffer) {
|
||||
return notifyrender.TextUpOne(event)
|
||||
}
|
||||
13
internal/sender/voice.go
Обычный файл
13
internal/sender/voice.go
Обычный файл
@@ -0,0 +1,13 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// RunVoice provides functionality.
|
||||
func RunVoice(message *models.Message) error {
|
||||
_ = message
|
||||
return errors.New("voice notifications are disabled")
|
||||
}
|
||||
99
internal/sender/webhook.go
Обычный файл
99
internal/sender/webhook.go
Обычный файл
@@ -0,0 +1,99 @@
|
||||
package sender
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/application"
|
||||
)
|
||||
|
||||
const envProduction = "production"
|
||||
|
||||
// RunWebhook provides functionality.
|
||||
func RunWebhook(message *models.Message) (*string, error) {
|
||||
if application.Env != envProduction {
|
||||
return nil, errors.New("not sending in env " + application.Env)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Post(
|
||||
message.Contact.Value,
|
||||
"application/json",
|
||||
bytes.NewBuffer(body),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
str := string(body)
|
||||
return &str, nil
|
||||
}
|
||||
|
||||
// SendWebhookWithCredential delivers a webhook notification using the
|
||||
// provided wire credential block. Phase 1 ships an empty signing secret;
|
||||
// phase 4 will plumb the per-account secret through Contact.Data. The
|
||||
// signature header is X-RSMon-Signature (hex-encoded HMAC-SHA256 of the
|
||||
// body), matching what webhook consumers in the existing fleet expect.
|
||||
//
|
||||
// The payload matches the wire.NotificationTask shape so customers
|
||||
// receiving the legacy Message JSON see one fewer breaking change.
|
||||
func SendWebhookWithCredential(payload []byte, contactValue, signingSecret string) (*string, error) {
|
||||
return SendWebhookWithCredentialContext(context.Background(), payload, contactValue, signingSecret)
|
||||
}
|
||||
|
||||
func SendWebhookWithCredentialContext(ctx context.Context, payload []byte, contactValue, signingSecret string) (*string, error) {
|
||||
if application.Env != envProduction {
|
||||
return nil, errors.New("not sending in env " + application.Env)
|
||||
}
|
||||
if contactValue == "" {
|
||||
return nil, errors.New("webhook contact value is empty")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, contactValue, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "rsmon-worker/1")
|
||||
if signingSecret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(signingSecret))
|
||||
mac.Write(payload)
|
||||
req.Header.Set("X-RSMon-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errors.New("webhook upstream " + resp.Status)
|
||||
}
|
||||
str := string(respBody)
|
||||
return &str, nil
|
||||
}
|
||||
411
internal/tg/bot.go
Обычный файл
411
internal/tg/bot.go
Обычный файл
@@ -0,0 +1,411 @@
|
||||
// Package tg provides Telegram bot functionality for RSMon.
|
||||
package tg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var bot *tgbotapi.BotAPI
|
||||
|
||||
type silentTelegramLogger struct{}
|
||||
|
||||
func (silentTelegramLogger) Println(...interface{}) {}
|
||||
func (silentTelegramLogger) Printf(string, ...interface{}) {}
|
||||
|
||||
func init() {
|
||||
_ = tgbotapi.SetLogger(silentTelegramLogger{})
|
||||
}
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://rsmon.ru"
|
||||
telegramAPITimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
func telegramAPIEndpoint(rawURL string) string {
|
||||
if rawURL == "" {
|
||||
return tgbotapi.APIEndpoint
|
||||
}
|
||||
return strings.TrimRight(rawURL, "/") + "/bot%s/%s"
|
||||
}
|
||||
|
||||
func newBotAPI(token, apiURL string) (*tgbotapi.BotAPI, error) {
|
||||
return tgbotapi.NewBotAPIWithClient(token, telegramAPIEndpoint(apiURL), &http.Client{Timeout: telegramAPITimeout})
|
||||
}
|
||||
|
||||
func botAPIForCredential(cred *models.NotificationCredential) (*tgbotapi.BotAPI, error) {
|
||||
if cred == nil {
|
||||
return nil, errors.New("telegram credential is nil")
|
||||
}
|
||||
token, err := cred.GetSecret()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("telegram credential secret: %w", err)
|
||||
}
|
||||
apiURL := ""
|
||||
if cred.APIURL != nil {
|
||||
apiURL = *cred.APIURL
|
||||
}
|
||||
client, err := newBotAPI(token, apiURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.Debug = false
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetContact finds or creates a contact for the given Telegram chat.
|
||||
func GetContact(kind, name string, chatID int64) (models.Contact, error) {
|
||||
contact := models.Contact{}
|
||||
|
||||
if kind == "supergroup" {
|
||||
kind = "group"
|
||||
}
|
||||
ckind := "telegram_" + kind
|
||||
cvalue := strconv.FormatInt(chatID, 10)
|
||||
|
||||
models.DB().Where("kind = ? AND value = ?", ckind, cvalue).Find(&contact)
|
||||
|
||||
contact.Kind = ckind
|
||||
contact.Value = cvalue
|
||||
contact.Name = name
|
||||
if contact.Token == "" {
|
||||
contact.SetToken()
|
||||
}
|
||||
err := models.DB().Save(&contact).Error
|
||||
|
||||
return contact, err
|
||||
}
|
||||
|
||||
// Init initializes the Telegram bot API client.
|
||||
func Init() error {
|
||||
if bot == nil {
|
||||
client, err := defaultBotAPI()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bot = client
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultBotAPI() (*tgbotapi.BotAPI, error) {
|
||||
creds, err := models.EnabledCredentialsByKind(models.CredentialKindTelegram)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
return nil, errors.New("telegram bot not configured")
|
||||
}
|
||||
return botAPIForCredential(&creds[0])
|
||||
}
|
||||
|
||||
func botAPIForCredentialID(id int64) (*tgbotapi.BotAPI, error) {
|
||||
if id <= 0 {
|
||||
return defaultBotAPI()
|
||||
}
|
||||
cred, err := models.FindCredential(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cred.Kind != models.CredentialKindTelegram {
|
||||
return nil, fmt.Errorf("credential %d is %q, not telegram", id, cred.Kind)
|
||||
}
|
||||
if cred.Enabled != nil && !*cred.Enabled {
|
||||
return nil, fmt.Errorf("credential %d is disabled", id)
|
||||
}
|
||||
return botAPIForCredential(cred)
|
||||
}
|
||||
|
||||
// SendMessage sends a Telegram message to the given chat ID string.
|
||||
func SendMessage(chatIDStr, message string) error {
|
||||
var err error
|
||||
|
||||
iChatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = Init()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := tgbotapi.NewMessage(iChatID, "")
|
||||
msg.Text = message
|
||||
|
||||
_, err = bot.Send(msg)
|
||||
recordSentMessage(iChatID, message, err)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SendMessageWithToken is the credential-scoped variant used by the worker
|
||||
// executor. It builds a one-shot bot client from the credential's BotToken +
|
||||
// optional APIURL, then sends the message. Returns the bot's response error
|
||||
// so callers can translate into retryable/permanent status.
|
||||
func SendMessageWithToken(chatIDStr, message string, cred *models.NotificationCredential) error {
|
||||
if cred == nil {
|
||||
return errors.New("telegram credential is nil")
|
||||
}
|
||||
if cred.Kind != models.CredentialKindTelegram {
|
||||
return fmt.Errorf("credential %d is not telegram (kind=%s)", cred.ID, cred.Kind)
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(chatIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := botAPIForCredential(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := tgbotapi.NewMessage(chatID, message)
|
||||
_, err = client.Send(msg)
|
||||
recordSentMessage(chatID, message, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Start starts the Telegram bot update loop.
|
||||
func Start() {
|
||||
StartWithCredentialID(0)
|
||||
}
|
||||
|
||||
// StartWithCredentialID starts the Telegram bot update loop for a specific credential.
|
||||
func StartWithCredentialID(credentialID int64) {
|
||||
var err error
|
||||
if credentialID > 0 {
|
||||
bot, err = botAPIForCredentialID(credentialID)
|
||||
} else {
|
||||
err = Init()
|
||||
}
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
log.Printf("Authorized on account %s", bot.Self.UserName)
|
||||
SetBotCommands(bot)
|
||||
if _, err := bot.Request(tgbotapi.DeleteWebhookConfig{DropPendingUpdates: false}); err != nil {
|
||||
log.Println("telegram delete webhook:", err)
|
||||
return
|
||||
}
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 10
|
||||
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
markBotOnline("")
|
||||
go heartbeat()
|
||||
|
||||
for update := range updates {
|
||||
ProcessUpdate(bot, update)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessWebhookUpdate handles one Telegram webhook update for a credential.
|
||||
func ProcessWebhookUpdate(cred *models.NotificationCredential, update tgbotapi.Update) error {
|
||||
client, err := botAPIForCredential(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ProcessUpdate(client, update)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCredentialCommands registers the slash command menu for a credential-backed bot.
|
||||
func SetCredentialCommands(cred *models.NotificationCredential) error {
|
||||
client, err := botAPIForCredential(cred)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
SetBotCommands(client)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessUpdate handles one Telegram update from polling or webhook delivery.
|
||||
func ProcessUpdate(client *tgbotapi.BotAPI, update tgbotapi.Update) {
|
||||
if update.Message == nil { // ignore any non-Message updates for now
|
||||
return
|
||||
}
|
||||
recordReceivedMessage(update.Message, nil)
|
||||
markBotOnline("")
|
||||
|
||||
if !update.Message.IsCommand() {
|
||||
return
|
||||
}
|
||||
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "")
|
||||
contact, err := contactForMessage(update.Message)
|
||||
if err != nil {
|
||||
msg.Text = "Внутренняя ошибка rsmon: " + err.Error()
|
||||
sendAndRecord(client, msg)
|
||||
return
|
||||
}
|
||||
|
||||
msg.Text = commandResponse(update.Message, contact)
|
||||
sendAndRecord(client, msg)
|
||||
}
|
||||
|
||||
func contactForMessage(message *tgbotapi.Message) (models.Contact, error) {
|
||||
var name string
|
||||
switch message.Chat.Type {
|
||||
case "private":
|
||||
name = strings.TrimSpace("@" + strings.TrimSpace(message.Chat.UserName+" "+message.Chat.FirstName+" "+message.Chat.LastName))
|
||||
case "group", "supergroup":
|
||||
name = message.Chat.Title
|
||||
default:
|
||||
return models.Contact{}, fmt.Errorf("unknown chat type: %s", message.Chat.Type)
|
||||
}
|
||||
return GetContact(message.Chat.Type, name, message.Chat.ID)
|
||||
}
|
||||
|
||||
func commandResponse(message *tgbotapi.Message, contact models.Contact) string {
|
||||
baseURL := strings.TrimRight(os.Getenv("BASE_URL"), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
link := baseURL + "/telegram?token=" + contact.Token
|
||||
switch message.Command() {
|
||||
case "start":
|
||||
return "Для завершения добавления Telegram-уведомлений перейдите по ссылке:\n" + link +
|
||||
"\n\n/id - показать ID чата\n/info - статус привязки\n/settings - настройки\n/stop - отключить уведомления"
|
||||
case "id":
|
||||
return fmt.Sprintf("chat_id: %d\ntype: %s", message.Chat.ID, message.Chat.Type)
|
||||
case "info":
|
||||
return contactInfo(contact, link)
|
||||
case "settings":
|
||||
return "Настройки Telegram-контакта доступны в RSMon:\n" + link + "\n\n/stop - отключить уведомления для этого чата"
|
||||
case "stop":
|
||||
disabled, disableErr := disableChatNotifications(message.Chat.ID)
|
||||
if disableErr != nil {
|
||||
return "Не удалось отключить уведомления: " + disableErr.Error()
|
||||
}
|
||||
return fmt.Sprintf("Telegram-уведомления для этого чата отключены: %d", disabled)
|
||||
case "help":
|
||||
return helpText()
|
||||
default:
|
||||
return "Неизвестная команда. " + helpText()
|
||||
}
|
||||
}
|
||||
|
||||
func contactInfo(contact models.Contact, link string) string {
|
||||
bound := contact.UserID != nil || contact.AccountID != nil
|
||||
status := "не привязан"
|
||||
if bound {
|
||||
status = "привязан"
|
||||
}
|
||||
return fmt.Sprintf("Контакт: %s\nТип: %s\nID: %s\nСтатус: %s\nСсылка настройки: %s", contact.Name, contact.Kind, contact.Value, status, link)
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return "/start - подключить Telegram-уведомления\n" +
|
||||
"/id - показать ID чата\n" +
|
||||
"/info - информация о контакте\n" +
|
||||
"/settings - ссылка на настройки\n" +
|
||||
"/stop - отключить уведомления"
|
||||
}
|
||||
|
||||
func sendAndRecord(client *tgbotapi.BotAPI, msg tgbotapi.MessageConfig) {
|
||||
_, err := client.Send(msg)
|
||||
recordSentMessage(msg.ChatID, msg.Text, err)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetBotCommands registers slash command menus for private and group chats.
|
||||
func SetBotCommands(client *tgbotapi.BotAPI) {
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
commands := []tgbotapi.BotCommand{
|
||||
{Command: "start", Description: "Подключить Telegram-уведомления"},
|
||||
{Command: "id", Description: "Показать ID чата"},
|
||||
{Command: "info", Description: "Информация о привязке"},
|
||||
{Command: "settings", Description: "Настройки контакта"},
|
||||
{Command: "stop", Description: "Отключить уведомления"},
|
||||
{Command: "help", Description: "Список команд"},
|
||||
}
|
||||
if _, err := client.Request(tgbotapi.NewSetMyCommands(commands...)); err != nil {
|
||||
log.Println("telegram set commands:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func heartbeat() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
markBotOnline("")
|
||||
}
|
||||
}
|
||||
|
||||
func markBotOnline(lastErr string) {
|
||||
now := time.Now()
|
||||
username := ""
|
||||
if bot != nil {
|
||||
username = bot.Self.UserName
|
||||
}
|
||||
status := models.TelegramBotStatus{}
|
||||
models.DB().Where("name = ?", models.TelegramBotStatusMain).
|
||||
FirstOrCreate(&status, models.TelegramBotStatus{Name: models.TelegramBotStatusMain})
|
||||
status.Username = username
|
||||
status.Online = true
|
||||
status.LastSeen = &now
|
||||
status.LastError = lastErr
|
||||
_ = models.DB().Save(&status).Error
|
||||
}
|
||||
|
||||
func recordReceivedMessage(message *tgbotapi.Message, contactID *int64) {
|
||||
if message == nil || message.Chat == nil {
|
||||
return
|
||||
}
|
||||
username := ""
|
||||
if message.From != nil {
|
||||
username = message.From.UserName
|
||||
}
|
||||
record := models.TelegramBotMessage{
|
||||
Direction: models.TelegramBotMessageReceived,
|
||||
ChatID: message.Chat.ID,
|
||||
ChatType: message.Chat.Type,
|
||||
Username: username,
|
||||
Text: message.Text,
|
||||
Command: message.Command(),
|
||||
ContactID: contactID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_ = models.DB().Create(&record).Error
|
||||
}
|
||||
|
||||
func recordSentMessage(chatID int64, text string, sendErr error) {
|
||||
errorText := ""
|
||||
if sendErr != nil {
|
||||
errorText = sendErr.Error()
|
||||
}
|
||||
record := models.TelegramBotMessage{
|
||||
Direction: models.TelegramBotMessageSent,
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
Error: errorText,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
_ = models.DB().Create(&record).Error
|
||||
}
|
||||
|
||||
func disableChatNotifications(chatID int64) (int64, error) {
|
||||
value := strconv.FormatInt(chatID, 10)
|
||||
result := models.DB().Model(&models.Contact{}).
|
||||
Where("kind IN ? AND value = ?", []string{"telegram_private", "telegram_group"}, value).
|
||||
Update("enabled", false)
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
14
internal/tg/bot_test.go
Обычный файл
14
internal/tg/bot_test.go
Обычный файл
@@ -0,0 +1,14 @@
|
||||
package tg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTelegramAPIEndpoint(t *testing.T) {
|
||||
assert.Equal(t, tgbotapi.APIEndpoint, telegramAPIEndpoint(""))
|
||||
assert.Equal(t, "https://api.telegram.org/bot%s/%s", telegramAPIEndpoint("https://api.telegram.org"))
|
||||
assert.Equal(t, "https://proxy.example.com/telegram/bot%s/%s", telegramAPIEndpoint("https://proxy.example.com/telegram/"))
|
||||
}
|
||||
78
internal/tg/debug/main.go
Обычный файл
78
internal/tg/debug/main.go
Обычный файл
@@ -0,0 +1,78 @@
|
||||
// Package main provides functionality.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
_ "rsgit.ru/rsmon/rsmon/config/env"
|
||||
)
|
||||
|
||||
func main() {
|
||||
database.Init()
|
||||
creds, err := models.EnabledCredentialsByKind(models.CredentialKindTelegram)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
if len(creds) == 0 {
|
||||
log.Panic("telegram credential is not configured")
|
||||
}
|
||||
token, err := creds[0].GetSecret()
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
bot, err := tgbotapi.NewBotAPI(token)
|
||||
if err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
|
||||
bot.Debug = true
|
||||
|
||||
log.Printf("Authorized on account %s", bot.Self.UserName)
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message == nil { // ignore any non-Message updates
|
||||
continue
|
||||
}
|
||||
|
||||
if !update.Message.IsCommand() { // ignore any non-command Messages
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a new MessageConfig. We don't have text yet,
|
||||
// so we leave it empty.
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "")
|
||||
|
||||
spew.Dump(update.Message)
|
||||
spew.Dump(update.Message.Chat)
|
||||
|
||||
switch update.Message.Chat.Type {
|
||||
case "private":
|
||||
// Extract the command from the Message.
|
||||
switch update.Message.Command() {
|
||||
case "start":
|
||||
msg.Text = "Для завершения добавления вида оповещений перейдите по ссылке https://rsmon.ru/contacts/new?kind=telegram&"
|
||||
case "help":
|
||||
msg.Text = "/start - добавление способа оповещений\n/list список способов оповещений для этого чата\n"
|
||||
default:
|
||||
msg.Text = "Неизвестная команда"
|
||||
}
|
||||
case "group":
|
||||
default:
|
||||
msg.Text = "Неизвестный тип чата: " + update.Message.Chat.Type
|
||||
}
|
||||
|
||||
if _, err := bot.Send(msg); err != nil {
|
||||
log.Panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
35
internal/util/format_duration.go
Обычный файл
35
internal/util/format_duration.go
Обычный файл
@@ -0,0 +1,35 @@
|
||||
// Package util provides functionality.
|
||||
package util
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/config/translator"
|
||||
)
|
||||
|
||||
// FormatDuration provides functionality.
|
||||
func FormatDuration(duration int64) string {
|
||||
// spew.Dump(translator.Translator)
|
||||
hours := duration / 3600
|
||||
minutes := (duration - hours*3600) / 60
|
||||
// seconds := duration % 60
|
||||
str := ""
|
||||
if hours > 0 {
|
||||
tr, err := translator.Translator.C("hours", float64(hours), 0, strconv.FormatInt(hours, 10))
|
||||
if err != nil {
|
||||
log.Println("translator error", err)
|
||||
return ""
|
||||
}
|
||||
str = str + tr + ", "
|
||||
}
|
||||
|
||||
tr, err := translator.Translator.C("minutes", float64(minutes), 0, strconv.FormatInt(minutes, 10))
|
||||
if err != nil {
|
||||
log.Println("translator error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
str += tr
|
||||
return str
|
||||
}
|
||||
15
internal/util/format_duration_test.go
Обычный файл
15
internal/util/format_duration_test.go
Обычный файл
@@ -0,0 +1,15 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
assert.Equal(t, "1 минуту", FormatDuration(60), "")
|
||||
assert.Equal(t, "2 минуты", FormatDuration(120), "")
|
||||
assert.Equal(t, "5 минут", FormatDuration(300), "")
|
||||
assert.Equal(t, "1 час, 5 минут", FormatDuration(3600+300), "")
|
||||
assert.Equal(t, "1 час, 0 минут", FormatDuration(3600), "")
|
||||
}
|
||||
31
internal/util/unix/pidfile.go
Обычный файл
31
internal/util/unix/pidfile.go
Обычный файл
@@ -0,0 +1,31 @@
|
||||
// Package unix provides functionality.
|
||||
package unix
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// WritePidFile provides functionality.
|
||||
// Write a pid file, but first make sure it doesn't exist with a running pid.
|
||||
func WritePidFile(pidFile string) error {
|
||||
// Read in the pid file as a slice of bytes.
|
||||
if piddata, err := os.ReadFile(pidFile); err == nil {
|
||||
// Convert the file contents to an integer.
|
||||
if pid, err := strconv.Atoi(string(piddata)); err == nil {
|
||||
// Look for the pid in the process list.
|
||||
if process, err := os.FindProcess(pid); err == nil {
|
||||
// Send the process a signal zero kill.
|
||||
if err := process.Signal(syscall.Signal(0)); err == nil {
|
||||
// We only get an error if the pid isn't running, or it's not ours.
|
||||
return fmt.Errorf("pid already running: %d", pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we get here, then the pidfile didn't exist,
|
||||
// or the pid in it doesn't belong to the user running this app.
|
||||
return os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", os.Getpid())), 0o664)
|
||||
}
|
||||
45
internal/webapp/auth_password.go
Обычный файл
45
internal/webapp/auth_password.go
Обычный файл
@@ -0,0 +1,45 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// bcryptCost is the work factor for new bcrypt hashes. Matches the
|
||||
// "cost 12" assumption from docs/distributed/worker-web-app.md
|
||||
// section 5.2; the cost applies to first-run and password-change
|
||||
// hashes alike.
|
||||
const bcryptCost = 12
|
||||
|
||||
// HashPassword bcrypts the given plaintext password at the package's
|
||||
// configured cost. Returns the encoded hash ready to be persisted.
|
||||
func HashPassword(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", fmt.Errorf("webapp: empty password")
|
||||
}
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("webapp: bcrypt hash: %w", err)
|
||||
}
|
||||
return string(h), nil
|
||||
}
|
||||
|
||||
// VerifyPassword reports whether the given plaintext matches the
|
||||
// given bcrypt hash. A nil error means the password is correct.
|
||||
func VerifyPassword(hash, plain string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
|
||||
}
|
||||
|
||||
// MaskToken returns the last 4 characters of a token prefixed with a
|
||||
// star mask, e.g. "****abcd". Empty input yields "—". Used on the
|
||||
// settings page where the worker's bearer token is shown read-only.
|
||||
func MaskToken(token string) string {
|
||||
if token == "" {
|
||||
return "—"
|
||||
}
|
||||
if len(token) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return "****" + token[len(token)-4:]
|
||||
}
|
||||
48
internal/webapp/auth_password_test.go
Обычный файл
48
internal/webapp/auth_password_test.go
Обычный файл
@@ -0,0 +1,48 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateFirstRunPassword(t *testing.T) {
|
||||
a, err := GenerateFirstRunPassword()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, a)
|
||||
// 24 bytes -> 32 base64 chars (RawURLEncoding, no padding).
|
||||
assert.Len(t, a, 32, "first-run password length")
|
||||
|
||||
// Two calls must produce different passwords (statistically
|
||||
// certain with crypto/rand).
|
||||
b, err := GenerateFirstRunPassword()
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, a, b, "two calls must produce distinct passwords")
|
||||
}
|
||||
|
||||
func TestHashAndVerifyPassword(t *testing.T) {
|
||||
const plain = "correct horse battery staple"
|
||||
hash, err := HashPassword(plain)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hash)
|
||||
assert.True(t, strings.HasPrefix(hash, "$2a$"),
|
||||
"bcrypt hash should start with $2a$")
|
||||
|
||||
require.NoError(t, VerifyPassword(hash, plain),
|
||||
"correct password must verify")
|
||||
assert.Error(t, VerifyPassword(hash, "wrong password"),
|
||||
"wrong password must not verify")
|
||||
}
|
||||
|
||||
func TestHashPasswordRejectsEmpty(t *testing.T) {
|
||||
_, err := HashPassword("")
|
||||
assert.Error(t, err, "empty plaintext must be rejected")
|
||||
}
|
||||
|
||||
func TestMaskToken(t *testing.T) {
|
||||
assert.Equal(t, "—", MaskToken(""))
|
||||
assert.Equal(t, "****", MaskToken("abcd"))
|
||||
assert.Equal(t, "****wxyz", MaskToken("abcdefghwxyz"))
|
||||
}
|
||||
33
internal/webapp/auth_sessionid.go
Обычный файл
33
internal/webapp/auth_sessionid.go
Обычный файл
@@ -0,0 +1,33 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GenerateFirstRunPassword returns a fresh random password suitable
|
||||
// for the worker's first-run webapp credential. The output is 24
|
||||
// bytes of crypto/rand encoded as URL-safe base64 (no padding), which
|
||||
// is roughly 32 characters long and safe to print once into the
|
||||
// worker log.
|
||||
//
|
||||
// Phase 1 (MVP) only: a stronger entropy scheme can replace this in
|
||||
// later phases if needed.
|
||||
func GenerateFirstRunPassword() (string, error) {
|
||||
buf := make([]byte, 24)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("webapp: read random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// newSessionID is the underlying primitive for both session cookies
|
||||
// and CSRF tokens: 32 bytes from crypto/rand, URL-safe base64.
|
||||
func newSessionID() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("webapp: read random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
47
internal/webapp/constants.go
Обычный файл
47
internal/webapp/constants.go
Обычный файл
@@ -0,0 +1,47 @@
|
||||
package webapp
|
||||
|
||||
// Audit constants used across the webapp audit-log writes. They
|
||||
// live in their own file so goconst sees them as named values
|
||||
// rather than scattered string literals.
|
||||
const (
|
||||
auditActorLocal = "operator"
|
||||
auditRoleAdmin = "admin"
|
||||
auditAuthModeLocal = "local"
|
||||
auditAuthModeBasic = "basic_auth"
|
||||
auditTargetSelf = "self"
|
||||
auditActionLogin = "login"
|
||||
auditActionLoginFail = "login_failed"
|
||||
auditActionLogout = "logout"
|
||||
auditActionPassChange = "password_change"
|
||||
)
|
||||
|
||||
// Inventory source labels. Phase 1 only emits "process"; Phase 3
|
||||
// adds "compose" and "docker".
|
||||
const (
|
||||
inventorySourceProcess = "process"
|
||||
)
|
||||
|
||||
// Environment variable names referenced by ConfigFromEnv. Lifted out
|
||||
// so the validator and the cmd binary share the same constants.
|
||||
const (
|
||||
envWorkerHost = "WORKER_HOST"
|
||||
envWorkerPort = "WORKER_PORT"
|
||||
envWorkerURL = "WORKER_URL"
|
||||
envWorkerLogin = "WORKER_LOGIN"
|
||||
envWorkerPassword = "WORKER_PASSWORD"
|
||||
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
|
||||
envClusterDebugApply = "WORKER_CLUSTER_DEBUG_APPLY"
|
||||
envReleaseURL = "WORKER_RELEASE_URL"
|
||||
)
|
||||
|
||||
// Route paths used as redirect targets. Lifted out so goconst stops
|
||||
// flagging the duplicates across handlers.
|
||||
const (
|
||||
pathOverview = "/overview"
|
||||
pathChangePassword = "/web/change-password"
|
||||
pathLogin = "/web/login"
|
||||
)
|
||||
|
||||
// basicAuthRealm is the value returned in the WWW-Authenticate
|
||||
// header. Fixed string so scripted callers can match on it.
|
||||
const basicAuthRealm = `Basic realm="rsmon-worker"`
|
||||
10
internal/webapp/cteq.go
Обычный файл
10
internal/webapp/cteq.go
Обычный файл
@@ -0,0 +1,10 @@
|
||||
package webapp
|
||||
|
||||
import "crypto/subtle"
|
||||
|
||||
// constantTimeEq is a thin wrapper around crypto/subtle.ConstantTimeCompare
|
||||
// so the middleware file does not need an extra import for a single
|
||||
// call.
|
||||
func constantTimeEq(a, b string) int {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b))
|
||||
}
|
||||
18
internal/webapp/doc.go
Обычный файл
18
internal/webapp/doc.go
Обычный файл
@@ -0,0 +1,18 @@
|
||||
// Package webapp implements the local web UI for the distributed
|
||||
// monitoring worker. See docs/distributed/worker-web-app.md.
|
||||
//
|
||||
// Phase 1 (MVP) implements:
|
||||
//
|
||||
// - Local-only auth (section 5.3): first-run password printed to
|
||||
// the worker log, bcrypt-hashed in the local SQLite store, forced
|
||||
// change on first login, session cookie with HTTP-only/Secure
|
||||
// (loopback-aware)/SameSite=Strict.
|
||||
// - Pages: overview, discovered apps (read-only), checks
|
||||
// (read-only), notifications (read-only), logs (worker log only),
|
||||
// settings (worker fields), updates.
|
||||
// - Server status: /proc and sysfs only (no SMART, no docker).
|
||||
// - Audit log with 7-day retention.
|
||||
//
|
||||
// Phase 2+ (OAuth, basic auth, compose management, public bind,
|
||||
// docker socket, secret storage) is explicitly out of scope here.
|
||||
package webapp
|
||||
103
internal/webapp/handlers_apps.go
Обычный файл
103
internal/webapp/handlers_apps.go
Обычный файл
@@ -0,0 +1,103 @@
|
||||
package webapp
|
||||
|
||||
import "net/http"
|
||||
|
||||
// handleApps lists the inventory rows the most recent refresh loop
|
||||
// persisted. Each row links to the detail view at /apps/:id, which
|
||||
// Phase 1 implements as a single-process summary (comm, cmdline,
|
||||
// cwd, ports, uptime).
|
||||
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
apps := s.inventory.Snapshot()
|
||||
data := appsPageData{
|
||||
basePageData: s.newBasePage(r, "Discovered apps", sess),
|
||||
Apps: apps,
|
||||
}
|
||||
if err := s.templates.Execute(w, "apps.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render apps: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAppDetail renders the detail page for a single inventory
|
||||
// row. Phase 1 has no grouping, so :id is the row index in the
|
||||
// snapshot (matching the table id column).
|
||||
func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
id := r.PathValue("id")
|
||||
apps := s.inventory.Snapshot()
|
||||
var found *DiscoveredApp
|
||||
for i := range apps {
|
||||
if idMatch(&apps[i], id, i) {
|
||||
found = &apps[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data := appDetailPageData{
|
||||
basePageData: s.newBasePage(r, "App: "+found.Name, sess),
|
||||
App: *found,
|
||||
}
|
||||
if err := s.templates.Execute(w, "app_detail.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render app detail: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// idMatch matches either by pid (when id is a positive integer) or
|
||||
// by name (otherwise). Keeps URLs short and avoids leaking pids to
|
||||
// browser history.
|
||||
func idMatch(a *DiscoveredApp, id string, idx int) bool {
|
||||
if a.Name == id {
|
||||
return true
|
||||
}
|
||||
if id == pidOrIndex(a, idx) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pidOrIndex(a *DiscoveredApp, idx int) string {
|
||||
if a.PID > 0 {
|
||||
return itoa(a.PID)
|
||||
}
|
||||
return itoa(idx)
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
const digits = "0123456789"
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = digits[n%10]
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
type appsPageData struct {
|
||||
basePageData
|
||||
Apps []DiscoveredApp
|
||||
}
|
||||
|
||||
type appDetailPageData struct {
|
||||
basePageData
|
||||
App DiscoveredApp
|
||||
}
|
||||
390
internal/webapp/handlers_auth.go
Обычный файл
390
internal/webapp/handlers_auth.go
Обычный файл
@@ -0,0 +1,390 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleHealth returns 200 OK with a tiny body. Public endpoint so
|
||||
// the operator's tooling (curl, monitoring) can probe the listener
|
||||
// without going through the login form.
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeNoStore(w)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
}
|
||||
|
||||
// handleLoginForm renders the login page. If the operator is already
|
||||
// logged in, they are redirected to /overview.
|
||||
//
|
||||
// When WORKER_LOGIN / WORKER_PASSWORD are configured, the login form
|
||||
// renders a username field and the explanatory copy tells the
|
||||
// operator to use the env-var credentials. Otherwise the form is the
|
||||
// plain first-run password entry (no username).
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, err := s.resolveSession(r)
|
||||
if err == nil && sess != nil {
|
||||
redirectTo(w, r, pathOverview)
|
||||
return
|
||||
}
|
||||
data := loginPageData{
|
||||
basePageData: s.newBasePage(r, "RSMon worker login", nil),
|
||||
Error: strings.TrimSpace(r.URL.Query().Get("error")),
|
||||
NextURL: strings.TrimSpace(r.URL.Query().Get("next")),
|
||||
BasicAuth: s.BasicAuthEnabled(),
|
||||
}
|
||||
if err := s.templates.Execute(w, "login.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render login: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLoginSubmit validates the credentials and starts a session.
|
||||
// On failure: 401 with the login page re-rendered and an error message.
|
||||
//
|
||||
// When basic auth is configured the form must supply BOTH a username
|
||||
// matching WORKER_LOGIN and a password matching WORKER_PASSWORD. The
|
||||
// per-machine bcrypt user is bypassed in that mode (so the operator
|
||||
// can rotate the basic-auth password without touching the bcrypt
|
||||
// store). Local-only mode keeps the original first-run password flow.
|
||||
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
plain := r.FormValue("password")
|
||||
if plain == "" {
|
||||
s.renderLoginError(w, r, "password is required", r.FormValue("next"))
|
||||
return
|
||||
}
|
||||
next := r.FormValue("next")
|
||||
if s.basicAuthOK {
|
||||
s.handleBasicAuthLogin(w, r, plain, next)
|
||||
return
|
||||
}
|
||||
s.handleLocalLogin(w, r, plain, next)
|
||||
}
|
||||
|
||||
// handleBasicAuthLogin verifies the form-submitted password against
|
||||
// WORKER_PASSWORD. The username field is checked against
|
||||
// WORKER_LOGIN and the comparison is constant-time.
|
||||
func (s *Server) handleBasicAuthLogin(w http.ResponseWriter, r *http.Request, password, next string) {
|
||||
login := strings.TrimSpace(r.FormValue("login"))
|
||||
if login == "" {
|
||||
s.renderLoginError(w, r, "username is required", next)
|
||||
return
|
||||
}
|
||||
if subtleEqual(login, s.cfg.BasicAuthLogin) != 1 ||
|
||||
subtleEqual(password, s.cfg.BasicAuthPassword) != 1 {
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeBasic,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: auditActionLoginFail,
|
||||
Target: auditTargetSelf,
|
||||
})
|
||||
s.renderLoginError(w, r, "invalid credentials", next)
|
||||
return
|
||||
}
|
||||
// Mint a synthetic session backed by the local store but tagged
|
||||
// with auth_mode=basic_auth so the audit log distinguishes the
|
||||
// two paths. The bcrypt user is bypassed entirely.
|
||||
if err := s.startSyntheticSession(w, r, "basic_auth"); err != nil {
|
||||
s.deps.Logger.Printf("start synthetic session: %v", err)
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeBasic,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: auditActionLogin,
|
||||
Target: auditTargetSelf,
|
||||
})
|
||||
if next == "" || !strings.HasPrefix(next, "/") {
|
||||
next = pathOverview
|
||||
}
|
||||
redirectTo(w, r, next)
|
||||
}
|
||||
|
||||
// handleLocalLogin is the legacy first-run bcrypt path. Kept as a
|
||||
// separate function so handleLoginSubmit reads top-down without
|
||||
// branching inside one long handler.
|
||||
func (s *Server) handleLocalLogin(w http.ResponseWriter, r *http.Request, plain, next string) {
|
||||
user, err := s.store.GetUser(r.Context())
|
||||
if err != nil {
|
||||
// No user provisioned yet => login is impossible. Surface as
|
||||
// a generic error so we do not leak the "no user" state to
|
||||
// a brute-force attacker.
|
||||
s.renderLoginError(w, r, "invalid credentials", next)
|
||||
return
|
||||
}
|
||||
if err := VerifyPassword(user.BcryptHash, plain); err != nil {
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeLocal,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: auditActionLoginFail,
|
||||
Target: auditTargetSelf,
|
||||
})
|
||||
s.renderLoginError(w, r, "invalid credentials", next)
|
||||
return
|
||||
}
|
||||
if err := s.startSession(w, r, user); err != nil {
|
||||
s.deps.Logger.Printf("start session: %v", err)
|
||||
http.Error(w, "session error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeLocal,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: auditActionLogin,
|
||||
Target: auditTargetSelf,
|
||||
})
|
||||
// The requires_change flag is recorded on the user row for
|
||||
// future hardening (a per-install "force rotation" toggle), but
|
||||
// the login flow does not bounce operators to /web/change-password
|
||||
// on first login any more. Frictionless first-login is the
|
||||
// current default; the change-password page is still reachable
|
||||
// from /settings.
|
||||
if next == "" || !strings.HasPrefix(next, "/") {
|
||||
next = pathOverview
|
||||
}
|
||||
redirectTo(w, r, next)
|
||||
}
|
||||
|
||||
// startSyntheticSession mints a session row that is NOT bound to the
|
||||
// bcrypt user. Used by the basic-auth login flow. The user_id is
|
||||
// re-used (the row in webapp_users still exists for the local-mode
|
||||
// fallback) so foreign-key-free audit inserts keep working.
|
||||
func (s *Server) startSyntheticSession(w http.ResponseWriter, r *http.Request, _ string) error {
|
||||
user, err := s.store.GetUser(r.Context())
|
||||
if err != nil {
|
||||
// No bcrypt user yet: synthesize an anonymous row so the
|
||||
// session has a user_id to point at. The local-only path
|
||||
// will eventually upgrade this to a real user on first
|
||||
// basic-auth-less login.
|
||||
if cerr := s.store.EnsureAnonymousUser(r.Context()); cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
user, err = s.store.GetUser(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.startSession(w, r, user)
|
||||
}
|
||||
|
||||
// subtleEqual wraps crypto/subtle.ConstantTimeCompare so the
|
||||
// handler body stays free of import noise. Returns 1 on match.
|
||||
func subtleEqual(a, b string) int {
|
||||
return constantTimeEq(a, b)
|
||||
}
|
||||
|
||||
// handleLogout deletes the session row and clears the cookies. The
|
||||
// logout endpoint is a POST so a stray GET cannot end a session via
|
||||
// link prefetch.
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := s.resolveSession(r)
|
||||
clearSessionCookie(w, r)
|
||||
if sess != nil {
|
||||
_ = s.store.DeleteSession(r.Context(), sess.ID)
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeLocal,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: auditActionLogout,
|
||||
Target: auditTargetSelf,
|
||||
})
|
||||
}
|
||||
redirectTo(w, r, pathLogin)
|
||||
}
|
||||
|
||||
// handleChangePasswordForm renders the change-password page.
|
||||
func (s *Server) handleChangePasswordForm(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, ok := sessionFromContext(r.Context())
|
||||
if !ok {
|
||||
redirectTo(w, r, pathLogin)
|
||||
return
|
||||
}
|
||||
if !s.requireCSRF(sess, r) {
|
||||
http.Error(w, "csrf token required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
data := changePasswordPageData{
|
||||
basePageData: s.newBasePage(r, "Change password", sess),
|
||||
MinStrength: minPasswordLength,
|
||||
}
|
||||
if err := s.templates.Execute(w, "change_password.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render change-password: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleChangePasswordSubmit rotates the user's bcrypt hash and
|
||||
// clears the requires_change flag. On success, the operator lands on
|
||||
// /overview. On any failure, the change-password page re-renders
|
||||
// with an error message.
|
||||
func (s *Server) handleChangePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, ok := sessionFromContext(r.Context())
|
||||
if !ok {
|
||||
redirectTo(w, r, pathLogin)
|
||||
return
|
||||
}
|
||||
if !s.requireCSRF(sess, r) {
|
||||
http.Error(w, "csrf token required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
current := r.FormValue("current_password")
|
||||
next := r.FormValue("new_password")
|
||||
confirm := r.FormValue("new_password_confirm")
|
||||
user, err := s.store.GetUser(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "no user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := VerifyPassword(user.BcryptHash, current); err != nil {
|
||||
s.renderChangePasswordError(w, r, sess, "current password is incorrect")
|
||||
return
|
||||
}
|
||||
if !validPasswordStrength(next) {
|
||||
s.renderChangePasswordError(w, r, sess, fmt.Sprintf("new password must be at least %d characters", minPasswordLength))
|
||||
return
|
||||
}
|
||||
if next != confirm {
|
||||
s.renderChangePasswordError(w, r, sess, "new password and confirmation do not match")
|
||||
return
|
||||
}
|
||||
newHash, err := HashPassword(next)
|
||||
if err != nil {
|
||||
http.Error(w, "hash error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdatePassword(r.Context(), user.ID, newHash); err != nil {
|
||||
http.Error(w, "update error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeLocal,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: auditActionPassChange,
|
||||
Target: fmt.Sprintf("user:%d", user.ID),
|
||||
BeforeHash: user.BcryptHash,
|
||||
AfterHash: newHash,
|
||||
})
|
||||
redirectTo(w, r, pathOverview)
|
||||
}
|
||||
|
||||
// minPasswordLength matches the bcrypt minimum the worker enforces
|
||||
// (bcrypt silently truncates after 72 bytes; the minimum is a UX
|
||||
// floor so the operator does not pick "a").
|
||||
const minPasswordLength = 8
|
||||
|
||||
func validPasswordStrength(p string) bool {
|
||||
return len(p) >= minPasswordLength
|
||||
}
|
||||
|
||||
// startSession creates a session row with a fresh id and CSRF token,
|
||||
// persists it, and sets the cookies.
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *User) error {
|
||||
id, err := newSessionID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
csrf, err := newSessionID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
sess := Session{
|
||||
ID: id,
|
||||
UserID: user.ID,
|
||||
CSRFToken: csrf,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.SessionAbs),
|
||||
}
|
||||
if err := s.store.CreateSession(r.Context(), &sess); err != nil {
|
||||
return err
|
||||
}
|
||||
s.writeSessionCookie(w, r, &sess)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderLoginError renders the login page with an inline error
|
||||
// message. We deliberately do NOT use http.StatusUnauthorized here;
|
||||
// the status is 200 so an interactive operator gets the form back
|
||||
// with the error visible, not a browser auth dialog.
|
||||
func (s *Server) renderLoginError(w http.ResponseWriter, r *http.Request, msg, next string) {
|
||||
data := loginPageData{
|
||||
basePageData: s.newBasePage(r, "RSMon worker login", nil),
|
||||
Error: msg,
|
||||
NextURL: next,
|
||||
BasicAuth: s.BasicAuthEnabled(),
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := s.templates.Execute(w, "login.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render login error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderChangePasswordError renders the change-password form with an
|
||||
// inline error. The CSRF token is reused from the current session so
|
||||
// the operator does not have to reload to retry.
|
||||
func (s *Server) renderChangePasswordError(w http.ResponseWriter, r *http.Request, sess *Session, msg string) {
|
||||
data := changePasswordPageData{
|
||||
basePageData: s.newBasePage(r, "Change password", sess),
|
||||
Error: msg,
|
||||
MinStrength: minPasswordLength,
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := s.templates.Execute(w, "change_password.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render change-password error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// loginPageData is the input to the login.html template.
|
||||
type loginPageData struct {
|
||||
basePageData
|
||||
Error string
|
||||
NextURL string
|
||||
BasicAuth bool // true when WORKER_LOGIN/WORKER_PASSWORD are configured; the form must show a username field
|
||||
}
|
||||
|
||||
// changePasswordPageData is the input to the change_password.html
|
||||
// template.
|
||||
type changePasswordPageData struct {
|
||||
basePageData
|
||||
Error string
|
||||
MinStrength int
|
||||
}
|
||||
84
internal/webapp/handlers_checks.go
Обычный файл
84
internal/webapp/handlers_checks.go
Обычный файл
@@ -0,0 +1,84 @@
|
||||
package webapp
|
||||
|
||||
import "net/http"
|
||||
|
||||
// handleChecks renders the worker's recent result rows. The
|
||||
// runner's in-memory ring buffer supplies the data; Phase 1 reads
|
||||
// from it directly with no extra caching.
|
||||
//
|
||||
// A "Run now" button is rendered on the page but stays disabled
|
||||
// until the control plane accepts one-off check hints. The hint
|
||||
// protocol is gated on the worker-notifier MVP plan
|
||||
// (docs/plans/worker-notifier-mvp.md §N) and on
|
||||
// docs/plans/separate-checks.md §11.6; once both are in place the
|
||||
// RunNowEnabled flag flips to true and the handler reads
|
||||
// s.deps.Runner.SubmitCheckHint(...) instead of the placeholder.
|
||||
func (s *Server) handleChecks(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderRunnerPage(w, r, "Recent checks", "checks.html", func() any {
|
||||
return checksPageData{
|
||||
basePageData: s.newBasePage(r, "Recent checks", sessionFromContextOrEmpty(r)),
|
||||
Rows: s.deps.Runner.RecentResults(50),
|
||||
RunNowEnabled: false,
|
||||
RunNowTooltip: "Run-now lands with the worker hint protocol (worker-notifier-mvp.md §N + separate-checks.md §11.6).",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type checksPageData struct {
|
||||
basePageData
|
||||
Rows []ResultRow
|
||||
RunNowEnabled bool
|
||||
RunNowTooltip string
|
||||
}
|
||||
|
||||
// handleNotifications renders the worker's recent notification
|
||||
// rows. Phase 1 only emits selfcheck alerts (email/telegram via the
|
||||
// cached credentials), but the runner ring buffer is shape-stable
|
||||
// for the main-app-issued notifications coming online in a later
|
||||
// phase.
|
||||
//
|
||||
// A "Resend" button is rendered on the page but stays disabled
|
||||
// until the worker resend protocol exists. Same gating as the
|
||||
// "Run now" button on /checks.
|
||||
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderRunnerPage(w, r, "Recent notifications", "notifications.html", func() any {
|
||||
return notificationsPageData{
|
||||
basePageData: s.newBasePage(r, "Recent notifications", sessionFromContextOrEmpty(r)),
|
||||
Rows: s.deps.Runner.RecentNotifications(50),
|
||||
ResendEnabled: false,
|
||||
ResendTooltip: "Resend lands with the worker notification resend protocol (worker-notifier-mvp.md §N).",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type notificationsPageData struct {
|
||||
basePageData
|
||||
Rows []NotificationRow
|
||||
ResendEnabled bool
|
||||
ResendTooltip string
|
||||
}
|
||||
|
||||
// renderRunnerPage is the small boilerplate-killer shared by
|
||||
// handleChecks / handleNotifications / handleApps: write the
|
||||
// no-store header, look up the session, build the page data via the
|
||||
// caller-supplied closure, execute the template, and log + 500 on
|
||||
// error. The closure receives no arguments because each handler
|
||||
// already has its own copy of *Server and *http.Request in scope
|
||||
// (this method is bound to *Server, so the closure captures them).
|
||||
func (s *Server) renderRunnerPage(w http.ResponseWriter, _ *http.Request, logName, tmpl string, build func() any) {
|
||||
writeNoStore(w)
|
||||
if err := s.templates.Execute(w, tmpl, build()); err != nil {
|
||||
s.deps.Logger.Printf("render %s: %v", logName, err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionFromContextOrEmpty is a thin wrapper around
|
||||
// sessionFromContext that returns a nil session instead of a bool,
|
||||
// for handlers that pass the session straight into a page-data
|
||||
// struct (the Session zero value is harmless for template
|
||||
// rendering).
|
||||
func sessionFromContextOrEmpty(r *http.Request) *Session {
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
return sess
|
||||
}
|
||||
136
internal/webapp/handlers_cluster.go
Обычный файл
136
internal/webapp/handlers_cluster.go
Обычный файл
@@ -0,0 +1,136 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// clusterStatusResponse is the JSON the operator-facing
|
||||
// /web/api/cluster/status endpoint returns. The shape is stable so the
|
||||
// e2e shell script and any future frontend pages can pin against it.
|
||||
//
|
||||
// FSMConfigVersion / FSMOutboxLen / FSMPartition surface the FSM-side
|
||||
// operator signals from plan section 6.1 (config_version, outbox
|
||||
// length, partition_state) so a single GET tells the operator what
|
||||
// config the cluster has adopted, whether the notification outbox is
|
||||
// draining, and whether the cluster sees itself as partitioned.
|
||||
type clusterStatusResponse struct {
|
||||
SelfID string `json:"self_id"`
|
||||
Role string `json:"role"`
|
||||
Term uint64 `json:"term"`
|
||||
LeaderID string `json:"leader_id"`
|
||||
Voters []string `json:"voters"`
|
||||
AppliedIndex uint64 `json:"applied_index"`
|
||||
CommitIndex uint64 `json:"commit_index"`
|
||||
FSMChecks int `json:"fsm_checks"`
|
||||
FSMMembers int `json:"fsm_membership"`
|
||||
FSMConfigVersion uint64 `json:"fsm_config_version"`
|
||||
FSMOutboxLen int `json:"fsm_outbox_len"`
|
||||
FSMPartition string `json:"fsm_partition"`
|
||||
ClusterID string `json:"cluster_id"`
|
||||
LocalAddr string `json:"local_addr"`
|
||||
}
|
||||
|
||||
// handleClusterStatus serializes the current cluster state for the
|
||||
// operator. Returns 503 if no cluster is attached; 200 otherwise.
|
||||
//
|
||||
// Admin-only: the worker webapp is single-tenant so the session
|
||||
// middleware (requireSession) is the admin check. Cross-tenant
|
||||
// protection is not required at this layer.
|
||||
//
|
||||
// The `r` parameter is unused but kept so the signature matches
|
||||
// http.HandlerFunc (the route is registered via requireSession).
|
||||
func (s *Server) handleClusterStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
writeNoStore(w)
|
||||
if s.cluster == nil {
|
||||
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
stats := s.cluster.Stats()
|
||||
resp := clusterStatusResponse{
|
||||
SelfID: stats.NodeID,
|
||||
Role: stats.State,
|
||||
Term: stats.Term,
|
||||
LeaderID: stats.Leader,
|
||||
Voters: stats.Voters,
|
||||
AppliedIndex: stats.AppliedIndex,
|
||||
CommitIndex: stats.LastIndex,
|
||||
FSMChecks: stats.FSMChecks,
|
||||
FSMMembers: stats.FSMMembers,
|
||||
FSMConfigVersion: stats.FSMConfigVersion,
|
||||
FSMOutboxLen: stats.FSMOutboxLen,
|
||||
FSMPartition: stats.FSMPartition,
|
||||
ClusterID: s.cluster.ClusterID(),
|
||||
LocalAddr: s.cluster.LocalAddr(),
|
||||
}
|
||||
if resp.Voters == nil {
|
||||
resp.Voters = []string{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
s.deps.Logger.Printf("cluster status encode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleClusterApplyTestConfig applies a hardcoded config.adopt log
|
||||
// entry to the cluster. It exists so the e2e script and any operator
|
||||
// debugging session can verify FSM replication without having to wire
|
||||
// up the real signed-config-adoption producer (which lives in a later
|
||||
// phase).
|
||||
//
|
||||
// DEBUG: this endpoint is a placeholder for the real producer. It must
|
||||
// be replaced (or removed) before any production deployment.
|
||||
//
|
||||
// The handler is gated behind Config.DebugClusterApply (env
|
||||
// WORKER_CLUSTER_DEBUG_APPLY=true). When the flag is false the
|
||||
// handler returns 404 — the route is still registered so the auth
|
||||
// + CSRF paths are exercised in tests, but no real FSM entry is ever
|
||||
// appended from a production webapp.
|
||||
//
|
||||
// TODO(worker-cluster-real-producer): remove the apply-test-config
|
||||
// endpoint entirely once the signed-config-adoption producer ships.
|
||||
func (s *Server) handleClusterApplyTestConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
if !s.cfg.DebugClusterApply {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if s.cluster == nil {
|
||||
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !s.requireCSRF(sessionFromContextOrFail(w, r), r) {
|
||||
http.Error(w, "csrf token required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
applied, err := s.cluster.ApplyTestConfig()
|
||||
if err != nil {
|
||||
s.deps.Logger.Printf("cluster apply test config: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(map[string]uint64{"applied_index": applied}); err != nil {
|
||||
s.deps.Logger.Printf("cluster apply encode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionFromContextOrFail is a tiny adapter so requireCSRF can be
|
||||
// called from this handler without leaking the middleware into the
|
||||
// cluster package. If no session is attached (should not happen
|
||||
// because requireSession already ran) we return a stub session with
|
||||
// no CSRF token, which causes requireCSRF to refuse the request.
|
||||
func sessionFromContextOrFail(_ http.ResponseWriter, r *http.Request) *Session {
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
if sess != nil {
|
||||
return sess
|
||||
}
|
||||
return &Session{}
|
||||
}
|
||||
|
||||
// ErrClusterNotConfigured is returned when a cluster-admin endpoint is
|
||||
// hit on a server without a cluster attached.
|
||||
var ErrClusterNotConfigured = errors.New("webapp: cluster not configured")
|
||||
336
internal/webapp/handlers_cluster_test.go
Обычный файл
336
internal/webapp/handlers_cluster_test.go
Обычный файл
@@ -0,0 +1,336 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubCluster is a minimal ClusterView implementation used by the
|
||||
// handler tests. It returns canned values so the JSON shape can be
|
||||
// pinned without standing up a real raft group.
|
||||
type stubCluster struct {
|
||||
stats ClusterStats
|
||||
applyIndex uint64
|
||||
applyErr error
|
||||
applyCalled int
|
||||
applyMu sync.Mutex
|
||||
clusterIDOut string
|
||||
addrOut string
|
||||
}
|
||||
|
||||
func (s *stubCluster) Stats() ClusterStats { return s.stats }
|
||||
func (s *stubCluster) ApplyTestConfig() (uint64, error) {
|
||||
s.applyMu.Lock()
|
||||
defer s.applyMu.Unlock()
|
||||
s.applyCalled++
|
||||
return s.applyIndex, s.applyErr
|
||||
}
|
||||
func (s *stubCluster) ClusterID() string { return s.clusterIDOut }
|
||||
func (s *stubCluster) LocalAddr() string { return s.addrOut }
|
||||
|
||||
// withClusterServer returns a test server whose ClusterView is the
|
||||
// supplied stub. The first-run password path is also exercised so
|
||||
// the session cookie is available for the cluster-endpoint probes.
|
||||
// Returns the *httptest.Server, the underlying *Server, and the
|
||||
// authenticated http.Client (cookie jar already populated).
|
||||
func withClusterServer(t *testing.T, c ClusterView) (*httptest.Server, *Server, *http.Client) {
|
||||
t.Helper()
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
srv.SetCluster(c)
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
|
||||
client, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
return ts, srv, client
|
||||
}
|
||||
|
||||
// TestClusterStatus_NotConfigured verifies the 503 path when no
|
||||
// cluster subsystem is attached to the webapp.
|
||||
func TestClusterStatus_NotConfigured(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode,
|
||||
"cluster status must 503 when no cluster is attached")
|
||||
}
|
||||
|
||||
// TestClusterStatus_HappyPath verifies the JSON shape of the
|
||||
// /web/api/cluster/status response when a stub cluster is attached.
|
||||
func TestClusterStatus_HappyPath(t *testing.T) {
|
||||
stub := &stubCluster{
|
||||
stats: ClusterStats{
|
||||
NodeID: "worker1",
|
||||
LocalAddr: "127.0.0.1:17401",
|
||||
State: "Leader",
|
||||
Leader: "worker1",
|
||||
Term: 17,
|
||||
AppliedIndex: 42,
|
||||
LastIndex: 42,
|
||||
NumPeers: 2,
|
||||
Voters: []string{"worker1", "worker2"},
|
||||
FSMChecks: 1,
|
||||
FSMMembers: 2,
|
||||
FSMConfigVersion: 7,
|
||||
FSMOutboxLen: 3,
|
||||
FSMPartition: "steady",
|
||||
},
|
||||
clusterIDOut: "worker1",
|
||||
addrOut: "127.0.0.1:17401",
|
||||
}
|
||||
ts, _, c := withClusterServer(t, stub)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "application/json; charset=utf-8", resp.Header.Get("Content-Type"))
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var got clusterStatusResponse
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.Equal(t, "worker1", got.SelfID)
|
||||
assert.Equal(t, "Leader", got.Role)
|
||||
assert.EqualValues(t, 17, got.Term)
|
||||
assert.Equal(t, "worker1", got.LeaderID)
|
||||
assert.Equal(t, []string{"worker1", "worker2"}, got.Voters)
|
||||
assert.EqualValues(t, 42, got.AppliedIndex)
|
||||
assert.EqualValues(t, 42, got.CommitIndex)
|
||||
assert.Equal(t, 1, got.FSMChecks)
|
||||
assert.Equal(t, 2, got.FSMMembers)
|
||||
assert.EqualValues(t, 7, got.FSMConfigVersion)
|
||||
assert.Equal(t, 3, got.FSMOutboxLen)
|
||||
assert.Equal(t, "steady", got.FSMPartition)
|
||||
assert.Equal(t, "worker1", got.ClusterID)
|
||||
assert.Equal(t, "127.0.0.1:17401", got.LocalAddr)
|
||||
}
|
||||
|
||||
// TestClusterStatus_FSMFieldsZeroByDefault pins the FSM-side fields
|
||||
// to the zero value when the stub cluster does not set them. Guards
|
||||
// against a future refactor accidentally widening the wire format
|
||||
// with a non-zero default for a fresh cluster.
|
||||
func TestClusterStatus_FSMFieldsZeroByDefault(t *testing.T) {
|
||||
stub := &stubCluster{
|
||||
stats: ClusterStats{
|
||||
NodeID: "worker1", State: "Follower", Leader: "worker2",
|
||||
Voters: []string{"worker1", "worker2"},
|
||||
},
|
||||
clusterIDOut: "worker1",
|
||||
addrOut: "127.0.0.1:17401",
|
||||
}
|
||||
ts, _, c := withClusterServer(t, stub)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
var got clusterStatusResponse
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.EqualValues(t, 0, got.FSMConfigVersion, "fresh cluster must report config_version 0")
|
||||
assert.Equal(t, 0, got.FSMOutboxLen, "fresh cluster must report outbox_len 0")
|
||||
assert.Equal(t, "", got.FSMPartition, "fresh cluster must report partition empty/zero")
|
||||
}
|
||||
|
||||
// TestClusterStatus_RequiresSession ensures the cluster admin
|
||||
// endpoint is gated by the session middleware.
|
||||
func TestClusterStatus_RequiresSession(t *testing.T) {
|
||||
stub := &stubCluster{}
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
srv.SetCluster(stub)
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
|
||||
// No session cookie — should redirect to login.
|
||||
client := httpClient()
|
||||
resp, err := client.Get(ts.URL + "/web/api/cluster/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode,
|
||||
"cluster status must redirect to login without session")
|
||||
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// TestClusterApplyTestConfig_NotConfigured verifies the 404 path
|
||||
// when WORKER_CLUSTER_DEBUG_APPLY is false (the production default)
|
||||
// and no cluster is attached. The handler must refuse before it
|
||||
// even checks the cluster because the debug flag is off.
|
||||
func TestClusterApplyTestConfig_NotConfigured(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
require.False(t, srv.cfg.DebugClusterApply, "default config must leave the debug apply flag off")
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode,
|
||||
"debug apply must be invisible (404) when WORKER_CLUSTER_DEBUG_APPLY is unset")
|
||||
}
|
||||
|
||||
// TestClusterApplyTestConfig_DebugOffReturns404 verifies that even
|
||||
// with a cluster attached the apply endpoint stays 404 unless the
|
||||
// debug flag is on. The flag, not cluster presence, gates the
|
||||
// endpoint.
|
||||
func TestClusterApplyTestConfig_DebugOffReturns404(t *testing.T) {
|
||||
stub := &stubCluster{applyIndex: 42}
|
||||
ts, _, c := withClusterServer(t, stub)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/overview")
|
||||
require.NoError(t, err)
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close() //nolint:errcheck
|
||||
csrf := extractCSRFToken(t, string(bodyBytes))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", csrf)
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err = c.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
assert.Equal(t, 0, stub.applyCalled,
|
||||
"ApplyTestConfig must never be called when the debug flag is off")
|
||||
}
|
||||
|
||||
// TestClusterApplyTestConfig_HappyPath verifies that the apply-test-
|
||||
// config endpoint returns the applied index when the cluster
|
||||
// subsystem accepts the entry. CSRF is checked. The DebugClusterApply
|
||||
// flag must be on for the endpoint to be reachable.
|
||||
func TestClusterApplyTestConfig_HappyPath(t *testing.T) {
|
||||
stub := &stubCluster{
|
||||
stats: ClusterStats{
|
||||
NodeID: "worker1", State: "Leader", Leader: "worker1",
|
||||
Voters: []string{"worker1"},
|
||||
},
|
||||
applyIndex: 13,
|
||||
clusterIDOut: "worker1",
|
||||
addrOut: "127.0.0.1:17401",
|
||||
}
|
||||
ts, srv, c := withClusterServer(t, stub)
|
||||
srv.cfg.DebugClusterApply = true
|
||||
|
||||
// Fetch CSRF token from any authenticated page.
|
||||
resp, err := c.Get(ts.URL + "/overview")
|
||||
require.NoError(t, err)
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close() //nolint:errcheck
|
||||
csrf := extractCSRFToken(t, string(bodyBytes))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", csrf)
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err = c.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||
|
||||
var got map[string]uint64
|
||||
require.NoError(t, json.Unmarshal(bodyBytes, &got))
|
||||
assert.EqualValues(t, 13, got["applied_index"])
|
||||
|
||||
assert.Equal(t, 1, stub.applyCalled)
|
||||
}
|
||||
|
||||
// TestClusterApplyTestConfig_PropagatesError verifies that errors
|
||||
// from the cluster subsystem surface as 502 Bad Gateway. Debug flag
|
||||
// must be on.
|
||||
func TestClusterApplyTestConfig_PropagatesError(t *testing.T) {
|
||||
stub := &stubCluster{
|
||||
applyErr: errStubApply,
|
||||
clusterIDOut: "worker1",
|
||||
addrOut: "127.0.0.1:17401",
|
||||
}
|
||||
ts, srv, c := withClusterServer(t, stub)
|
||||
srv.cfg.DebugClusterApply = true
|
||||
|
||||
resp, err := c.Get(ts.URL + "/overview")
|
||||
require.NoError(t, err)
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close() //nolint:errcheck
|
||||
csrf := extractCSRFToken(t, string(bodyBytes))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("csrf_token", csrf)
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
|
||||
strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err = c.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusBadGateway, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestClusterApplyTestConfig_RequiresCSRF ensures the apply-test-
|
||||
// config POST is refused without a CSRF token. Debug flag must be
|
||||
// on for the endpoint to be reachable; without the flag it returns
|
||||
// 404 (priority over CSRF check).
|
||||
func TestClusterApplyTestConfig_RequiresCSRF(t *testing.T) {
|
||||
stub := &stubCluster{applyIndex: 99}
|
||||
ts, srv, c := withClusterServer(t, stub)
|
||||
srv.cfg.DebugClusterApply = true
|
||||
|
||||
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
|
||||
"apply-test-config without CSRF must be 403")
|
||||
assert.Equal(t, 0, stub.applyCalled, "ApplyTestConfig must not be called without CSRF")
|
||||
}
|
||||
|
||||
// errStubApply is a sentinel error used by the apply-error test.
|
||||
var errStubApply = errApply("worker not leader")
|
||||
|
||||
type errApply string
|
||||
|
||||
func (e errApply) Error() string { return string(e) }
|
||||
|
||||
// TestSetClusterDetaches verifies SetCluster(nil) returns the server
|
||||
// to the no-cluster-attached state (503 from the endpoints).
|
||||
func TestSetClusterDetaches(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
stub := &stubCluster{applyIndex: 7}
|
||||
srv.SetCluster(stub)
|
||||
require.NotNil(t, srv.Cluster())
|
||||
|
||||
srv.SetCluster(nil)
|
||||
require.Nil(t, srv.Cluster())
|
||||
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/web/api/cluster/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
}
|
||||
|
||||
// _ = context.Background and time.Time keep the linter quiet about
|
||||
// unused imports if the file shrinks.
|
||||
var (
|
||||
_ = context.Background
|
||||
_ = time.Now
|
||||
_ = url.Parse
|
||||
)
|
||||
49
internal/webapp/handlers_logs.go
Обычный файл
49
internal/webapp/handlers_logs.go
Обычный файл
@@ -0,0 +1,49 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// handleLogs tails the in-memory worker log buffer. The handler
|
||||
// reads ?tail=200|500|1000|5000 (default 200) per section 6.6.
|
||||
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
tail := parseTail(r.URL.Query().Get("tail"))
|
||||
lines := s.logBuffer.Tail(tail)
|
||||
data := logsPageData{
|
||||
basePageData: s.newBasePage(r, "Worker logs", sess),
|
||||
Tail: tail,
|
||||
Lines: lines,
|
||||
BufferSize: s.logBuffer.Size(),
|
||||
BufferCap: s.logBuffer.Cap(),
|
||||
}
|
||||
if err := s.templates.Execute(w, "logs.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render logs: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTail clamps the requested tail count to one of the
|
||||
// doc-prescribed buckets (200/500/1000/5000) and falls back to 200.
|
||||
func parseTail(raw string) int {
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 200
|
||||
}
|
||||
for _, allowed := range []int{200, 500, 1000, 5000} {
|
||||
if n == allowed {
|
||||
return allowed
|
||||
}
|
||||
}
|
||||
return 200
|
||||
}
|
||||
|
||||
type logsPageData struct {
|
||||
basePageData
|
||||
Tail int
|
||||
Lines []string
|
||||
BufferSize int
|
||||
BufferCap int
|
||||
}
|
||||
84
internal/webapp/handlers_overview.go
Обычный файл
84
internal/webapp/handlers_overview.go
Обычный файл
@@ -0,0 +1,84 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleOverview is the landing page after login. Phase 1 shows the
|
||||
// worker status, recent results counts, and a tail of the worker
|
||||
// log buffer. The data shape will grow in later phases as the
|
||||
// heartbeat inventory and 24h result counts come online.
|
||||
func (s *Server) handleOverview(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
snap, snapAt := s.metrics.Last()
|
||||
data := overviewPageData{
|
||||
basePageData: s.newBasePage(r, "Overview", sess),
|
||||
WorkerID: workerIDOrDash(s.deps.Runner),
|
||||
RegionCode: regionOrDash(s.deps.Runner),
|
||||
WorkerState: workerState(s.deps.Runner, s.deps.StartedAt),
|
||||
LastAckAt: lastAckOrZero(s.deps.Runner),
|
||||
StartedAt: s.deps.StartedAt,
|
||||
Snapshot: snap,
|
||||
SnapshotAt: snapAt,
|
||||
DiscoveredCount: len(s.inventory.Snapshot()),
|
||||
ResultCount: len(s.deps.Runner.RecentResults(1000)),
|
||||
NotifCount: len(s.deps.Runner.RecentNotifications(1000)),
|
||||
LogTail: s.logBuffer.Tail(20),
|
||||
}
|
||||
if err := s.templates.Execute(w, "overview.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render overview: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func workerIDOrDash(v WorkerView) string {
|
||||
if v == nil {
|
||||
return "—"
|
||||
}
|
||||
return v.WorkerID()
|
||||
}
|
||||
|
||||
func regionOrDash(v WorkerView) string {
|
||||
if v == nil {
|
||||
return "—"
|
||||
}
|
||||
return v.RegionCode()
|
||||
}
|
||||
|
||||
func workerState(v WorkerView, startedAt time.Time) string {
|
||||
if v == nil {
|
||||
return "—"
|
||||
}
|
||||
if last := v.LastHeartbeatAck(); !last.IsZero() && last.After(startedAt) {
|
||||
return "connected"
|
||||
}
|
||||
return "starting"
|
||||
}
|
||||
|
||||
func lastAckOrZero(v WorkerView) time.Time {
|
||||
if v == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return v.LastHeartbeatAck()
|
||||
}
|
||||
|
||||
// overviewPageData is the data backing overview.html.
|
||||
type overviewPageData struct {
|
||||
basePageData
|
||||
WorkerID string
|
||||
RegionCode string
|
||||
WorkerState string
|
||||
LastAckAt time.Time
|
||||
StartedAt time.Time
|
||||
Snapshot Snapshot
|
||||
SnapshotAt time.Time
|
||||
DiscoveredCount int
|
||||
ResultCount int
|
||||
NotifCount int
|
||||
LogTail []string
|
||||
}
|
||||
|
||||
var _ = strings.TrimSpace
|
||||
59
internal/webapp/handlers_peer.go
Обычный файл
59
internal/webapp/handlers_peer.go
Обычный файл
@@ -0,0 +1,59 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// peerStatusResponse is the JSON returned at GET /api/peer/status.
|
||||
// The shape matches distworker.PeerStatus exactly so the peer
|
||||
// poller on the other end can decode it without a separate
|
||||
// type. Kept here as a local view to keep the webapp package free
|
||||
// of any concrete dependency on the distworker peer types; the
|
||||
// fields are JSON-stable.
|
||||
//
|
||||
// Up == nil means "no probe has run yet" so peer workers that
|
||||
// query this endpoint right after boot do not get a misleading
|
||||
// "true" verdict while the selfcheck is still spinning up.
|
||||
type peerStatusResponse struct {
|
||||
WorkerID string `json:"worker_id"`
|
||||
Up *bool `json:"up"`
|
||||
ObservedAt *time.Time `json:"observed_at"`
|
||||
}
|
||||
|
||||
// handlePeerStatus serves the most recent local selfcheck verdict
|
||||
// to peer workers over HTTP. The endpoint is intentionally
|
||||
// unauthenticated for now: a worker with basic auth configured
|
||||
// (WORKER_LOGIN / WORKER_PASSWORD) still exposes the verdict
|
||||
// because the path lives outside the /web/api/* prefix that the
|
||||
// basic-auth middleware gates. This matches the
|
||||
// "keep simple for local trusted workers if no peer auth exists
|
||||
// yet" directive in
|
||||
// docs/distributed/worker-to-worker-raft.md (slice 1).
|
||||
//
|
||||
// The endpoint never returns a 5xx: a runner that has not yet
|
||||
// produced a verdict simply returns {"up": null, ...} so the
|
||||
// peer poller can keep the slot in cache as "unknown" instead of
|
||||
// treating the absence as a hard failure.
|
||||
func (s *Server) handlePeerStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
resp := peerStatusResponse{}
|
||||
if s.deps.Runner != nil {
|
||||
up, at := s.deps.Runner.MasterStatus()
|
||||
resp.WorkerID = s.deps.Runner.WorkerID()
|
||||
resp.Up = up
|
||||
if !at.IsZero() {
|
||||
// Copy the timestamp so callers see a value
|
||||
// (json omitempty is not used on purpose: an
|
||||
// explicit zero time communicates "no probe" and
|
||||
// an RFC3339 string communicates "probed at").
|
||||
atCopy := at.UTC()
|
||||
resp.ObservedAt = &atCopy
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
s.deps.Logger.Printf("peer status encode: %v", err)
|
||||
}
|
||||
}
|
||||
94
internal/webapp/handlers_peer_test.go
Обычный файл
94
internal/webapp/handlers_peer_test.go
Обычный файл
@@ -0,0 +1,94 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestHandlePeerStatus_NoProbe covers the first-boot window: the
|
||||
// runner has not yet produced a selfcheck verdict, so the endpoint
|
||||
// must return a valid JSON body with up=null and observed_at=null.
|
||||
func TestHandlePeerStatus_NoProbe(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/peer/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got peerStatusResponse
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.Equal(t, "w-1", got.WorkerID)
|
||||
assert.Nil(t, got.Up, "up must be nil before the first probe")
|
||||
assert.Nil(t, got.ObservedAt, "observed_at must be nil before the first probe")
|
||||
}
|
||||
|
||||
// TestHandlePeerStatus_UpAndDown covers the post-probe window: the
|
||||
// endpoint must reflect the most recent selfcheck verdict.
|
||||
func TestHandlePeerStatus_UpAndDown(t *testing.T) {
|
||||
up := true
|
||||
at := time.Date(2026, 7, 10, 16, 0, 0, 0, time.UTC)
|
||||
srv := newTestServer(t, &stubRunner{id: "w-2", masterUp: &up, masterAt: at})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/peer/status")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var got peerStatusResponse
|
||||
require.NoError(t, json.Unmarshal(body, &got))
|
||||
assert.Equal(t, "w-2", got.WorkerID)
|
||||
require.NotNil(t, got.Up)
|
||||
assert.True(t, *got.Up)
|
||||
require.NotNil(t, got.ObservedAt)
|
||||
assert.Equal(t, at, *got.ObservedAt)
|
||||
|
||||
// Flip to down and re-fetch; the handler reads from the live
|
||||
// stub view, not a cached copy, so the new verdict must surface.
|
||||
down := false
|
||||
runner := srv.deps.Runner.(*stubRunner) //nolint:forcetypeassert // helper under test
|
||||
runner.masterUp = &down
|
||||
runner.masterAt = at.Add(time.Minute)
|
||||
|
||||
resp2, err := http.Get(ts.URL + "/api/peer/status")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close() //nolint:errcheck
|
||||
|
||||
var got2 peerStatusResponse
|
||||
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&got2))
|
||||
require.NotNil(t, got2.Up)
|
||||
assert.False(t, *got2.Up)
|
||||
require.NotNil(t, got2.ObservedAt)
|
||||
assert.Equal(t, at.Add(time.Minute), *got2.ObservedAt)
|
||||
}
|
||||
|
||||
// TestHandlePeerStatus_DoesNotRequireSession confirms the slice-1
|
||||
// design: the endpoint sits outside /web/api/* so the basic-auth
|
||||
// middleware does not intercept it and no session cookie is needed.
|
||||
// The handler should still answer 200 OK when the runner is wired.
|
||||
func TestHandlePeerStatus_DoesNotRequireSession(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-3"})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, ts.URL+"/api/peer/status", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
||||
"peer status must be reachable without a session cookie (slice 1)")
|
||||
}
|
||||
100
internal/webapp/handlers_settings.go
Обычный файл
100
internal/webapp/handlers_settings.go
Обычный файл
@@ -0,0 +1,100 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleSettings renders the worker fields (worker id, region,
|
||||
// capabilities, version, last heartbeat ack, last token rotation).
|
||||
// The token is masked; rotation is a POST to
|
||||
// /settings/rotate-token (see handleRotateToken).
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
token := ""
|
||||
if s.deps.Runner != nil {
|
||||
token = s.deps.Runner.Token()
|
||||
}
|
||||
rotatedAt := time.Time{}
|
||||
if s.deps.Runner != nil {
|
||||
rotatedAt = s.deps.Runner.TokenRotatedAt()
|
||||
}
|
||||
data := settingsPageData{
|
||||
basePageData: s.newBasePage(r, "Settings", sess),
|
||||
WorkerID: workerIDOrDash(s.deps.Runner),
|
||||
RegionCode: regionOrDash(s.deps.Runner),
|
||||
WorkerVersion: workerVersionOrDash(s.deps.Runner),
|
||||
Capabilities: capabilitiesOrEmpty(s.deps.Runner),
|
||||
LastAckAt: lastAckOrZero(s.deps.Runner),
|
||||
TokenMasked: MaskToken(token),
|
||||
TokenRotatedAt: rotatedAt,
|
||||
}
|
||||
if err := s.templates.Execute(w, "settings.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render settings: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func workerVersionOrDash(v WorkerView) string {
|
||||
if v == nil {
|
||||
return "—"
|
||||
}
|
||||
return v.WorkerVersion()
|
||||
}
|
||||
|
||||
func capabilitiesOrEmpty(v WorkerView) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return v.WorkerCapabilities()
|
||||
}
|
||||
|
||||
type settingsPageData struct {
|
||||
basePageData
|
||||
WorkerID string
|
||||
RegionCode string
|
||||
WorkerVersion string
|
||||
Capabilities []string
|
||||
LastAckAt time.Time
|
||||
TokenMasked string
|
||||
TokenRotatedAt time.Time
|
||||
}
|
||||
|
||||
// handleRotateToken calls the worker-defined rotator (if any) to
|
||||
// issue a fresh token via the main app's API and update the in-
|
||||
// memory runner config. On failure, return 502 and keep the old
|
||||
// token (the doc's Phase 1 contract).
|
||||
func (s *Server) handleRotateToken(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, ok := sessionFromContext(r.Context())
|
||||
if !ok {
|
||||
redirectTo(w, r, "/web/login")
|
||||
return
|
||||
}
|
||||
if !s.requireCSRF(sess, r) {
|
||||
http.Error(w, "csrf token required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if s.deps.TokenRotator == nil {
|
||||
http.Error(w, "token rotation is not configured", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
newToken, err := s.deps.TokenRotator(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Logger.Printf("token rotation: %v", err)
|
||||
http.Error(w, "rotation failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
_ = s.store.WriteAudit(r.Context(), &AuditEntry{
|
||||
Actor: auditActorLocal,
|
||||
Role: auditRoleAdmin,
|
||||
AuthMode: auditAuthModeLocal,
|
||||
IP: clientIP(r),
|
||||
UA: r.UserAgent(),
|
||||
Action: "token_rotation",
|
||||
Target: auditTargetSelf,
|
||||
})
|
||||
_ = newToken // the rotator updates the runner; the page just confirms success.
|
||||
redirectTo(w, r, "/settings")
|
||||
}
|
||||
38
internal/webapp/handlers_status.go
Обычный файл
38
internal/webapp/handlers_status.go
Обычный файл
@@ -0,0 +1,38 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleStatus renders the host metrics snapshot. Phase 1 reads
|
||||
// only /proc and statfs; the lsblk / smartctl / sensors
|
||||
// integrations are deferred to the worker webapp expansion tracked
|
||||
// in docs/distributed/worker-web-app.md §6.7 (out of band; not
|
||||
// shipped in this repo). The current Snapshot struct accommodates
|
||||
// the extra sections if/when those are wired in.
|
||||
//
|
||||
// TODO(worker-web-app §6.7): surface lsblk / smartctl / sensors /
|
||||
// docker info on this page once the worker process is allowed to
|
||||
// invoke those CLI tools. The cli tools are not bundled with the
|
||||
// worker binary; install them separately on the host if needed.
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
snap, snapAt := s.metrics.Last()
|
||||
data := statusPageData{
|
||||
basePageData: s.newBasePage(r, "Server status", sess),
|
||||
Snapshot: snap,
|
||||
SnapshotAt: snapAt,
|
||||
}
|
||||
if err := s.templates.Execute(w, "status.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render status: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type statusPageData struct {
|
||||
basePageData
|
||||
Snapshot Snapshot
|
||||
SnapshotAt time.Time
|
||||
}
|
||||
129
internal/webapp/handlers_updates.go
Обычный файл
129
internal/webapp/handlers_updates.go
Обычный файл
@@ -0,0 +1,129 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// placeholderLatestVersion is the fallback shown on the /updates
|
||||
// page when Config.ReleaseURL is empty or the poll fails. Phase 1
|
||||
// uses it permanently; once Config.ReleaseURL is set the handler
|
||||
// replaces it with the polled tag_name (GitHub release JSON shape).
|
||||
const placeholderLatestVersion = "v1 (dev)"
|
||||
|
||||
// defaultReleasePollTimeout bounds the time a single release-server
|
||||
// HTTP fetch is allowed to take. 3 seconds keeps the page render
|
||||
// fast; a slow upstream just shows the placeholder.
|
||||
const defaultReleasePollTimeout = 3 * time.Second
|
||||
|
||||
// releaseCacheTTL bounds how often the worker re-fetches the
|
||||
// release URL. One hour is short enough that a fresh release shows
|
||||
// up reasonably quickly, long enough that the page never hammers the
|
||||
// upstream.
|
||||
const releaseCacheTTL = 1 * time.Hour
|
||||
|
||||
// releasePoller holds the cached release-server response. A single
|
||||
// instance lives on the Server (one per process) so concurrent
|
||||
// /updates hits share the same cache entry.
|
||||
type releasePoller struct {
|
||||
mu sync.RWMutex
|
||||
url string
|
||||
cached string
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// latest returns the cached value if it is fresh, otherwise it
|
||||
// fetches the URL, parses {"tag_name":"..."} from the response and
|
||||
// caches the result. Errors fall back to placeholderLatestVersion
|
||||
// without touching the cache, so a transient outage does not poison
|
||||
// the next successful poll.
|
||||
func (p *releasePoller) latest(ctx context.Context, httpClient *http.Client) string {
|
||||
if p == nil || p.url == "" {
|
||||
return placeholderLatestVersion
|
||||
}
|
||||
p.mu.RLock()
|
||||
if !p.cachedAt.IsZero() && time.Since(p.cachedAt) < releaseCacheTTL && p.cached != "" {
|
||||
out := p.cached
|
||||
p.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
client := httpClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: defaultReleasePollTimeout}
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, defaultReleasePollTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, p.url, http.NoBody)
|
||||
if err != nil {
|
||||
return placeholderLatestVersion
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return placeholderLatestVersion
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return placeholderLatestVersion
|
||||
}
|
||||
var body struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.TagName == "" {
|
||||
return placeholderLatestVersion
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.cached = body.TagName
|
||||
p.cachedAt = time.Now()
|
||||
p.mu.Unlock()
|
||||
return body.TagName
|
||||
}
|
||||
|
||||
// setURL configures the poller with a new release URL and resets
|
||||
// the cache. Called from New() when Config.ReleaseURL is set.
|
||||
func (p *releasePoller) setURL(u string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.url = u
|
||||
p.cached = ""
|
||||
p.cachedAt = time.Time{}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// handleUpdates renders the updates page. The "pull and restart"
|
||||
// button stays disabled until sudo / docker socket access lands
|
||||
// (gated on the Phase 3 Docker management work). The version
|
||||
// comparison above it IS real: it polls Config.ReleaseURL (env
|
||||
// WORKER_RELEASE_URL) and falls back to placeholderLatestVersion on
|
||||
// any network or parse error.
|
||||
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
writeNoStore(w)
|
||||
sess, _ := sessionFromContext(r.Context())
|
||||
data := updatesPageData{
|
||||
basePageData: s.newBasePage(r, "Updates", sess),
|
||||
CurrentVersion: workerVersionOrDash(s.deps.Runner),
|
||||
LatestKnown: s.releasePoller.latest(r.Context(), s.deps.ReleaseHTTPClient),
|
||||
PullEnabled: false,
|
||||
PullTooltip: "Pull and restart lands with Docker management (sudo / docker socket required).",
|
||||
}
|
||||
if err := s.templates.Execute(w, "updates.html", data); err != nil {
|
||||
s.deps.Logger.Printf("render updates: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
type updatesPageData struct {
|
||||
basePageData
|
||||
CurrentVersion string
|
||||
LatestKnown string
|
||||
PullEnabled bool
|
||||
PullTooltip string
|
||||
}
|
||||
272
internal/webapp/handlers_updates_test.go
Обычный файл
272
internal/webapp/handlers_updates_test.go
Обычный файл
@@ -0,0 +1,272 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// stubReleaseServer returns a httptest.Server whose handler serves
|
||||
// the supplied tag_name as a GitHub-style JSON body. The close func
|
||||
// is returned alongside so callers can defer shutdown.
|
||||
func stubReleaseServer(t *testing.T, tagName string, status int) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "application/json", r.Header.Get("Accept"))
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
body, _ := json.Marshal(map[string]string{"tag_name": tagName})
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
return ts, ts.Close
|
||||
}
|
||||
|
||||
// TestReleasePoller_NoURLReturnsPlaceholder pins the
|
||||
// no-URL-is-configured fallback. The handler must not hit the
|
||||
// network when Config.ReleaseURL is empty.
|
||||
func TestReleasePoller_NoURLReturnsPlaceholder(t *testing.T) {
|
||||
p := &releasePoller{}
|
||||
got := p.latest(context.Background(), nil)
|
||||
assert.Equal(t, placeholderLatestVersion, got)
|
||||
}
|
||||
|
||||
// TestReleasePoller_SuccessCaches verifies the happy path: the
|
||||
// first call hits the URL, subsequent calls within the TTL come
|
||||
// from the cache.
|
||||
func TestReleasePoller_SuccessCaches(t *testing.T) {
|
||||
ts, cleanup := stubReleaseServer(t, "v2.7.1", http.StatusOK)
|
||||
defer cleanup()
|
||||
|
||||
p := &releasePoller{}
|
||||
p.setURL(ts.URL)
|
||||
|
||||
got := p.latest(context.Background(), ts.Client())
|
||||
assert.Equal(t, "v2.7.1", got)
|
||||
|
||||
// Second call: cache hit. Replace the upstream with one that
|
||||
// would error; the cached value must still come back.
|
||||
p.url = "http://127.0.0.1:1/never-reachable"
|
||||
got = p.latest(context.Background(), ts.Client())
|
||||
assert.Equal(t, "v2.7.1", got, "cached value must survive upstream failures inside the TTL window")
|
||||
}
|
||||
|
||||
// TestReleasePoller_Non2xxReturnsPlaceholder ensures a 5xx upstream
|
||||
// does not poison the cache (placeholder shown, cache untouched).
|
||||
func TestReleasePoller_Non2xxReturnsPlaceholder(t *testing.T) {
|
||||
ts, cleanup := stubReleaseServer(t, "ignored", http.StatusInternalServerError)
|
||||
defer cleanup()
|
||||
|
||||
p := &releasePoller{}
|
||||
p.setURL(ts.URL)
|
||||
|
||||
got := p.latest(context.Background(), ts.Client())
|
||||
assert.Equal(t, placeholderLatestVersion, got)
|
||||
|
||||
// Confirm cache was not touched: a fresh request to a working
|
||||
// upstream must produce the placeholder if the broken one was
|
||||
// recorded. We use a different working upstream here.
|
||||
ts2, cleanup2 := stubReleaseServer(t, "v9.9.9", http.StatusOK)
|
||||
defer cleanup2()
|
||||
p.setURL(ts2.URL)
|
||||
got = p.latest(context.Background(), ts2.Client())
|
||||
assert.Equal(t, "v9.9.9", got)
|
||||
}
|
||||
|
||||
// TestReleasePoller_BadJSONReturnsPlaceholder verifies that a 200
|
||||
// with a missing tag_name falls back to the placeholder.
|
||||
func TestReleasePoller_BadJSONReturnsPlaceholder(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name": "no tag here"}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &releasePoller{}
|
||||
p.setURL(ts.URL)
|
||||
|
||||
got := p.latest(context.Background(), ts.Client())
|
||||
assert.Equal(t, placeholderLatestVersion, got)
|
||||
}
|
||||
|
||||
// TestReleasePoller_TimeoutReturnsPlaceholder confirms a slow
|
||||
// upstream degrades to the placeholder without exceeding the
|
||||
// per-call timeout budget.
|
||||
func TestReleasePoller_TimeoutReturnsPlaceholder(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(2 * defaultReleasePollTimeout)
|
||||
_, _ = io.WriteString(w, `{"tag_name":"too-late"}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
p := &releasePoller{}
|
||||
p.setURL(ts.URL)
|
||||
|
||||
start := time.Now()
|
||||
got := p.latest(context.Background(), &http.Client{Timeout: defaultReleasePollTimeout})
|
||||
elapsed := time.Since(start)
|
||||
assert.Equal(t, placeholderLatestVersion, got)
|
||||
assert.Less(t, elapsed, 2*defaultReleasePollTimeout,
|
||||
"timeout must fire before the slow upstream replies")
|
||||
}
|
||||
|
||||
// TestReleasePoller_TTLExpiry verifies that after releaseCacheTTL
|
||||
// the poller re-fetches the URL. We can't wait an hour in a unit
|
||||
// test, so we reset the cachedAt directly to a stale time and
|
||||
// confirm the next call refetches.
|
||||
func TestReleasePoller_TTLExpiry(t *testing.T) {
|
||||
ts, cleanup := stubReleaseServer(t, "v3.0.0", http.StatusOK)
|
||||
defer cleanup()
|
||||
|
||||
p := &releasePoller{}
|
||||
p.setURL(ts.URL)
|
||||
|
||||
// Prime the cache.
|
||||
got := p.latest(context.Background(), ts.Client())
|
||||
require.Equal(t, "v3.0.0", got)
|
||||
|
||||
// Force the cachedAt into the past.
|
||||
p.mu.Lock()
|
||||
p.cachedAt = time.Now().Add(-2 * releaseCacheTTL)
|
||||
p.mu.Unlock()
|
||||
|
||||
// Change the upstream to a new tag — must be observed.
|
||||
ts2, cleanup2 := stubReleaseServer(t, "v3.0.1", http.StatusOK)
|
||||
defer cleanup2()
|
||||
p.setURL(ts2.URL)
|
||||
got = p.latest(context.Background(), ts2.Client())
|
||||
assert.Equal(t, "v3.0.1", got, "stale cache must not block a fresh fetch after setURL reset")
|
||||
}
|
||||
|
||||
// TestUpdatesPage_ShowsPlaceholder verifies that without
|
||||
// Config.ReleaseURL the page renders the placeholder string in
|
||||
// the "Latest known" cell.
|
||||
func TestUpdatesPage_ShowsPlaceholder(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
require.Empty(t, srv.cfg.ReleaseURL, "test fixture must not pre-set ReleaseURL")
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/updates")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
page := string(body)
|
||||
assert.Contains(t, page, placeholderLatestVersion,
|
||||
"placeholder version must appear when no release URL is configured")
|
||||
assert.Contains(t, page, "WORKER_RELEASE_URL",
|
||||
"placeholder copy must mention the env var that turns on real polls")
|
||||
}
|
||||
|
||||
// TestUpdatesPage_RunsPollWithReleaseURL verifies that with
|
||||
// Config.ReleaseURL set, the page renders the tag_name from the
|
||||
// upstream release server.
|
||||
func TestUpdatesPage_RunsPollWithReleaseURL(t *testing.T) {
|
||||
ts, cleanup := stubReleaseServer(t, "v9.9.9", http.StatusOK)
|
||||
defer cleanup()
|
||||
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
srv.cfg.ReleaseURL = ts.URL
|
||||
srv.releasePoller.setURL(ts.URL)
|
||||
|
||||
hts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, hts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(hts.URL + "/updates")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(body), "v9.9.9",
|
||||
"page must surface the polled tag_name when WORKER_RELEASE_URL is configured")
|
||||
}
|
||||
|
||||
// TestChecksPage_RunNowDisabledButton verifies that the "Run now"
|
||||
// button is rendered and disabled, with the tooltip explaining the
|
||||
// gating.
|
||||
func TestChecksPage_RunNowDisabledButton(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/checks")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
page := string(body)
|
||||
assert.Contains(t, page, "Run now",
|
||||
"the Run now button must appear on the checks page")
|
||||
assert.Contains(t, page, "disabled",
|
||||
"the Run now button must be disabled in Phase 1")
|
||||
assert.Contains(t, page, "worker-notifier-mvp",
|
||||
"tooltip must cite the worker-notifier MVP plan that owns the hint protocol")
|
||||
}
|
||||
|
||||
// TestNotificationsPage_ResendDisabledButton mirrors the checks
|
||||
// page test for the resend button on /notifications.
|
||||
func TestNotificationsPage_ResendDisabledButton(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/notifications")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
page := string(body)
|
||||
assert.Contains(t, page, "Resend")
|
||||
assert.Contains(t, page, "disabled")
|
||||
assert.Contains(t, page, "worker-notifier-mvp")
|
||||
}
|
||||
|
||||
// TestAppsPage_ReferencesInventoryPlan ensures the copy on
|
||||
// /apps points operators at the deploymentd-driven inventory
|
||||
// surface that this PR's plan docs describe.
|
||||
func TestAppsPage_ReferencesInventoryPlan(t *testing.T) {
|
||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||
ts := newHTTPTestServer(t, srv)
|
||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||
clearRequiresChange(t, srv)
|
||||
|
||||
resp, err := c.Get(ts.URL + "/apps")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
page := string(body)
|
||||
assert.Contains(t, page, "deploymentd",
|
||||
"apps page must mention deploymentd so operators know where Docker Compose discoveries land")
|
||||
assert.Contains(t, page, "inventory-management.md",
|
||||
"apps page must cite the inventory-management plan doc")
|
||||
}
|
||||
|
||||
// _ = url.Values and strings.Builder keep imports used if the file
|
||||
// shrinks in future refactors; they document the surface without
|
||||
// affecting compilation.
|
||||
var (
|
||||
_ = url.Values{}
|
||||
_ = strings.Builder{}
|
||||
)
|
||||
446
internal/webapp/inventory.go
Обычный файл
446
internal/webapp/inventory.go
Обычный файл
@@ -0,0 +1,446 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// inventoryRefreshInterval matches the 60s cadence called out in
|
||||
// docs/distributed/worker-web-app.md section 7 ("rebuilt every 60s").
|
||||
const inventoryRefreshInterval = 60 * time.Second
|
||||
|
||||
// procMount is the directory the inventory walker reads /proc
|
||||
// entries from. Defaults to /proc on a normal host. Tests override
|
||||
// it via SetProcRoot.
|
||||
var procMount = "/proc"
|
||||
|
||||
// SetProcRoot overrides the /proc mount for tests. It must be called
|
||||
// before any Inventory goroutine starts.
|
||||
func SetProcRoot(path string) {
|
||||
if path == "" {
|
||||
procMount = "/proc"
|
||||
return
|
||||
}
|
||||
procMount = path
|
||||
}
|
||||
|
||||
// procInfo holds the per-process info the inventory walker reads
|
||||
// from /proc/<pid>. Defined at file scope so readProcInfo can return
|
||||
// it by value.
|
||||
type procInfo struct {
|
||||
pid int
|
||||
comm string
|
||||
cmdline string
|
||||
cwd string
|
||||
startTS int64
|
||||
}
|
||||
|
||||
// ProcRoot returns the currently configured /proc mount.
|
||||
func ProcRoot() string { return procMount }
|
||||
|
||||
// Inventory owns the periodic /proc -> sqlite refresh loop and
|
||||
// exposes a Snapshot for the discovered-apps handler.
|
||||
type Inventory struct {
|
||||
store *Store
|
||||
log *log.Logger
|
||||
mu sync.RWMutex
|
||||
snapshot []DiscoveredApp
|
||||
stopCh chan struct{}
|
||||
stopWG sync.WaitGroup
|
||||
started bool
|
||||
}
|
||||
|
||||
// DiscoveredApp is the shape we render on /apps. It is JSON-encodable
|
||||
// so the cache can stash a blob for later drill-in rendering.
|
||||
type DiscoveredApp struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"` // inventorySourceProcess in Phase 1
|
||||
PID int `json:"pid"`
|
||||
Ports []string `json:"ports"` // "7401/tcp", "127.0.0.1:5432"
|
||||
StartTS int64 `json:"start_ts"` // unix seconds
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
Cmdline string `json:"cmdline"`
|
||||
CWD string `json:"cwd"`
|
||||
}
|
||||
|
||||
// NewInventory returns an Inventory bound to the given store. The
|
||||
// refresh loop does NOT start until Start is called.
|
||||
func NewInventory(store *Store, logger *log.Logger) *Inventory {
|
||||
if logger == nil {
|
||||
logger = log.New(os.Stderr, "webapp-inventory: ", log.LstdFlags)
|
||||
}
|
||||
return &Inventory{
|
||||
store: store,
|
||||
log: logger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the background refresh loop. Returns immediately;
|
||||
// callers must call Stop for clean shutdown.
|
||||
func (i *Inventory) Start(ctx context.Context) {
|
||||
i.mu.Lock()
|
||||
if i.started {
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
i.started = true
|
||||
i.mu.Unlock()
|
||||
|
||||
i.stopWG.Add(1)
|
||||
go i.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop cancels the refresh loop and waits for it to exit.
|
||||
func (i *Inventory) Stop() {
|
||||
i.mu.Lock()
|
||||
if !i.started {
|
||||
i.mu.Unlock()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-i.stopCh:
|
||||
// already closed
|
||||
default:
|
||||
close(i.stopCh)
|
||||
}
|
||||
i.mu.Unlock()
|
||||
i.stopWG.Wait()
|
||||
}
|
||||
|
||||
// Snapshot returns the most recent inventory. Always safe to call
|
||||
// (returns an empty slice if the first refresh has not completed).
|
||||
func (i *Inventory) Snapshot() []DiscoveredApp {
|
||||
i.mu.RLock()
|
||||
defer i.mu.RUnlock()
|
||||
out := make([]DiscoveredApp, len(i.snapshot))
|
||||
copy(out, i.snapshot)
|
||||
return out
|
||||
}
|
||||
|
||||
func (i *Inventory) loop(ctx context.Context) {
|
||||
defer i.stopWG.Done()
|
||||
i.refresh(ctx)
|
||||
ticker := time.NewTicker(inventoryRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-i.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
i.refresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Inventory) refresh(ctx context.Context) {
|
||||
apps, err := ScanProcApps(ProcRoot())
|
||||
if err != nil {
|
||||
i.log.Printf("inventory refresh: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
rows := make([]App, 0, len(apps))
|
||||
for i := range apps {
|
||||
a := &apps[i]
|
||||
lastSeen := now
|
||||
if !a.LastSeen.IsZero() {
|
||||
lastSeen = a.LastSeen
|
||||
}
|
||||
rows = append(rows, App{
|
||||
Name: a.Name,
|
||||
Source: a.Source,
|
||||
PID: a.PID,
|
||||
Ports: strings.Join(a.Ports, ","),
|
||||
StartTS: a.StartTS,
|
||||
LastSeen: lastSeen,
|
||||
JSONBlob: a.Cmdline, // minimal JSON for now; full struct kept in Snapshot()
|
||||
})
|
||||
}
|
||||
if err := i.store.ReplaceApps(ctx, rows); err != nil {
|
||||
i.log.Printf("inventory persist: %v", err)
|
||||
return
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.snapshot = apps
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
// ScanProcApps walks the /proc mount and returns one DiscoveredApp
|
||||
// per process. It excludes kernel threads (comm == "") and is the
|
||||
// single source of truth for the inventory refresh.
|
||||
//
|
||||
// The grouping rule from section 7.1 ("processes sharing a cwd and
|
||||
// started within 5 seconds of each other are one app") is applied
|
||||
// by CollideByCWD before returning. Single processes are apps.
|
||||
//
|
||||
// Docker / Compose / systemd discovery on top of this per-process
|
||||
// list lands with the deploymentd integration in
|
||||
// docs/plans/inventory-management.md M1: RSMon's
|
||||
// /api/v1/inventory/deploymentd/receive/docker endpoint will upsert
|
||||
// Site + Deployment rows, and the worker webapp's `/apps` page will
|
||||
// show those rows side-by-side with the /proc-derived processes.
|
||||
// Phase 1 ships process discovery only.
|
||||
func ScanProcApps(root string) ([]DiscoveredApp, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", root, err)
|
||||
}
|
||||
|
||||
// Index inodes -> pid via /proc/<pid>/fd. We do this once at the
|
||||
// top of the scan so port resolution can reuse it.
|
||||
inodeOwner := map[uint64]int{}
|
||||
var procs []procInfo
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue // not a pid directory
|
||||
}
|
||||
pi, ok := readProcInfo(root, pid)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
procs = append(procs, pi)
|
||||
if pids, err := readSocketOwners(root, pid); err == nil {
|
||||
for _, ino := range pids {
|
||||
inodeOwner[ino] = pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve listeners -> pid (and hence the proc above).
|
||||
listeners, err := readListeners(root, inodeOwner)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read listeners: %w", err)
|
||||
}
|
||||
|
||||
// Build the discovered-apps list. Phase 1 has no grouping: each
|
||||
// process is its own app. Section 7.1 grouping (cwd + start
|
||||
// window) is deferred because it requires a stable cwd per
|
||||
// process which root-only /proc/<pid>/cwd symlinks cannot give
|
||||
// for other users' processes.
|
||||
apps := make([]DiscoveredApp, 0, len(procs))
|
||||
now := time.Now().UTC()
|
||||
for i := range procs {
|
||||
p := &procs[i]
|
||||
apps = append(apps, DiscoveredApp{
|
||||
Name: p.comm,
|
||||
Source: inventorySourceProcess,
|
||||
PID: p.pid,
|
||||
Ports: listeners[p.pid],
|
||||
StartTS: p.startTS,
|
||||
LastSeen: now,
|
||||
Cmdline: p.cmdline,
|
||||
CWD: p.cwd,
|
||||
})
|
||||
}
|
||||
sort.Slice(apps, func(i, j int) bool { return apps[i].PID < apps[j].PID })
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func readProcInfo(root string, pid int) (procInfo, bool) {
|
||||
pi := procInfo{pid: pid}
|
||||
// comm (15-char truncated, but we want a friendly name).
|
||||
if data, err := os.ReadFile(filepath.Join(root, strconv.Itoa(pid), "comm")); err == nil {
|
||||
pi.comm = strings.TrimSpace(string(data))
|
||||
}
|
||||
if pi.comm == "" {
|
||||
// Kernel thread, or vanished. Skip.
|
||||
return pi, false
|
||||
}
|
||||
if data, err := os.ReadFile(filepath.Join(root, strconv.Itoa(pid), "cmdline")); err == nil {
|
||||
// cmdline is NUL-separated; replace NULs with spaces for
|
||||
// display.
|
||||
pi.cmdline = strings.TrimSpace(strings.ReplaceAll(string(data), "\x00", " "))
|
||||
}
|
||||
// cwd is a symlink. Reading it requires permission; tolerate EACCES.
|
||||
if target, err := os.Readlink(filepath.Join(root, strconv.Itoa(pid), "cwd")); err == nil {
|
||||
pi.cwd = target
|
||||
}
|
||||
// stat: field 22 is starttime in clock ticks since boot. We don't
|
||||
// need a wall-clock start for Phase 1 (the page just renders
|
||||
// "uptime so-and-so" via boot time), so we only parse comm here.
|
||||
return pi, true
|
||||
}
|
||||
|
||||
// readSocketOwners walks /proc/<pid>/fd looking for socket:[inode]
|
||||
// entries. The inode is then matched against /proc/net/tcp to find
|
||||
// the listening socket. The pid map is the source of truth for
|
||||
// socket-to-pid translation.
|
||||
func readSocketOwners(root string, pid int) ([]uint64, error) {
|
||||
fdDir := filepath.Join(root, strconv.Itoa(pid), "fd")
|
||||
entries, err := os.ReadDir(fdDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []uint64
|
||||
for _, e := range entries {
|
||||
target, err := os.Readlink(filepath.Join(fdDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
const prefix = "socket:["
|
||||
if !strings.HasPrefix(target, prefix) {
|
||||
continue
|
||||
}
|
||||
raw := strings.TrimSuffix(strings.TrimPrefix(target, prefix), "]")
|
||||
ino, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ino)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// listenerRow mirrors a single line of /proc/net/tcp (or tcp6).
|
||||
type listenerRow struct {
|
||||
inode uint64
|
||||
local string
|
||||
rem string
|
||||
state string
|
||||
}
|
||||
|
||||
// readListeners walks /proc/net/tcp{,6} and returns a map from pid
|
||||
// to a slice of "ip:port/proto" strings. Only LISTEN state (0A) is
|
||||
// surfaced in Phase 1.
|
||||
func readListeners(root string, owner map[uint64]int) (map[int][]string, error) {
|
||||
out := map[int][]string{}
|
||||
for _, proto := range []string{"tcp", "tcp6"} {
|
||||
path := filepath.Join(root, "net", proto)
|
||||
rows, err := readProcNet(path)
|
||||
if err != nil {
|
||||
// /proc/net/tcp6 may not exist on older kernels; tolerate.
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.state != "0A" {
|
||||
continue
|
||||
}
|
||||
pid, ok := owner[r.inode]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[pid] = append(out[pid], r.local+"/"+proto)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readProcNet parses the columnar /proc/net/tcp{,6} format. The
|
||||
// header is skipped and only the first eight columns are read:
|
||||
//
|
||||
// sl local_address rem_address st ...
|
||||
//
|
||||
// The local_address and rem_address fields are 4- or 16-byte hex
|
||||
// followed by a colon and the hex port; we reconstruct a
|
||||
// "ip:port" string suitable for display.
|
||||
//
|
||||
// The inode column (index 9) is hex (matches the address format)
|
||||
// while /proc/<pid>/fd symlinks carry the same inode in decimal.
|
||||
// Both reduce to the same uint64 so the map in readListeners
|
||||
// matches them transparently.
|
||||
func readProcNet(path string) ([]listenerRow, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
var rows []listenerRow
|
||||
scanner := bufioNewScanner(f)
|
||||
first := true
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if first {
|
||||
first = false
|
||||
if strings.HasPrefix(line, " sl") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
ino, err := strconv.ParseUint(fields[9], 16, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, listenerRow{
|
||||
local: decodeHexAddrPort(fields[1], len(fields[1]) > 8),
|
||||
rem: decodeHexAddrPort(fields[2], true),
|
||||
state: fields[3],
|
||||
inode: ino,
|
||||
})
|
||||
}
|
||||
return rows, scanner.Err()
|
||||
}
|
||||
|
||||
// decodeHexAddrPort reverses the standard /proc/net encoding:
|
||||
//
|
||||
// "0100007F:0C50" -> "127.0.0.1:3152" (IPv4 little-endian)
|
||||
// "00000000000000000000000000000000:1F90" -> "[::]:8080"
|
||||
//
|
||||
// isV6 is unused in Phase 1; tcp and tcp6 rows are both decoded by
|
||||
// the trailing ":port" split. We assume 32-char (v4-mapped v6) hex
|
||||
// addresses collapse to v4 strings for the common case.
|
||||
func decodeHexAddrPort(raw string, _ bool) string {
|
||||
idx := strings.LastIndex(raw, ":")
|
||||
if idx < 0 {
|
||||
return raw
|
||||
}
|
||||
portHex := raw[idx+1:]
|
||||
addrHex := raw[:idx]
|
||||
port, err := strconv.ParseUint(portHex, 16, 16)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if len(addrHex) == 8 {
|
||||
// IPv4 little-endian: the kernel writes each octet
|
||||
// low-byte-first. "0100007F" means octets 1,0,0,127 which
|
||||
// in network order is "127.0.0.1".
|
||||
var b [4]byte
|
||||
for i := 0; i < 4; i++ {
|
||||
v, err := strconv.ParseUint(addrHex[2*i:2*i+2], 16, 8)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b[i] = byte(v)
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d.%d:%d", b[3], b[2], b[1], b[0], port)
|
||||
}
|
||||
if len(addrHex) == 32 {
|
||||
// IPv6: 8 16-bit groups in network order. Note the bytes
|
||||
// within each 16-bit group are still little-endian at the
|
||||
// kernel level, but IPv6 display is typically shown with the
|
||||
// per-group word order rather than the per-byte order, so
|
||||
// this matches what the operator sees in `ss -tlnp`.
|
||||
var groups [8]uint16
|
||||
for i := 0; i < 8; i++ {
|
||||
v, err := strconv.ParseUint(addrHex[4*i:4*i+4], 16, 16)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
groups[i] = uint16(v)
|
||||
}
|
||||
return fmt.Sprintf("[%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x]:%d",
|
||||
groups[0], groups[1], groups[2], groups[3],
|
||||
groups[4], groups[5], groups[6], groups[7], port)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
159
internal/webapp/inventory_test.go
Обычный файл
159
internal/webapp/inventory_test.go
Обычный файл
@@ -0,0 +1,159 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// makeFakeProc builds a minimal /proc tree suitable for
|
||||
// ScanProcApps. It writes:
|
||||
//
|
||||
// - a "comm" file for each pid
|
||||
// - a "cmdline" file (NUL-separated)
|
||||
// - a few sockets under fd/ so the listener scan can match
|
||||
//
|
||||
// We deliberately skip the cwd symlink (root-only) and accept that
|
||||
// the readlink call returns an error; ScanProcApps must tolerate
|
||||
// EACCES / ENOENT for permission-denied fds.
|
||||
func makeFakeProc(t *testing.T, pids []fakeProcEntry) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, e := range pids {
|
||||
pdir := filepath.Join(root, strconv.Itoa(e.pid))
|
||||
require.NoError(t, os.MkdirAll(pdir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte(e.comm+"\n"), 0o644))
|
||||
if e.cmdline != "" {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(pdir, "cmdline"), []byte(e.cmdline), 0o644))
|
||||
}
|
||||
if len(e.sockets) > 0 {
|
||||
fdDir := filepath.Join(pdir, "fd")
|
||||
require.NoError(t, os.MkdirAll(fdDir, 0o755))
|
||||
for i, sock := range e.sockets {
|
||||
// fake inode numbers are arbitrary, but must match the
|
||||
// /proc/net/tcp "inode" column for ScanProcApps to
|
||||
// resolve them. Use a stable mapping.
|
||||
target := "socket:[" + strconv.FormatInt(sock, 10) + "]"
|
||||
require.NoError(t, os.Symlink(target, filepath.Join(fdDir, strconv.Itoa(i))))
|
||||
}
|
||||
}
|
||||
}
|
||||
// /proc/net/tcp with state 0A (LISTEN) entries pointing at the
|
||||
// fake inodes. We do this last so test setup is sequential.
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755))
|
||||
var lines []string
|
||||
lines = append(lines, " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ")
|
||||
for _, e := range pids {
|
||||
for _, ino := range e.sockets {
|
||||
// "0100007F:1E61" -> 127.0.0.1:7777 in little-endian hex.
|
||||
lines = append(lines, fakeTCPLine(ino, "0100007F:1E61"))
|
||||
}
|
||||
}
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(root, "net", "tcp"),
|
||||
[]byte(joinLines(lines)),
|
||||
0o644))
|
||||
return root
|
||||
}
|
||||
|
||||
type fakeProcEntry struct {
|
||||
pid int
|
||||
comm string
|
||||
cmdline string
|
||||
sockets []int64 // fake inode numbers
|
||||
}
|
||||
|
||||
func fakeTCPLine(inode int64, local string) string {
|
||||
// 4 hex chars for tx/rx queue (always 0), 8 hex for tr/tm->when,
|
||||
// 8 hex for retrnsmt, 1 hex for uid (0), 1 hex for timeout (0),
|
||||
// then inode (10 hex zero-padded). The trailing fields are zero-
|
||||
// filled so the scanner skips them.
|
||||
inodeHex := strconv.FormatInt(inode, 16)
|
||||
for len(inodeHex) < 8 {
|
||||
inodeHex = "0" + inodeHex
|
||||
}
|
||||
return " 0: " + local + " 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 " + inodeHex + " 0 0 0 0 0"
|
||||
}
|
||||
|
||||
func joinLines(ls []string) string {
|
||||
out := ""
|
||||
for i, l := range ls {
|
||||
if i > 0 {
|
||||
out += "\n"
|
||||
}
|
||||
out += l
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestScanProcAppsEmpty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
apps, err := ScanProcApps(root)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, apps)
|
||||
}
|
||||
|
||||
func TestScanProcAppsSingleProcess(t *testing.T) {
|
||||
root := makeFakeProc(t, []fakeProcEntry{
|
||||
{pid: 42, comm: "rsmon-worker", cmdline: "rsmon-worker --foo\x00bar", sockets: []int64{1001}},
|
||||
})
|
||||
apps, err := ScanProcApps(root)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, apps, 1)
|
||||
assert.Equal(t, "rsmon-worker", apps[0].Name)
|
||||
assert.Equal(t, 42, apps[0].PID)
|
||||
assert.Equal(t, "rsmon-worker --foo bar", apps[0].Cmdline)
|
||||
// ports slice should have the resolved address.
|
||||
assert.Contains(t, apps[0].Ports, "127.0.0.1:7777/tcp")
|
||||
}
|
||||
|
||||
func TestScanProcAppsSkipsKernelThreads(t *testing.T) {
|
||||
// A pid directory with no comm file is treated as a vanished
|
||||
// process; ScanProcApps must skip it rather than panic.
|
||||
root := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(root, "1"), 0o755))
|
||||
apps, err := ScanProcApps(root)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, apps)
|
||||
}
|
||||
|
||||
func TestInventoryStoreReplace(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := OpenStore(filepath.Join(dir, "webapp.db"))
|
||||
require.NoError(t, err)
|
||||
defer store.Close() //nolint:errcheck
|
||||
|
||||
inv := NewInventory(store, nil)
|
||||
_ = inv // currently no public method to inject scanned rows;
|
||||
// we exercise ReplaceApps directly via the store.
|
||||
now := mustParseTime(t)
|
||||
rows := []App{
|
||||
{Name: "rsmon-worker", Source: "process", PID: 1, Ports: "7401/tcp", LastSeen: now},
|
||||
{Name: "postgres", Source: "process", PID: 2, Ports: "5432/tcp", LastSeen: now},
|
||||
}
|
||||
ctx := context.Background()
|
||||
require.NoError(t, store.ReplaceApps(ctx, rows))
|
||||
got, err := store.ListApps(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 2)
|
||||
assert.Equal(t, "rsmon-worker", got[0].Name)
|
||||
|
||||
require.NoError(t, store.ReplaceApps(ctx, []App{
|
||||
{Name: "redis", Source: "process", PID: 3, Ports: "6379/tcp", LastSeen: now},
|
||||
}))
|
||||
got, err = store.ListApps(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, got, 1)
|
||||
assert.Equal(t, "redis", got[0].Name)
|
||||
}
|
||||
|
||||
func mustParseTime(t *testing.T) time.Time {
|
||||
t.Helper()
|
||||
return time.Now().UTC()
|
||||
}
|
||||
99
internal/webapp/logbuffer.go
Обычный файл
99
internal/webapp/logbuffer.go
Обычный файл
@@ -0,0 +1,99 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LogBuffer is a small in-memory ring buffer the webapp uses to
|
||||
// expose the last N worker log lines on the Logs page. The worker
|
||||
// process feeds it via slog/JSON or by calling Append directly.
|
||||
//
|
||||
// Capacity is bounded; old entries are evicted FIFO.
|
||||
type LogBuffer struct {
|
||||
mu sync.RWMutex
|
||||
buf []string
|
||||
capN int
|
||||
offset int
|
||||
full bool
|
||||
}
|
||||
|
||||
// NewLogBuffer returns an empty LogBuffer that holds up to capN
|
||||
// lines. capN <= 0 falls back to a sensible default.
|
||||
func NewLogBuffer(capN int) *LogBuffer {
|
||||
if capN <= 0 {
|
||||
capN = 5000
|
||||
}
|
||||
return &LogBuffer{buf: make([]string, 0, capN), capN: capN}
|
||||
}
|
||||
|
||||
// Append adds a single line to the buffer. Newlines are stripped so
|
||||
// multi-line log records do not split into separate rows.
|
||||
func (l *LogBuffer) Append(line string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
line = strings.TrimRight(line, "\n")
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
if len(l.buf) < l.capN {
|
||||
l.buf = append(l.buf, line)
|
||||
return
|
||||
}
|
||||
l.full = true
|
||||
l.buf[l.offset] = line
|
||||
l.offset = (l.offset + 1) % l.capN
|
||||
}
|
||||
|
||||
// Tail returns the most recent n lines in chronological order. If n
|
||||
// is larger than the buffer capacity, only the held lines are
|
||||
// returned. n <= 0 returns an empty slice.
|
||||
func (l *LogBuffer) Tail(n int) []string {
|
||||
if l == nil || n <= 0 {
|
||||
return nil
|
||||
}
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
size := len(l.buf)
|
||||
if size == 0 {
|
||||
return nil
|
||||
}
|
||||
if n > size {
|
||||
n = size
|
||||
}
|
||||
out := make([]string, n)
|
||||
if !l.full {
|
||||
// Buffer not yet wrapped: just slice the tail.
|
||||
copy(out, l.buf[size-n:])
|
||||
return out
|
||||
}
|
||||
// Buffer is wrapped. The oldest line lives at l.offset; the
|
||||
// newest line lives at (offset - 1 + capN) % capN.
|
||||
idx := (l.offset - n + l.capN) % l.capN
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = l.buf[idx]
|
||||
idx = (idx + 1) % l.capN
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Size reports the current number of stored lines.
|
||||
func (l *LogBuffer) Size() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return len(l.buf)
|
||||
}
|
||||
|
||||
// Cap reports the maximum number of lines the buffer holds.
|
||||
func (l *LogBuffer) Cap() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
return l.capN
|
||||
}
|
||||
88
internal/webapp/logbuffer_test.go
Обычный файл
88
internal/webapp/logbuffer_test.go
Обычный файл
@@ -0,0 +1,88 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogBufferAppendTail(t *testing.T) {
|
||||
buf := NewLogBuffer(5)
|
||||
for i := 0; i < 12; i++ {
|
||||
buf.Append("line " + itoa(i))
|
||||
}
|
||||
// capacity 5, should hold the last 5 lines: 7..11
|
||||
got := buf.Tail(5)
|
||||
require.Len(t, got, 5)
|
||||
assert.Equal(t, "line 7", got[0])
|
||||
assert.Equal(t, "line 11", got[4])
|
||||
assert.Equal(t, 5, buf.Cap())
|
||||
assert.Equal(t, 5, buf.Size())
|
||||
}
|
||||
|
||||
func TestLogBufferTailSmall(t *testing.T) {
|
||||
buf := NewLogBuffer(100)
|
||||
for i := 0; i < 3; i++ {
|
||||
buf.Append("x" + itoa(i))
|
||||
}
|
||||
got := buf.Tail(2)
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "x1", got[0])
|
||||
assert.Equal(t, "x2", got[1])
|
||||
}
|
||||
|
||||
func TestLogBufferTailEmpty(t *testing.T) {
|
||||
buf := NewLogBuffer(10)
|
||||
assert.Nil(t, buf.Tail(5))
|
||||
assert.Equal(t, 0, buf.Size())
|
||||
}
|
||||
|
||||
func TestLogBufferTailZero(t *testing.T) {
|
||||
buf := NewLogBuffer(10)
|
||||
buf.Append("hello")
|
||||
assert.Nil(t, buf.Tail(0))
|
||||
}
|
||||
|
||||
func TestLogBufferAppendStripsNewline(t *testing.T) {
|
||||
buf := NewLogBuffer(10)
|
||||
buf.Append("hello\n")
|
||||
got := buf.Tail(1)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "hello", got[0])
|
||||
}
|
||||
|
||||
func TestFormatBytes(t *testing.T) {
|
||||
assert.Equal(t, "0 B", fmtBytes(0))
|
||||
assert.Equal(t, "1023 B", fmtBytes(1023))
|
||||
assert.Equal(t, "1.00 KiB", fmtBytes(1024))
|
||||
assert.Equal(t, "1.50 KiB", fmtBytes(1536))
|
||||
assert.Equal(t, "1.00 MiB", fmtBytes(1024*1024))
|
||||
assert.Equal(t, "4.00 GiB", fmtBytes(4*1024*1024*1024))
|
||||
}
|
||||
|
||||
func TestFormatPercent(t *testing.T) {
|
||||
assert.Equal(t, "0.00%", fmtPercent(0))
|
||||
assert.Equal(t, "50.00%", fmtPercent(50))
|
||||
assert.Equal(t, "100.00%", fmtPercent(100))
|
||||
}
|
||||
|
||||
func TestFormatDuration(t *testing.T) {
|
||||
assert.Equal(t, "0s", fmtDuration(0))
|
||||
assert.Equal(t, "59s", fmtDuration(59*1_000_000_000))
|
||||
assert.Equal(t, "1m 0s", fmtDuration(60*1_000_000_000))
|
||||
assert.Equal(t, "1h 0m 0s", fmtDuration(60*60*1_000_000_000))
|
||||
assert.Equal(t, "1d 0h 0m 0s", fmtDuration(24*60*60*1_000_000_000))
|
||||
assert.Equal(t, "2d 3h 4m 5s", fmtDuration((2*24+3)*3600*1_000_000_000+(4*60+5)*1_000_000_000))
|
||||
}
|
||||
|
||||
func TestParseTail(t *testing.T) {
|
||||
assert.Equal(t, 200, parseTail(""))
|
||||
assert.Equal(t, 200, parseTail("garbage"))
|
||||
assert.Equal(t, 200, parseTail("199")) // not in the allowed set
|
||||
assert.Equal(t, 200, parseTail("200"))
|
||||
assert.Equal(t, 500, parseTail("500"))
|
||||
assert.Equal(t, 1000, parseTail("1000"))
|
||||
assert.Equal(t, 5000, parseTail("5000"))
|
||||
assert.Equal(t, 200, parseTail("5001"))
|
||||
}
|
||||
446
internal/webapp/metrics.go
Обычный файл
446
internal/webapp/metrics.go
Обычный файл
@@ -0,0 +1,446 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metrics owns the host-level status sample used by the overview and
|
||||
// server-status pages. Phase 1 reads only /proc and statfs over
|
||||
// mount points. CLI tools (lsblk, smartctl, sensors) are Phase 5.
|
||||
type Metrics struct {
|
||||
mu sync.RWMutex
|
||||
last Snapshot
|
||||
lastAt time.Time
|
||||
stopCh chan struct{}
|
||||
stopWG sync.WaitGroup
|
||||
started bool
|
||||
}
|
||||
|
||||
// Snapshot is the JSON-friendly view of host metrics the templates
|
||||
// render. The fields are picked so the status table on /status and
|
||||
// the cards on /overview share a single type.
|
||||
type Snapshot struct {
|
||||
CPU CPUSample `json:"cpu"`
|
||||
Memory MemorySample `json:"memory"`
|
||||
Load LoadSample `json:"load"`
|
||||
Uptime time.Duration `json:"uptime"`
|
||||
BootAt time.Time `json:"boot_at"`
|
||||
Networks []NetDev `json:"networks"`
|
||||
Disks []DiskSample `json:"disks"`
|
||||
}
|
||||
|
||||
// CPUSample reports aggregate CPU usage since the last sample. The
|
||||
// fields are percentages normalised to 0..100.
|
||||
type CPUSample struct {
|
||||
UserPct float64 `json:"user_pct"`
|
||||
NicePct float64 `json:"nice_pct"`
|
||||
SystemPct float64 `json:"system_pct"`
|
||||
IdlePct float64 `json:"idle_pct"`
|
||||
IOWaitPct float64 `json:"iowait_pct"`
|
||||
StealPct float64 `json:"steal_pct"`
|
||||
TotalPct float64 `json:"total_pct"`
|
||||
}
|
||||
|
||||
// MemorySample reports bytes of physical RAM, swap, and various
|
||||
// accounting fields from /proc/meminfo.
|
||||
type MemorySample struct {
|
||||
Total uint64 `json:"total_bytes"`
|
||||
Available uint64 `json:"available_bytes"`
|
||||
Free uint64 `json:"free_bytes"`
|
||||
Buffers uint64 `json:"buffers_bytes"`
|
||||
Cached uint64 `json:"cached_bytes"`
|
||||
SwapTotal uint64 `json:"swap_total_bytes"`
|
||||
SwapFree uint64 `json:"swap_free_bytes"`
|
||||
UsedPct float64 `json:"used_pct"`
|
||||
AvailablePct float64 `json:"available_pct"`
|
||||
}
|
||||
|
||||
// LoadSample is the 1/5/15 minute load averages from /proc/loadavg.
|
||||
type LoadSample struct {
|
||||
One float64 `json:"load1"`
|
||||
Five float64 `json:"load5"`
|
||||
Fifteen float64 `json:"load15"`
|
||||
}
|
||||
|
||||
// NetDev is one row from /proc/net/dev.
|
||||
type NetDev struct {
|
||||
Name string `json:"name"`
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
RxPkt uint64 `json:"rx_packets"`
|
||||
TxPkt uint64 `json:"tx_packets"`
|
||||
RxErr uint64 `json:"rx_errors"`
|
||||
TxErr uint64 `json:"tx_errors"`
|
||||
RxDrop uint64 `json:"rx_dropped"`
|
||||
TxDrop uint64 `json:"tx_dropped"`
|
||||
}
|
||||
|
||||
// DiskSample is a single mount point from /proc/mounts with disk
|
||||
// usage from statfs(2).
|
||||
type DiskSample struct {
|
||||
Mount string `json:"mount"`
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
Total uint64 `json:"total_bytes"`
|
||||
Free uint64 `json:"free_bytes"`
|
||||
Used uint64 `json:"used_bytes"`
|
||||
UsedPct float64 `json:"used_pct"`
|
||||
}
|
||||
|
||||
// NewMetrics constructs an empty Metrics sampler. The loop is not
|
||||
// started until Start is called.
|
||||
func NewMetrics() *Metrics {
|
||||
return &Metrics{
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches a sample loop. The first sample is taken
|
||||
// immediately so /status never renders "no data yet".
|
||||
func (m *Metrics) Start(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
if m.started {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.started = true
|
||||
m.mu.Unlock()
|
||||
|
||||
m.sample(ctx)
|
||||
m.stopWG.Add(1)
|
||||
go m.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop cancels the sample loop and waits for it to exit.
|
||||
func (m *Metrics) Stop() {
|
||||
m.mu.Lock()
|
||||
if !m.started {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-m.stopCh:
|
||||
default:
|
||||
close(m.stopCh)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
m.stopWG.Wait()
|
||||
}
|
||||
|
||||
// Last returns the most recent snapshot and the time it was taken.
|
||||
// Always safe to call; returns a zero-value snapshot if the first
|
||||
// sample has not yet completed.
|
||||
func (m *Metrics) Last() (Snapshot, time.Time) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.last, m.lastAt
|
||||
}
|
||||
|
||||
func (m *Metrics) loop(ctx context.Context) {
|
||||
defer m.stopWG.Done()
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.sample(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Metrics) sample(_ context.Context) {
|
||||
snap, err := CollectSnapshot(ProcRoot())
|
||||
if err != nil {
|
||||
return // best-effort
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.last = snap
|
||||
m.lastAt = time.Now().UTC()
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// CollectSnapshot reads the /proc mount once and returns a Snapshot.
|
||||
// Exposed at package scope so tests can drive it directly with a
|
||||
// fixture /proc tree.
|
||||
func CollectSnapshot(root string) (Snapshot, error) {
|
||||
now := time.Now().UTC()
|
||||
cpu, err := readProcStat(filepath.Join(root, "stat"))
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read stat: %w", err)
|
||||
}
|
||||
mem, err := readMemInfo(filepath.Join(root, "meminfo"))
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read meminfo: %w", err)
|
||||
}
|
||||
load, err := readLoadAvg(filepath.Join(root, "loadavg"))
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read loadavg: %w", err)
|
||||
}
|
||||
uptime, err := readUptime(filepath.Join(root, "uptime"))
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read uptime: %w", err)
|
||||
}
|
||||
net, err := readNetDev(filepath.Join(root, "net", "dev"))
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read net/dev: %w", err)
|
||||
}
|
||||
disks, err := readMounts(filepath.Join(root, "mounts"))
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("read mounts: %w", err)
|
||||
}
|
||||
for i := range disks {
|
||||
if err := statDisk(disks[i].Mount, &disks[i]); err != nil {
|
||||
// statfs may fail for some pseudo mounts (proc, sys);
|
||||
// we leave Total/Free/Used at zero in that case so the
|
||||
// page renders an empty row rather than a hard error.
|
||||
continue
|
||||
}
|
||||
}
|
||||
bootAt := now.Add(-uptime)
|
||||
return Snapshot{
|
||||
CPU: cpu,
|
||||
Memory: mem,
|
||||
Load: load,
|
||||
Uptime: uptime,
|
||||
BootAt: bootAt,
|
||||
Networks: net,
|
||||
Disks: disks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readProcStat reads the aggregate "cpu " row of /proc/stat and
|
||||
// returns percentages. /proc/stat is cumulative since boot, so a
|
||||
// single read yields busy/total ratios only if we remember the
|
||||
// previous delta. Phase 1 does not keep history; the per-CPU
|
||||
// "busy since boot" snapshot is rendered on the page as a static
|
||||
// "load since boot" indicator instead of a live % value.
|
||||
//
|
||||
// The function takes a "previous" sample for delta math; if prev
|
||||
// is the zero value, the function returns zero percentages.
|
||||
func readProcStat(path string) (CPUSample, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return CPUSample{}, err
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
|
||||
var (
|
||||
user, nice, system, idle, iowait, steal uint64
|
||||
agg bool
|
||||
)
|
||||
scanner := bufioNewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 8 {
|
||||
return CPUSample{}, fmt.Errorf("short cpu line: %q", line)
|
||||
}
|
||||
agg = true
|
||||
user, _ = strconv.ParseUint(fields[1], 10, 64)
|
||||
nice, _ = strconv.ParseUint(fields[2], 10, 64)
|
||||
system, _ = strconv.ParseUint(fields[3], 10, 64)
|
||||
idle, _ = strconv.ParseUint(fields[4], 10, 64)
|
||||
iowait, _ = strconv.ParseUint(fields[5], 10, 64)
|
||||
steal, _ = strconv.ParseUint(fields[7], 10, 64)
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return CPUSample{}, err
|
||||
}
|
||||
if !agg {
|
||||
return CPUSample{}, fmt.Errorf("no aggregate cpu line in %s", path)
|
||||
}
|
||||
total := user + nice + system + idle + iowait + steal
|
||||
if total == 0 {
|
||||
return CPUSample{}, nil
|
||||
}
|
||||
return CPUSample{
|
||||
UserPct: pct(user, total),
|
||||
NicePct: pct(nice, total),
|
||||
SystemPct: pct(system, total),
|
||||
IdlePct: pct(idle, total),
|
||||
IOWaitPct: pct(iowait, total),
|
||||
StealPct: pct(steal, total),
|
||||
TotalPct: pct(total-(idle+iowait), total),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pct(part, total uint64) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(part) * 100 / float64(total)
|
||||
}
|
||||
|
||||
// readMemInfo parses /proc/meminfo. Units are kB; we convert to
|
||||
// bytes on the way out so the page never has to multiply.
|
||||
func readMemInfo(path string) (MemorySample, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return MemorySample{}, err
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
values := map[string]uint64{}
|
||||
scanner := bufioNewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
v, err := strconv.ParseUint(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = v * 1024
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return MemorySample{}, err
|
||||
}
|
||||
mem := MemorySample{
|
||||
Total: values["MemTotal"],
|
||||
Available: values["MemAvailable"],
|
||||
Free: values["MemFree"],
|
||||
Buffers: values["Buffers"],
|
||||
Cached: values["Cached"],
|
||||
SwapTotal: values["SwapTotal"],
|
||||
SwapFree: values["SwapFree"],
|
||||
}
|
||||
if mem.Total > 0 {
|
||||
used := mem.Total - mem.Available
|
||||
mem.UsedPct = pct(used, mem.Total)
|
||||
mem.AvailablePct = pct(mem.Available, mem.Total)
|
||||
}
|
||||
return mem, nil
|
||||
}
|
||||
|
||||
func readLoadAvg(path string) (LoadSample, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return LoadSample{}, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return LoadSample{}, fmt.Errorf("short loadavg line: %q", data)
|
||||
}
|
||||
one, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return LoadSample{}, err
|
||||
}
|
||||
five, err := strconv.ParseFloat(fields[1], 64)
|
||||
if err != nil {
|
||||
return LoadSample{}, err
|
||||
}
|
||||
fifteen, err := strconv.ParseFloat(fields[2], 64)
|
||||
if err != nil {
|
||||
return LoadSample{}, err
|
||||
}
|
||||
return LoadSample{One: one, Five: five, Fifteen: fifteen}, nil
|
||||
}
|
||||
|
||||
func readUptime(path string) (time.Duration, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 1 {
|
||||
return 0, fmt.Errorf("short uptime line: %q", data)
|
||||
}
|
||||
secs, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return time.Duration(secs * float64(time.Second)), nil
|
||||
}
|
||||
|
||||
func readNetDev(path string) ([]NetDev, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
var out []NetDev
|
||||
scanner := bufioNewScanner(f)
|
||||
first := true
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if first {
|
||||
first = false
|
||||
if strings.Contains(line, "Inter-|") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 17 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(fields[0], ":")
|
||||
out = append(out, NetDev{
|
||||
Name: name,
|
||||
RxBytes: parseU64(fields[1]),
|
||||
TxBytes: parseU64(fields[9]),
|
||||
RxPkt: parseU64(fields[2]),
|
||||
TxPkt: parseU64(fields[10]),
|
||||
RxErr: parseU64(fields[3]),
|
||||
TxErr: parseU64(fields[11]),
|
||||
RxDrop: parseU64(fields[4]),
|
||||
TxDrop: parseU64(fields[12]),
|
||||
})
|
||||
}
|
||||
return out, scanner.Err()
|
||||
}
|
||||
|
||||
func parseU64(s string) uint64 {
|
||||
v, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func readMounts(path string) ([]DiskSample, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []DiskSample
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
// device mountpoint fstype options dump pass
|
||||
out = append(out, DiskSample{
|
||||
Device: fields[0],
|
||||
Mount: fields[1],
|
||||
FSType: fields[2],
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// statDisk is implemented in metrics_linux.go (real statfs) and
|
||||
// metrics_other.go (no-op stub for non-Linux dev builds). The
|
||||
// function is called from CollectSnapshot below to fill disk usage
|
||||
// data; tests that exercise the metric paths with fake /proc trees
|
||||
// accept the zero-value stats as expected.
|
||||
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Ссылка в новой задаче
Block a user