feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
39
checks/ctcp/result.go
Обычный файл
39
checks/ctcp/result.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package ctcp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single TCP check. It embeds
|
||||
// checkresult.CheckResult and adds the resolved address so logs /
|
||||
// metrics can show what was actually dialed.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
// InfluxFields reports the dial duration in milliseconds — same
|
||||
// convention as chttp and cping.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags returns the standard set of tags used by chttp / cping /
|
||||
// cdns. The "state" tag is the final Result.State set by Perform().
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
77
checks/ctcp/tcp.go
Обычный файл
77
checks/ctcp/tcp.go
Обычный файл
@@ -0,0 +1,77 @@
|
||||
// Package ctcp provides TCP connect / port check functionality for RSMon.
|
||||
//
|
||||
// Semantics: the check opens a TCP connection to host:port using the
|
||||
// dialer's timeout, and reports OK as soon as the kernel-level
|
||||
// handshake completes (no banner read, no payload sent). Any dial
|
||||
// failure (refused, timed out, network unreachable, no route, ...)
|
||||
// is reported as ERR with the underlying error message so operators
|
||||
// can distinguish configuration problems from real outages.
|
||||
//
|
||||
// Settings consumed from models.CheckSettings:
|
||||
// - port (string): TCP port to dial. Defaults to 80 when empty.
|
||||
// - timeout (int, seconds): per-dial budget. Clamped to at least 1s.
|
||||
// - host (string): optional override of the monitor host.
|
||||
package ctcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
|
||||
defaultTimeout = 5 * time.Second
|
||||
minTimeout = 1 * time.Second
|
||||
)
|
||||
|
||||
// Perform executes a single TCP connect check.
|
||||
func Perform(c *models.Check) *Result {
|
||||
r := &Result{}
|
||||
settings := c.GetSettings()
|
||||
|
||||
host := c.Monitor.Host
|
||||
if settings.Host != "" {
|
||||
host = settings.Host
|
||||
}
|
||||
if host == "" {
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("tcp: empty host")
|
||||
return r
|
||||
}
|
||||
|
||||
port := settings.Port
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
|
||||
timeout := time.Duration(settings.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
if timeout < minTimeout {
|
||||
timeout = minTimeout
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
dialer := &net.Dialer{Timeout: timeout, DualStack: true}
|
||||
start := time.Now()
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
r.Duration = time.Since(start)
|
||||
if err != nil {
|
||||
r.State = stateERR
|
||||
r.Error = err
|
||||
return r
|
||||
}
|
||||
r.RemoteAddr = conn.RemoteAddr().String()
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
r.Warnings = append(r.Warnings, fmt.Sprintf("close: %v", closeErr))
|
||||
}
|
||||
r.State = stateOK
|
||||
r.Infos = append(r.Infos, fmt.Sprintf("tcp %s in %.2fms", addr, float64(r.Duration.Microseconds())/1000.0))
|
||||
return r
|
||||
}
|
||||
164
checks/ctcp/tcp_test.go
Обычный файл
164
checks/ctcp/tcp_test.go
Обычный файл
@@ -0,0 +1,164 @@
|
||||
package ctcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
type settings struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
PacketSize int `json:"packet_size,omitempty"`
|
||||
}
|
||||
|
||||
func newCheck(t *testing.T, s settings, host string) *models.Check {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return &models.Check{
|
||||
Kind: "tcp",
|
||||
Monitor: &models.Monitor{Host: host},
|
||||
Settings: datatypes.JSON(raw),
|
||||
}
|
||||
}
|
||||
|
||||
// startListener brings up a TCP listener on 127.0.0.1:0 so tests can
|
||||
// reach a real port. Returns the listener and the resolved address.
|
||||
func startListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
return ln, ln.Addr().String()
|
||||
}
|
||||
|
||||
func TestPerformOK(t *testing.T) {
|
||||
ln, addr := startListener(t)
|
||||
defer ln.Close()
|
||||
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split addr: %v", err)
|
||||
}
|
||||
c := newCheck(t, settings{Port: port, Timeout: 1}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if r.RemoteAddr == "" {
|
||||
t.Fatalf("expected RemoteAddr to be populated, got %q", r.RemoteAddr)
|
||||
}
|
||||
if !contains(r.Infos[0], "tcp") {
|
||||
t.Fatalf("expected info line about tcp probe, got %v", r.Infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformRefused(t *testing.T) {
|
||||
// Bind to 127.0.0.1:0 to find a free port, close immediately so
|
||||
// the next dial gets RST/CONNREFUSED.
|
||||
ln, addr := startListener(t)
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
_ = ln.Close()
|
||||
|
||||
// On some runners the OS reassigns the just-closed port to a
|
||||
// listener before our dial. Retry up to a few times to stabilise.
|
||||
var final net.Listener
|
||||
for i := 0; i < 3; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr2 := l.Addr().String()
|
||||
h, p, _ := net.SplitHostPort(addr2)
|
||||
_ = l.Close()
|
||||
// Replace what we are about to dial with one we just freed.
|
||||
addr = addr2
|
||||
host = h
|
||||
port = p
|
||||
break
|
||||
}
|
||||
_ = final
|
||||
|
||||
c := newCheck(t, settings{Port: port, Timeout: 1}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if r.Error == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUnreachable(t *testing.T) {
|
||||
// 127.0.0.0/8 — using 127.0.0.99 should resolve but be a hard
|
||||
// "no route" on most platforms. The check should fail fast.
|
||||
c := newCheck(t, settings{Port: "65530", Timeout: 1}, "127.0.0.99")
|
||||
// Use a short timeout so the test does not hang on slow CI.
|
||||
r := Perform(c)
|
||||
if r.State == stateOK {
|
||||
t.Fatalf("expected ERR/WARN, got OK (this loopback should not answer)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformDefaultPort(t *testing.T) {
|
||||
ln, addr := startListener(t)
|
||||
defer ln.Close()
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
c := newCheck(t, settings{}, host) // port empty -> defaults to 80
|
||||
r := Perform(c)
|
||||
// 80 is unlikely to answer on the loopback; we only care that
|
||||
// the check did not panic and reported something on failure.
|
||||
if r.State == "" {
|
||||
t.Fatalf("expected non-empty state, got %q", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformEmptyHost(t *testing.T) {
|
||||
c := newCheck(t, settings{Port: "80", Timeout: 1}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR on empty host, got %s", r.State)
|
||||
}
|
||||
if r.Error == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformClampsTimeout(t *testing.T) {
|
||||
// Already covered indirectly by TestPerformDefaultPort but
|
||||
// assert the dialer budget is at least 1s even with Timeout=0.
|
||||
ln, addr := startListener(t)
|
||||
defer ln.Close()
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
|
||||
start := time.Now()
|
||||
c := newCheck(t, settings{Port: port, Timeout: 0}, host)
|
||||
r := Perform(c)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("default timeout exceeded 2s, got %v", elapsed)
|
||||
}
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK against local listener, got %s", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Ссылка в новой задаче
Block a user