- InsertMetricSample now includes ts=now() (TimescaleDB hypertable requires it) - ssh-script: pass host and port separately (ssh uses -p flag, not host:port) - ssh-script: use OIKOS_SSH_USER from config/env, default root - Add -o LogLevel=ERROR to suppress SSH warnings polluting JSON output - Use Output() (stdout only) instead of CombinedOutput() - Set OIKOS_SSH_USER=root in scheduler docker-compose service
684 lines
18 KiB
Go
684 lines
18 KiB
Go
// Package scheduler implements the Oikos observe + decide loop (Phase 3).
|
|
// It loads enabled check_defs, runs checks on schedule, manages signal
|
|
// lifecycle (dedup, flap suppression, maintenance mode), and writes metrics.
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os/exec"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/dtoro/oikos/internal/config"
|
|
"github.com/dtoro/oikos/internal/db"
|
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
|
"github.com/dtoro/oikos/internal/observability"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
var (
|
|
sshKeyPath string
|
|
sshUser string
|
|
)
|
|
|
|
// Run starts the scheduler loop. Blocks until ctx is cancelled.
|
|
func Run(ctx context.Context, pool *db.Pool, cfg config.Config) {
|
|
slog.Info("scheduler: starting", "interval", cfg.SchedulerInterval)
|
|
interval := cfg.SchedulerInterval
|
|
if interval <= 0 {
|
|
interval = 30 * time.Second
|
|
}
|
|
|
|
sshKeyPath = cfg.SSHKeyPath
|
|
sshUser = cfg.SSHUser
|
|
if sshUser == "" {
|
|
sshUser = "root"
|
|
}
|
|
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
// Immediate first pass
|
|
runCheckPass(ctx, pool)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
slog.Info("scheduler: shutting down")
|
|
return
|
|
case <-ticker.C:
|
|
runCheckPass(ctx, pool)
|
|
}
|
|
}
|
|
}
|
|
|
|
// runCheckPass executes one full cycle of check evaluation.
|
|
func runCheckPass(ctx context.Context, pool *db.Pool) {
|
|
q := sqlcgen.New(pool)
|
|
|
|
defs, err := q.ListEnabledCheckDefs(ctx)
|
|
if err != nil {
|
|
slog.Error("scheduler: list check defs", "error", err)
|
|
return
|
|
}
|
|
if len(defs) == 0 {
|
|
slog.Debug("scheduler: no enabled check_defs")
|
|
return
|
|
}
|
|
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
g.SetLimit(10) // bounded worker pool
|
|
|
|
for _, def := range defs {
|
|
cd := def
|
|
g.Go(func() error {
|
|
runCheck(gctx, pool, cd)
|
|
return nil
|
|
})
|
|
}
|
|
g.Wait()
|
|
|
|
// Housekeeping after each pass
|
|
housekeeping(ctx, pool)
|
|
}
|
|
|
|
// runCheck executes a single check and processes the result.
|
|
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
|
|
q := sqlcgen.New(pool)
|
|
start := time.Now()
|
|
|
|
result := executeCheck(ctx, cd)
|
|
|
|
latency := time.Since(start).Milliseconds()
|
|
|
|
if result.metrics == nil {
|
|
result.metrics = make(map[string]float64)
|
|
}
|
|
result.metrics["probe_latency_ms"] = float64(latency)
|
|
|
|
for metric, value := range result.metrics {
|
|
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
|
|
EntityID: cd.EntityID,
|
|
Metric: metric,
|
|
Value: value,
|
|
Tags: []byte(`{}`),
|
|
})
|
|
}
|
|
|
|
if result.err != nil {
|
|
slog.Warn("scheduler: check failed",
|
|
"entity", cd.EntitySlug, "kind", cd.Kind, "error", result.err)
|
|
}
|
|
|
|
prevHealth := currentHealth(ctx, pool, cd.EntityID)
|
|
|
|
if result.signalKind == "" || result.health == "healthy" {
|
|
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
|
EntityID: cd.EntityID,
|
|
Health: "healthy",
|
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
|
Details: []byte(`{}`),
|
|
})
|
|
if prevHealth != "" && prevHealth != "healthy" {
|
|
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info",
|
|
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
|
|
}
|
|
return
|
|
}
|
|
|
|
slog.Warn("scheduler: raising signal",
|
|
"entity", cd.EntitySlug, "kind", result.signalKind, "evidence", result.evidence)
|
|
|
|
severity := evaluateSeverity(cd.Kind, result.signalKind, cd.Config, result.metrics)
|
|
|
|
sig, err := q.UpsertSignal(ctx, sqlcgen.UpsertSignalParams{
|
|
EntityID: cd.EntityID,
|
|
Kind: result.signalKind,
|
|
Severity: severity,
|
|
TargetEntityID: cd.TargetID,
|
|
Evidence: &result.evidence,
|
|
})
|
|
if err != nil {
|
|
slog.Error("scheduler: upsert signal", "error", err)
|
|
return
|
|
}
|
|
|
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
|
EntityID: cd.EntityID,
|
|
Health: result.health,
|
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
|
Details: []byte(`{}`),
|
|
})
|
|
_ = sig
|
|
|
|
if prevHealth == "" || prevHealth == "healthy" {
|
|
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
|
|
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
|
|
}
|
|
if prevHealth != result.health {
|
|
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
|
|
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": result.health})
|
|
}
|
|
}
|
|
|
|
// currentHealth reads the last recorded health for an entity, or "" if none.
|
|
func currentHealth(ctx context.Context, pool *db.Pool, entityID uuid.UUID) string {
|
|
var health string
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT health FROM entity_status WHERE entity_id = $1`, entityID).Scan(&health); err != nil {
|
|
return ""
|
|
}
|
|
return health
|
|
}
|
|
|
|
// emitSchedulerEvent records a scheduler-sourced event for SSE fan-out.
|
|
func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, entityID uuid.UUID, severity string, data map[string]any) {
|
|
_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
|
|
}
|
|
|
|
// resolveSignal resolves any open signal for the given check entity.
|
|
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
|
q := sqlcgen.New(pool)
|
|
// Check if there's an open signal on this entity
|
|
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
|
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if tag.RowsAffected() > 0 {
|
|
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
|
|
map[string]any{"slug": slug})
|
|
}
|
|
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
|
EntityID: entityID,
|
|
Health: "healthy",
|
|
LastCheckAt: &[]time.Time{time.Now()}[0],
|
|
Details: []byte(`{}`),
|
|
})
|
|
slog.Info("scheduler: signal resolved", "entity", slug)
|
|
}
|
|
|
|
// checkResult bundles the outcome of a single check execution.
|
|
type checkResult struct {
|
|
health string
|
|
signalKind string
|
|
evidence string
|
|
metrics map[string]float64
|
|
err error
|
|
}
|
|
|
|
// executeCheck dispatches to the appropriate checker by kind.
|
|
func executeCheck(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
switch cd.Kind {
|
|
case "http":
|
|
return checkHTTP(ctx, cd)
|
|
case "tcp":
|
|
return checkTCP(ctx, cd)
|
|
case "disk":
|
|
return checkDisk(ctx, cd)
|
|
case "cert-expiry":
|
|
return checkCertExpiry(ctx, cd)
|
|
case "ping":
|
|
return checkPing(ctx, cd)
|
|
case "ssh-script":
|
|
return checkSSHScript(ctx, cd)
|
|
default:
|
|
return checkResult{health: "unknown"}
|
|
}
|
|
}
|
|
|
|
// housekeeping runs background maintenance tasks.
|
|
func housekeeping(ctx context.Context, pool *db.Pool) {
|
|
|
|
// Prune expired idempotency keys (older than 24h)
|
|
cutoff := time.Now().Add(-24 * time.Hour)
|
|
_, err := pool.Exec(ctx,
|
|
"DELETE FROM idempotency_keys WHERE created_at < $1", cutoff)
|
|
if err != nil {
|
|
slog.Error("scheduler: prune idempotency keys", "error", err)
|
|
}
|
|
|
|
// Log housekeeping completion
|
|
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
|
|
}
|
|
|
|
// checkHTTP performs an HTTP health check.
|
|
func checkHTTP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
cfg := struct {
|
|
URL string `json:"url"`
|
|
ExpectedStatus int `json:"expected_status"`
|
|
Insecure bool `json:"insecure"`
|
|
}{
|
|
ExpectedStatus: 200,
|
|
}
|
|
if len(cd.Config) > 0 {
|
|
_ = json.Unmarshal(cd.Config, &cfg)
|
|
}
|
|
if cfg.URL == "" {
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
|
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: timeout,
|
|
Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure},
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.URL, nil)
|
|
if err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "http",
|
|
evidence: fmt.Sprintf("invalid URL %q: %v", cfg.URL, err),
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "http",
|
|
evidence: fmt.Sprintf("GET %s: %v", cfg.URL, err),
|
|
err: err,
|
|
}
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != cfg.ExpectedStatus {
|
|
return checkResult{
|
|
health: "degraded", signalKind: "http",
|
|
evidence: fmt.Sprintf("GET %s returned %d (expected %d)", cfg.URL, resp.StatusCode, cfg.ExpectedStatus),
|
|
}
|
|
}
|
|
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
|
|
// checkTCP performs a TCP dial check.
|
|
func checkTCP(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
cfg := struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
}{}
|
|
if len(cd.Config) > 0 {
|
|
_ = json.Unmarshal(cd.Config, &cfg)
|
|
}
|
|
if cfg.Host == "" || cfg.Port == 0 {
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
|
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
|
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
if err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "tcp",
|
|
evidence: fmt.Sprintf("dial %s: %v", addr, err),
|
|
err: err,
|
|
}
|
|
}
|
|
conn.Close()
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
|
|
// checkDisk performs a disk usage check.
|
|
func checkDisk(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
cfg := struct {
|
|
Path string `json:"path"`
|
|
ThresholdPct int `json:"threshold_pct"`
|
|
}{
|
|
Path: "/",
|
|
ThresholdPct: 85,
|
|
}
|
|
if len(cd.Config) > 0 {
|
|
_ = json.Unmarshal(cd.Config, &cfg)
|
|
}
|
|
|
|
var stat unix.Statfs_t
|
|
if err := unix.Statfs(cfg.Path, &stat); err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "disk",
|
|
evidence: fmt.Sprintf("statfs %s: %v", cfg.Path, err),
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
total := stat.Blocks * uint64(stat.Bsize)
|
|
free := stat.Bfree * uint64(stat.Bsize)
|
|
if total == 0 {
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
|
|
usedPct := float64(total-free) / float64(total) * 100
|
|
inodePct := 0.0
|
|
if stat.Files > 0 {
|
|
inodePct = float64(stat.Files-stat.Ffree) / float64(stat.Files) * 100
|
|
}
|
|
|
|
metrics := map[string]float64{
|
|
"disk_used_pct": usedPct,
|
|
"disk_inode_pct": inodePct,
|
|
}
|
|
|
|
if usedPct > float64(cfg.ThresholdPct) {
|
|
return checkResult{
|
|
health: "degraded", signalKind: "disk",
|
|
evidence: fmt.Sprintf("%s %.1f%% full (threshold %d%%)", cfg.Path, usedPct, cfg.ThresholdPct),
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
|
|
return checkResult{health: "healthy", metrics: metrics}
|
|
}
|
|
|
|
// checkCertExpiry checks TLS certificate expiry.
|
|
func checkCertExpiry(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
cfg := struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
WarnDays int `json:"warn_days"`
|
|
CritDays int `json:"crit_days"`
|
|
}{
|
|
Port: 443,
|
|
WarnDays: 30,
|
|
CritDays: 7,
|
|
}
|
|
if len(cd.Config) > 0 {
|
|
_ = json.Unmarshal(cd.Config, &cfg)
|
|
}
|
|
if cfg.Host == "" {
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
|
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
|
|
|
|
d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true}}
|
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "cert-expiry",
|
|
evidence: fmt.Sprintf("TLS dial %s: %v", addr, err),
|
|
err: err,
|
|
}
|
|
}
|
|
defer conn.Close()
|
|
|
|
tlsConn := conn.(*tls.Conn)
|
|
cs := tlsConn.ConnectionState()
|
|
if len(cs.PeerCertificates) == 0 {
|
|
return checkResult{
|
|
health: "down", signalKind: "cert-expiry",
|
|
evidence: "no peer certificates",
|
|
}
|
|
}
|
|
|
|
cert := cs.PeerCertificates[0]
|
|
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
|
|
|
|
metrics := map[string]float64{
|
|
"cert_days_left": float64(daysLeft),
|
|
}
|
|
|
|
if daysLeft <= cfg.CritDays {
|
|
return checkResult{
|
|
health: "down", signalKind: "cert-expiry",
|
|
evidence: fmt.Sprintf("%s expires in %d days (crit=%d)", cfg.Host, daysLeft, cfg.CritDays),
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
if daysLeft <= cfg.WarnDays {
|
|
return checkResult{
|
|
health: "degraded", signalKind: "cert-expiry",
|
|
evidence: fmt.Sprintf("%s expires in %d days (warn=%d)", cfg.Host, daysLeft, cfg.WarnDays),
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
|
|
return checkResult{health: "healthy", metrics: metrics}
|
|
}
|
|
|
|
// checkPing performs an ICMP ping check using the system ping command.
|
|
func checkPing(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
cfg := struct {
|
|
Host string `json:"host"`
|
|
Count int `json:"count"`
|
|
}{}
|
|
if len(cd.Config) > 0 {
|
|
_ = json.Unmarshal(cd.Config, &cfg)
|
|
}
|
|
if cfg.Host == "" {
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
if cfg.Count <= 0 {
|
|
cfg.Count = 1
|
|
}
|
|
|
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
|
|
deadline := time.Duration(cfg.Count+1) * timeout
|
|
ctx, cancel := context.WithTimeout(ctx, deadline)
|
|
defer cancel()
|
|
|
|
countStr := strconv.Itoa(cfg.Count)
|
|
timeoutSec := strconv.Itoa(int(timeout.Seconds()))
|
|
if timeoutSec == "0" {
|
|
timeoutSec = "1"
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, "ping", "-c", countStr, "-W", timeoutSec, cfg.Host)
|
|
if runtime.GOOS == "darwin" {
|
|
cmd = exec.CommandContext(ctx, "ping", "-c", countStr, "-t", timeoutSec, cfg.Host)
|
|
}
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "ping",
|
|
evidence: fmt.Sprintf("ping %s: %v", cfg.Host, err),
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
latency := parsePingLatency(output)
|
|
metrics := map[string]float64{}
|
|
if latency > 0 {
|
|
metrics["ping_latency_ms"] = latency
|
|
}
|
|
|
|
return checkResult{health: "healthy", metrics: metrics}
|
|
}
|
|
|
|
var pingRttRe = regexp.MustCompile(`(?:rtt\s+min\/avg\/max\/mdev|round-trip\s+min\/avg\/max\/stddev)\s*=\s*[\d.]+\/([\d.]+)\/`)
|
|
|
|
func parsePingLatency(output []byte) float64 {
|
|
matches := pingRttRe.FindSubmatch(output)
|
|
if len(matches) < 2 {
|
|
return 0
|
|
}
|
|
val, err := strconv.ParseFloat(string(matches[1]), 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return val
|
|
}
|
|
|
|
// checkSSHScript executes an allowlisted script on a remote host via SSH.
|
|
func checkSSHScript(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
|
|
cfg := struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user"`
|
|
Script string `json:"script"`
|
|
}{}
|
|
if len(cd.Config) > 0 {
|
|
_ = json.Unmarshal(cd.Config, &cfg)
|
|
}
|
|
if cfg.Host == "" || cfg.Script == "" {
|
|
return checkResult{health: "healthy"}
|
|
}
|
|
if cfg.Port == 0 {
|
|
cfg.Port = 22
|
|
}
|
|
if cfg.User == "" {
|
|
cfg.User = sshUser
|
|
}
|
|
|
|
if !allowlistedScript(cfg.Script) {
|
|
return checkResult{
|
|
health: "unknown", signalKind: "ssh-script",
|
|
evidence: fmt.Sprintf("script %q not allowlisted", cfg.Script),
|
|
}
|
|
}
|
|
|
|
timeout := time.Duration(cd.TimeoutS) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
scriptPath := "/opt/oikos/checks/" + cfg.Script
|
|
port := strconv.Itoa(cfg.Port)
|
|
|
|
output, err := sshExec(ctx, cfg.Host, port, cfg.User, scriptPath, timeout)
|
|
if err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "ssh-script",
|
|
evidence: fmt.Sprintf("ssh %s:%s %s: %v", cfg.Host, strconv.Itoa(cfg.Port), cfg.Script, err),
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
type scriptOutput struct {
|
|
Health string `json:"health"`
|
|
SignalKind string `json:"signalKind"`
|
|
Evidence string `json:"evidence"`
|
|
Metrics map[string]float64 `json:"metrics"`
|
|
}
|
|
var so scriptOutput
|
|
if err := json.Unmarshal(output, &so); err != nil {
|
|
return checkResult{
|
|
health: "down", signalKind: "ssh-script",
|
|
evidence: fmt.Sprintf("invalid script output from %s: %v", cfg.Script, err),
|
|
err: err,
|
|
}
|
|
}
|
|
|
|
health := so.Health
|
|
if health == "" {
|
|
health = "healthy"
|
|
}
|
|
|
|
metrics := so.Metrics
|
|
if metrics == nil {
|
|
metrics = make(map[string]float64)
|
|
}
|
|
|
|
return checkResult{
|
|
health: health,
|
|
signalKind: so.SignalKind,
|
|
evidence: so.Evidence,
|
|
metrics: metrics,
|
|
}
|
|
}
|
|
|
|
var scriptNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]+\.sh$`)
|
|
|
|
func allowlistedScript(name string) bool {
|
|
return scriptNameRe.MatchString(name)
|
|
}
|
|
|
|
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
|
|
args := []string{
|
|
"-o", "ConnectTimeout=" + strconv.Itoa(int(timeout.Seconds())),
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "BatchMode=yes",
|
|
"-o", "UserKnownHostsFile=/dev/null",
|
|
"-o", "LogLevel=ERROR",
|
|
}
|
|
if sshKeyPath != "" {
|
|
args = append(args, "-i", sshKeyPath)
|
|
}
|
|
if port != "" && port != "22" {
|
|
args = append(args, "-p", port)
|
|
}
|
|
args = append(args, "-l", user, host, cmd)
|
|
c := exec.CommandContext(ctx, "ssh", args...)
|
|
out, err := c.Output()
|
|
if err != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) {
|
|
return nil, fmt.Errorf("ssh %s: %v (stderr: %s)", host, err, string(ee.Stderr))
|
|
}
|
|
return nil, fmt.Errorf("ssh %s: %v", host, err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
|
|
// metricThreshold defines warn/crit thresholds for a single metric.
|
|
type metricThreshold struct {
|
|
Warn float64 `json:"warn"`
|
|
Crit float64 `json:"crit"`
|
|
}
|
|
|
|
// thresholdsConfig is parsed from check_defs.config.thresholds JSONB.
|
|
type thresholdsConfig map[string]metricThreshold
|
|
|
|
// evaluateSeverity determines signal severity from check result and thresholds.
|
|
func evaluateSeverity(kind string, signalKind string, config []byte, metrics map[string]float64) string {
|
|
var thresholds thresholdsConfig
|
|
if len(config) > 0 {
|
|
_ = json.Unmarshal(config, &thresholds)
|
|
}
|
|
|
|
for metric, value := range metrics {
|
|
t, ok := thresholds[metric]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if t.Crit > 0 && value >= t.Crit {
|
|
return "critical"
|
|
}
|
|
if t.Warn > 0 && value >= t.Warn {
|
|
return "warning"
|
|
}
|
|
}
|
|
|
|
if signalKind == "down" {
|
|
return "critical"
|
|
}
|
|
return "warning"
|
|
}
|
|
|
|
var _ = uuid.UUID{} // ensure uuid import stays |