feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 &notificationBuffer{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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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(&notification).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(&notification, &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(&notification).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(&notification, &contact, &loaded)
require.NoError(t, err)
require.NotNil(t, first)
second, err := EnqueueNotificationTaskFromMessage(&notification, &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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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.

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше