feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
319
internal/wire/types.go
Обычный файл
319
internal/wire/types.go
Обычный файл
@@ -0,0 +1,319 @@
|
||||
// Package wire contains shared wire format types for control plane <-> worker communication
|
||||
package wire
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// RegisterRequest is sent to the control plane registration API.
|
||||
type RegisterRequest struct {
|
||||
WorkerID string `json:"worker_id" binding:"required"`
|
||||
RegionCode string `json:"region_code" binding:"required"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
TaskEnvelope bool `json:"task_envelope"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
}
|
||||
|
||||
// RegisterResponse contains the generated auth token.
|
||||
type RegisterResponse struct {
|
||||
AuthToken string `json:"auth_token"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
}
|
||||
|
||||
// HeartbeatRequest is sent periodically by workers to indicate liveness.
|
||||
type HeartbeatRequest struct {
|
||||
ActiveChecks int `json:"active_checks"`
|
||||
QueueDepth int `json:"queue_depth"`
|
||||
ActiveNotifications int `json:"active_notifications,omitempty"`
|
||||
NotificationQueueDepth int `json:"notification_queue_depth,omitempty"`
|
||||
}
|
||||
|
||||
// CheckJob represents a check assigned to a worker
|
||||
type CheckJob struct {
|
||||
JobID string `json:"job_id"` // unique job identifier
|
||||
LeaseToken string `json:"lease_token,omitempty"`
|
||||
CheckID int64 `json:"check_id"`
|
||||
MonitorID int64 `json:"monitor_id"`
|
||||
Kind string `json:"kind"` // "http", "ssl", etc.
|
||||
Host string `json:"host"`
|
||||
URL *string `json:"url"`
|
||||
Interval int `json:"interval"`
|
||||
Settings json.RawMessage `json:"settings"` // CheckSettings JSON
|
||||
}
|
||||
|
||||
// JobsResponse contains a batch of check jobs for a worker
|
||||
type JobsResponse struct {
|
||||
Jobs []CheckJob `json:"jobs"`
|
||||
}
|
||||
|
||||
// CheckResultReport is sent by workers to report check results
|
||||
type CheckResultReport struct {
|
||||
JobID string `json:"job_id"`
|
||||
LeaseToken string `json:"lease_token,omitempty"`
|
||||
CheckID int64 `json:"check_id"`
|
||||
MonitorID int64 `json:"monitor_id"`
|
||||
State string `json:"state"` // "OK", "ERR", "WARN", "FAIL"
|
||||
Error *string `json:"error"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Infos []string `json:"infos"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
ExpiresAt *string `json:"expires_at"` // RFC3339 if set
|
||||
Metrics []MetricPoint `json:"metrics,omitempty"`
|
||||
}
|
||||
|
||||
// MetricPoint is a TSDB point reported by a worker for control-plane persistence.
|
||||
type MetricPoint struct {
|
||||
Metric string `json:"metric"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
Fields map[string]interface{} `json:"fields"`
|
||||
}
|
||||
|
||||
// ServerMetricReport is a worker-local host snapshot. Workers never receive
|
||||
// database credentials: the control plane validates worker/server ownership
|
||||
// then persists this cache and its TSDB points.
|
||||
type ServerMetricReport struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
CPUPercent *float64 `json:"cpu_percent,omitempty"`
|
||||
MemUsed *int64 `json:"mem_used,omitempty"`
|
||||
MemTotal *int64 `json:"mem_total,omitempty"`
|
||||
DiskUsed *int64 `json:"disk_used,omitempty"`
|
||||
DiskTotal *int64 `json:"disk_total,omitempty"`
|
||||
NetRx *int64 `json:"net_rx,omitempty"`
|
||||
NetTx *int64 `json:"net_tx,omitempty"`
|
||||
HostUptimeSec *int64 `json:"host_uptime_sec,omitempty"`
|
||||
Load1 *float64 `json:"load1,omitempty"`
|
||||
Load5 *float64 `json:"load5,omitempty"`
|
||||
Load15 *float64 `json:"load15,omitempty"`
|
||||
ProcessCount *int `json:"process_count,omitempty"`
|
||||
Processes []ProcessMetric `json:"processes,omitempty"`
|
||||
Networks []NetworkMetric `json:"networks,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessMetric and NetworkMetric are bounded diagnostic snapshots, not a
|
||||
// second time series. The worker enforces their limits before sending them.
|
||||
type ProcessMetric struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryRSS int64 `json:"memory_rss"`
|
||||
}
|
||||
|
||||
type NetworkMetric struct {
|
||||
Interface string `json:"interface"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
// ResultsRequest contains multiple check results being reported
|
||||
type ResultsRequest struct {
|
||||
Results []CheckResultReport `json:"results"`
|
||||
}
|
||||
|
||||
// LLMConfig is an LLM endpoint configuration sent to workers.
|
||||
type LLMConfig struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// SMTPCredential is the wire form of an SMTP notification credential pushed
|
||||
// to workers via the init/config refresh. See docs/worker-protocol.md
|
||||
// "Credentials Push".
|
||||
type SMTPCredential struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Server string `json:"server"`
|
||||
Port int `json:"port"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
FromName string `json:"from_name"`
|
||||
FromAddress string `json:"from_address"`
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||||
}
|
||||
|
||||
// TelegramCredential is the wire form of a Telegram bot credential pushed
|
||||
// to workers via the init/config refresh.
|
||||
type TelegramCredential struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BotName string `json:"bot_name"`
|
||||
Token string `json:"token"`
|
||||
APIURL string `json:"api_url"`
|
||||
}
|
||||
|
||||
// WebhookCredential is the wire form of the shared webhook signing secret
|
||||
// (one global secret per environment; the contact URL is per-task). Phase 4
|
||||
// will move to per-account secrets.
|
||||
type WebhookCredential struct {
|
||||
SigningSecret string `json:"signing_secret"`
|
||||
}
|
||||
|
||||
// MattermostCredential is the wire form of the shared Mattermost default
|
||||
// webhook. Per-account credentials (Phase 4) override this when present.
|
||||
type MattermostCredential struct {
|
||||
DefaultUsername string `json:"default_username,omitempty"`
|
||||
DefaultIconURL string `json:"default_icon_url,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationCredentials groups all notification credentials the worker
|
||||
// is allowed to use, pushed via init.credentials (and every 5m config refresh).
|
||||
// The shape matches the per-method arrays shown in docs/worker-protocol.md
|
||||
// "Credentials Push" so the worker can resolve a credential by method directly.
|
||||
type NotificationCredentials struct {
|
||||
SMTP []SMTPCredential `json:"smtp,omitempty"`
|
||||
Telegram []TelegramCredential `json:"telegram,omitempty"`
|
||||
Webhook *WebhookCredential `json:"webhook,omitempty"`
|
||||
Mattermost *MattermostCredential `json:"mattermost,omitempty"`
|
||||
}
|
||||
|
||||
// SystemContact is the wire form of a Contact flagged is_system=true.
|
||||
// Workers notify these contacts directly when the main API is
|
||||
// unreachable, bypassing the central tasks queue. See
|
||||
// docs/distributed/notifications-from-worker.md "System Selfcheck".
|
||||
type SystemContact struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"` // "email", "telegram_private", "telegram_group"
|
||||
Value string `json:"value"` // email address or numeric telegram chat id
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// PeerInfo is the wire form of a peer worker this worker can reach
|
||||
// over HTTP for cross-worker confirmation (see
|
||||
// docs/distributed/worker-to-worker-raft.md and
|
||||
// docs/plans/network-diagnostics-partial.md §4.1). The control plane
|
||||
// pushes one entry per other active worker via WorkerInit.Peers; the
|
||||
// current worker is excluded so a worker never dials itself.
|
||||
//
|
||||
// Login/Password are populated only when the peer has basic auth
|
||||
// configured (WORKER_LOGIN / WORKER_PASSWORD). They are the same
|
||||
// shared secret every worker is configured with locally, so pushing
|
||||
// them per-peer is safe inside a trusted control-plane / worker fleet
|
||||
// and avoids the per-peer registration ceremony. The fields are
|
||||
// omitted from JSON when empty so a legacy control plane that does
|
||||
// not yet fill them in still produces a wire-compatible payload.
|
||||
type PeerInfo struct {
|
||||
WorkerID string `json:"worker_id"`
|
||||
URL string `json:"url"`
|
||||
RegionCode string `json:"region_code,omitempty"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
// WorkerInit is sent by the control plane after websocket authentication.
|
||||
type WorkerInit struct {
|
||||
WorkerID string `json:"worker_id"`
|
||||
RegionCode string `json:"region_code"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
NotificationMethods []string `json:"notification_methods,omitempty"`
|
||||
NotificationAccounts []int64 `json:"notification_accounts,omitempty"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ServerID *int64 `json:"server_id,omitempty"`
|
||||
LLMs []LLMConfig `json:"llms,omitempty"`
|
||||
Credentials *NotificationCredentials `json:"credentials,omitempty"`
|
||||
SystemContacts []SystemContact `json:"system_contacts,omitempty"`
|
||||
// Peers lists the other active workers this node can reach over
|
||||
// HTTP for cross-worker confirmation / Raft-style consensus on
|
||||
// the master API selfcheck. The current worker is excluded by
|
||||
// the control plane; a worker that receives an empty list treats
|
||||
// the selfcheck as a single-node decision (no peer polling).
|
||||
Peers []PeerInfo `json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEnvelope is the task frame the control plane sends on the worker
|
||||
// websocket. It is a tagged union over (check | notification); the existing
|
||||
// check shape is preserved so workers built against the original CheckJob
|
||||
// envelope keep working until they are rebuilt.
|
||||
//
|
||||
// Phase 1 of docs/plans/worker-notifier-mvp.md only emits NotificationTask
|
||||
// envelopes; the check kind remains the legacy Task CheckJob path.
|
||||
type TaskEnvelope struct {
|
||||
Type string `json:"type"` // "check" | "notification"
|
||||
JobID string `json:"job_id"` // mirrors the inner Job.ID for dispatch
|
||||
Job *CheckJob `json:"check,omitempty"` // check payload (existing path)
|
||||
Notify *NotificationTask `json:"notification,omitempty"` // notification payload (new path)
|
||||
}
|
||||
|
||||
const (
|
||||
TaskTypeCheck = "check"
|
||||
TaskTypeNotification = "notification"
|
||||
)
|
||||
|
||||
// NotificationTask is the wire shape of one notification delivery attempt.
|
||||
// It carries everything the worker needs to render and send one message
|
||||
// without going back to the database: pre-rendered subject/body, the contact
|
||||
// endpoint, and the notification/monitor context for audit logging.
|
||||
type NotificationTask struct {
|
||||
JobID string `json:"job_id"`
|
||||
LeaseToken string `json:"lease_token,omitempty"`
|
||||
AccountID int64 `json:"account_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
NotificationID int64 `json:"notification_id"`
|
||||
EventIDs []int64 `json:"event_ids"`
|
||||
CheckID *int64 `json:"check_id,omitempty"`
|
||||
MonitorID *int64 `json:"monitor_id,omitempty"`
|
||||
CredentialID *int64 `json:"credential_id,omitempty"`
|
||||
Method string `json:"method"` // "email" | "telegram" | "webhook" | "mattermost" | "sms" | "voice"
|
||||
Contact NotificationContact `json:"contact"`
|
||||
Subject string `json:"subject"`
|
||||
BodyText string `json:"body_text"`
|
||||
BodyMarkdown string `json:"body_markdown"`
|
||||
BodyHTML string `json:"body_html"`
|
||||
Language string `json:"language,omitempty"`
|
||||
MessageKind string `json:"message_kind"` // "down" | "up" | "exp" | "test"
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationContact is the wire form of Contact. Kept minimal so the
|
||||
// worker can deliver without pulling additional DB rows.
|
||||
type NotificationContact struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// NotificationResultReport is the wire shape returned by the worker after
|
||||
// attempting one NotificationTask. Status drives the control-plane result
|
||||
// handler (see docs/plans/worker-notifier-mvp.md section 5.3).
|
||||
type NotificationResultReport struct {
|
||||
JobID string `json:"job_id"`
|
||||
LeaseToken string `json:"lease_token,omitempty"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
Status string `json:"status"` // "delivered" | "retryable" | "permanent" | "partial"
|
||||
ProviderResponse *string `json:"provider_response,omitempty"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Notification result status enums. Mirror the wire JSON values so the
|
||||
// control-plane handler and the worker executor can speak the same language
|
||||
// without translating strings.
|
||||
const (
|
||||
NotificationResultDelivered = "delivered"
|
||||
NotificationResultRetryable = "retryable"
|
||||
NotificationResultPermanent = "permanent"
|
||||
NotificationResultPartial = "partial"
|
||||
)
|
||||
|
||||
// WorkerMessage is the websocket envelope used by control plane and workers.
|
||||
// TaskEnvelope carries normal generic tasks. The legacy Task and
|
||||
// NotificationTask fields remain accepted by workers during rollout.
|
||||
type WorkerMessage struct {
|
||||
Kind string `json:"kind"`
|
||||
Init *WorkerInit `json:"init,omitempty"`
|
||||
Task *CheckJob `json:"task,omitempty"`
|
||||
TaskEnvelope *TaskEnvelope `json:"task_envelope,omitempty"`
|
||||
NotificationTask *NotificationTask `json:"notification_task,omitempty"`
|
||||
Event *json.RawMessage `json:"event,omitempty"`
|
||||
Result *CheckResultReport `json:"result,omitempty"`
|
||||
NotificationResult *NotificationResultReport `json:"notification_result,omitempty"`
|
||||
ServerMetric *ServerMetricReport `json:"server_metric,omitempty"`
|
||||
Heartbeat *HeartbeatRequest `json:"heartbeat,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
305
internal/wire/types_test.go
Обычный файл
305
internal/wire/types_test.go
Обычный файл
@@ -0,0 +1,305 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestWorkerInit_CredentialsJSON verifies that WorkerInit with Credentials
|
||||
// round-trips through JSON with the expected nested structure.
|
||||
func TestWorkerInit_CredentialsJSON(t *testing.T) {
|
||||
init := WorkerInit{
|
||||
WorkerID: "worker-1",
|
||||
RegionCode: "ru",
|
||||
Version: "v1",
|
||||
Concurrency: 4,
|
||||
Credentials: &NotificationCredentials{
|
||||
SMTP: []SMTPCredential{{
|
||||
ID: 11,
|
||||
Name: "primary",
|
||||
Server: "smtp.example.com",
|
||||
Port: 587,
|
||||
Login: "alerts@example.com",
|
||||
Password: "smtp-password-xyz",
|
||||
FromName: "RSMon Alerts",
|
||||
FromAddress: "alerts@example.com",
|
||||
}},
|
||||
Telegram: []TelegramCredential{{
|
||||
ID: 22,
|
||||
Name: "main-bot",
|
||||
BotName: "rsmon_alerts_bot",
|
||||
Token: "bot-token-9876543210:ABCDEFG",
|
||||
APIURL: "https://api.telegram.org",
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := string(data)
|
||||
assert.Contains(t, out, `"credentials":{`,
|
||||
"credentials must serialize as a top-level object, got %s", out)
|
||||
assert.Contains(t, out, `"smtp":[`,
|
||||
"credentials.smtp must be an array, got %s", out)
|
||||
assert.Contains(t, out, `"telegram":[`,
|
||||
"credentials.telegram must be an array, got %s", out)
|
||||
assert.Contains(t, out, `"smtp-password-xyz"`,
|
||||
"smtp password must round-trip, got %s", out)
|
||||
assert.Contains(t, out, `"bot-token-9876543210:ABCDEFG"`,
|
||||
"telegram token must round-trip, got %s", out)
|
||||
|
||||
var decoded WorkerInit
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
require.NotNil(t, decoded.Credentials,
|
||||
"Credentials must unmarshal back into a non-nil pointer")
|
||||
require.Len(t, decoded.Credentials.SMTP, 1)
|
||||
require.Len(t, decoded.Credentials.Telegram, 1)
|
||||
assert.Equal(t, "primary", decoded.Credentials.SMTP[0].Name)
|
||||
assert.Equal(t, "smtp-password-xyz", decoded.Credentials.SMTP[0].Password)
|
||||
assert.Equal(t, 587, decoded.Credentials.SMTP[0].Port)
|
||||
assert.Equal(t, "main-bot", decoded.Credentials.Telegram[0].Name)
|
||||
assert.Equal(t, "bot-token-9876543210:ABCDEFG", decoded.Credentials.Telegram[0].Token)
|
||||
}
|
||||
|
||||
// TestWorkerInit_CredentialsOmittedWhenNil verifies the omitempty contract:
|
||||
// a WorkerInit with no credentials must not serialize the credentials key.
|
||||
func TestWorkerInit_CredentialsOmittedWhenNil(t *testing.T) {
|
||||
init := WorkerInit{WorkerID: "w-1", Concurrency: 1}
|
||||
data, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, strings.Contains(string(data), `"credentials"`),
|
||||
"credentials key must be omitted when nil, got %s", string(data))
|
||||
}
|
||||
|
||||
// TestNotificationCredentials_EmptySlicesOmitted ensures both inner slices
|
||||
// honor omitempty so an empty config is a compact object.
|
||||
func TestNotificationCredentials_EmptySlicesOmitted(t *testing.T) {
|
||||
creds := NotificationCredentials{}
|
||||
data, err := json.Marshal(creds)
|
||||
require.NoError(t, err)
|
||||
out := string(data)
|
||||
assert.False(t, strings.Contains(out, `"smtp"`),
|
||||
"empty smtp slice must be omitted, got %s", out)
|
||||
assert.False(t, strings.Contains(out, `"telegram"`),
|
||||
"empty telegram slice must be omitted, got %s", out)
|
||||
}
|
||||
|
||||
// TestWorkerInit_URLRoundTrip verifies that the URL field added in Task 2
|
||||
// round-trips through JSON in both directions and honors omitempty when
|
||||
// empty so legacy workers that do not push a URL stay wire-compatible.
|
||||
func TestWorkerInit_URLRoundTrip(t *testing.T) {
|
||||
init := WorkerInit{
|
||||
WorkerID: "worker-1",
|
||||
RegionCode: "ru",
|
||||
Version: "v1",
|
||||
Concurrency: 4,
|
||||
URL: "https://worker-eu.example.com",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"url":"https://worker-eu.example.com"`,
|
||||
"URL must serialize as a top-level url field, got %s", string(data))
|
||||
|
||||
var decoded WorkerInit
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
assert.Equal(t, "https://worker-eu.example.com", decoded.URL,
|
||||
"URL must round-trip back into the WorkerInit struct")
|
||||
}
|
||||
|
||||
// TestWorkerInit_URLOmittedWhenEmpty ensures the omitempty contract for
|
||||
// the URL field: a worker that does not yet advertise a URL must not
|
||||
// push an empty url key to the control plane.
|
||||
func TestWorkerInit_URLOmittedWhenEmpty(t *testing.T) {
|
||||
init := WorkerInit{WorkerID: "w-1", Concurrency: 1}
|
||||
data, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, strings.Contains(string(data), `"url"`),
|
||||
"empty URL must be omitted from the JSON envelope, got %s", string(data))
|
||||
}
|
||||
|
||||
// TestRegisterRequest_URLRoundTrip mirrors the init test for the
|
||||
// registration payload sent at POST /api/internal/workers/register.
|
||||
func TestRegisterRequest_URLRoundTrip(t *testing.T) {
|
||||
req := RegisterRequest{
|
||||
WorkerID: "worker-eu-1",
|
||||
RegionCode: "eu",
|
||||
Version: "v1",
|
||||
URL: "https://worker-eu.example.com",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"url":"https://worker-eu.example.com"`,
|
||||
"URL must serialize into the register payload, got %s", string(data))
|
||||
|
||||
var decoded RegisterRequest
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
assert.Equal(t, "https://worker-eu.example.com", decoded.URL)
|
||||
}
|
||||
|
||||
// TestRegisterRequest_URLOmittedWhenEmpty guards the same wire
|
||||
// compatibility for the register payload.
|
||||
func TestRegisterRequest_URLOmittedWhenEmpty(t *testing.T) {
|
||||
req := RegisterRequest{WorkerID: "w-1", RegionCode: "ru"}
|
||||
data, err := json.Marshal(req)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, strings.Contains(string(data), `"url"`),
|
||||
"empty URL must be omitted from the register payload, got %s", string(data))
|
||||
}
|
||||
|
||||
// TestWorkerInit_NotificationCapabilitiesRoundTrip ensures the
|
||||
// notification_methods + notification_accounts fields added by
|
||||
// docs/plans/worker-notifier-mvp.md section 5.4 round-trip through JSON so
|
||||
// the worker can read them after the init push.
|
||||
func TestWorkerInit_NotificationCapabilitiesRoundTrip(t *testing.T) {
|
||||
init := WorkerInit{
|
||||
WorkerID: "w-1",
|
||||
RegionCode: "production",
|
||||
Concurrency: 8,
|
||||
NotificationMethods: []string{"email", "telegram", "webhook", "mattermost"},
|
||||
NotificationAccounts: []int64{7, 8, 9},
|
||||
}
|
||||
data, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
body := string(data)
|
||||
assert.Contains(t, body, `"notification_methods":["email","telegram","webhook","mattermost"]`)
|
||||
assert.Contains(t, body, `"notification_accounts":[7,8,9]`)
|
||||
|
||||
var decoded WorkerInit
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
assert.Equal(t, init.NotificationMethods, decoded.NotificationMethods)
|
||||
assert.Equal(t, init.NotificationAccounts, decoded.NotificationAccounts)
|
||||
}
|
||||
|
||||
// TestWorkerInit_NotificationCapabilitiesOmittedWhenEmpty guards the
|
||||
// omitempty contract for the new fields so a worker that does not advertise
|
||||
// notification capabilities stays wire-compatible.
|
||||
func TestWorkerInit_NotificationCapabilitiesOmittedWhenEmpty(t *testing.T) {
|
||||
init := WorkerInit{WorkerID: "w-1", RegionCode: "ru", Concurrency: 1}
|
||||
data, err := json.Marshal(init)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, strings.Contains(string(data), `"notification_methods"`))
|
||||
assert.False(t, strings.Contains(string(data), `"notification_accounts"`))
|
||||
}
|
||||
|
||||
// TestNotificationTask_RoundTrip verifies the new notification task payload
|
||||
// carries the full envelope (contact, method, pre-rendered body) so the
|
||||
// worker can deliver without touching the database.
|
||||
func TestNotificationTask_RoundTrip(t *testing.T) {
|
||||
task := NotificationTask{
|
||||
JobID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
AccountID: 42,
|
||||
MessageID: 12345,
|
||||
NotificationID: 678,
|
||||
EventIDs: []int64{987, 988},
|
||||
CredentialID: int64Ptr(77),
|
||||
Method: "email",
|
||||
Contact: NotificationContact{
|
||||
ID: 1,
|
||||
Kind: "email",
|
||||
Value: "ops@example.com",
|
||||
Name: "Ops on-call",
|
||||
},
|
||||
Subject: "[rsmon] example.com is down",
|
||||
BodyText: "Down since 2026-06-24T12:00Z.",
|
||||
BodyMarkdown: "**Down** since 2026-06-24T12:00Z.",
|
||||
BodyHTML: "<p><strong>Down</strong> since 2026-06-24T12:00Z.</p>",
|
||||
Language: "en",
|
||||
MessageKind: "down",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(task)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded NotificationTask
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
|
||||
assert.Equal(t, task.JobID, decoded.JobID)
|
||||
assert.Equal(t, task.AccountID, decoded.AccountID)
|
||||
assert.Equal(t, task.MessageID, decoded.MessageID)
|
||||
assert.Equal(t, task.Method, decoded.Method)
|
||||
assert.Equal(t, task.CredentialID, decoded.CredentialID)
|
||||
assert.Equal(t, task.Contact, decoded.Contact)
|
||||
assert.Equal(t, task.Subject, decoded.Subject)
|
||||
assert.Equal(t, task.BodyHTML, decoded.BodyHTML)
|
||||
assert.Equal(t, task.EventIDs, decoded.EventIDs)
|
||||
assert.Equal(t, task.MessageKind, decoded.MessageKind)
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
|
||||
// TestNotificationResultReport_RoundTrip ensures the result report carries
|
||||
// every field the control-plane result handler relies on (status,
|
||||
// retry_after, provider_response).
|
||||
func TestNotificationResultReport_RoundTrip(t *testing.T) {
|
||||
respStr := "250 OK id=1234"
|
||||
errStr := "smtp 421 retry-after: 60"
|
||||
retry := 60
|
||||
report := NotificationResultReport{
|
||||
JobID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
MessageID: 12345,
|
||||
Status: "retryable",
|
||||
ProviderResponse: &respStr,
|
||||
DurationMs: 240,
|
||||
Error: &errStr,
|
||||
RetryAfterSeconds: &retry,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(report)
|
||||
require.NoError(t, err)
|
||||
body := string(data)
|
||||
assert.Contains(t, body, `"status":"retryable"`)
|
||||
assert.Contains(t, body, `"retry_after_seconds":60`)
|
||||
assert.Contains(t, body, `"duration_ms":240`)
|
||||
|
||||
var decoded NotificationResultReport
|
||||
require.NoError(t, json.Unmarshal(data, &decoded))
|
||||
assert.Equal(t, report.JobID, decoded.JobID)
|
||||
assert.Equal(t, report.Status, decoded.Status)
|
||||
assert.Equal(t, "250 OK id=1234", *decoded.ProviderResponse)
|
||||
require.NotNil(t, decoded.RetryAfterSeconds)
|
||||
assert.Equal(t, 60, *decoded.RetryAfterSeconds)
|
||||
require.NotNil(t, decoded.Error)
|
||||
assert.Equal(t, errStr, *decoded.Error)
|
||||
}
|
||||
|
||||
// TestWorkerMessage_TaskEnvelopeBranches confirms both the rollout-compatible
|
||||
// legacy CheckJob frame and the generic task envelope.
|
||||
func TestWorkerMessage_TaskEnvelopeBranches(t *testing.T) {
|
||||
legacy := WorkerMessage{
|
||||
Kind: "task",
|
||||
Task: &CheckJob{JobID: "job-1", CheckID: 11, Kind: "http", Host: "example.com"},
|
||||
}
|
||||
data, err := json.Marshal(legacy)
|
||||
require.NoError(t, err)
|
||||
body := string(data)
|
||||
assert.Contains(t, body, `"task":{"job_id":"job-1","check_id":11`,
|
||||
"legacy frame must carry the task payload, got %s", body)
|
||||
assert.False(t, strings.Contains(body, `"notification_task"`),
|
||||
"legacy check frame must not surface the notification fields")
|
||||
assert.False(t, strings.Contains(body, `"notification_result"`),
|
||||
"legacy check frame must not surface the notification fields")
|
||||
|
||||
notification := WorkerMessage{
|
||||
Kind: "task",
|
||||
TaskEnvelope: &TaskEnvelope{
|
||||
Type: TaskTypeNotification, JobID: "job-2", Notify: &NotificationTask{
|
||||
JobID: "job-2", Method: "email", MessageKind: "down", Subject: "down",
|
||||
Contact: NotificationContact{ID: 1, Kind: "email", Value: "ops@example.com"},
|
||||
},
|
||||
},
|
||||
}
|
||||
data2, err := json.Marshal(notification)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data2), `"task_envelope":{"type":"notification","job_id":"job-2"`)
|
||||
assert.False(t, strings.Contains(string(data2), `"check":`),
|
||||
"notification frame must not surface the legacy check field")
|
||||
}
|
||||
Ссылка в новой задаче
Block a user