feat: Phase 5a — probe adapters under adapters/probes/ + Checker registry
Problem: probe logic (checkHTTP, checkTCP, checkPing, checkDNS, checkSSHScript, etc.) was embedded inside scheduler/scheduler.go as unexported functions coupled to sqlcgen types — unreachable from the core ObservationService the hexagonal refactor needs. Change: - adapters/probes/network.go: HTTP, TCP, ping, and DNS probe adapters implementing ports.Checker. Each parses CheckDef.Config (json map), runs the probe against the Target, and returns a ports.CheckResult. configMap helper unmarshals config JSON; parseStr/parseFloat extract typed values. - adapters/probes/ssh.go: SSHChecker wraps actuator.Dial + RunCombinedOutput with a SignerSource for key resolution. Registry (map[string]ports.Checker) with NewRegistry() pre-populating all known kinds (ssh-script, vm-status, backup-freshness, cert-expiry set to nil — filled by the ObservationService when signers are available). Verification: go build/vet, full test suite (19 pkgs), DB integration (postgres + mcp — green).
This commit is contained in:
171
internal/adapters/probes/network.go
Normal file
171
internal/adapters/probes/network.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Package probes implements the Checker port for each check kind.
|
||||||
|
// Each file exports a Checker constructor (e.g. NewHTTPChecker) that
|
||||||
|
// returns ports.Checker wrapping the scheduler's probe logic.
|
||||||
|
package probes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpChecker is the HTTP reachability probe.
|
||||||
|
type httpChecker struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPChecker builds an HTTP probe with a connection-scoped client.
|
||||||
|
func NewHTTPChecker() ports.Checker {
|
||||||
|
return &httpChecker{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult {
|
||||||
|
cfg := configMap(def)
|
||||||
|
|
||||||
|
url, _ := parseStr(cfg, "url")
|
||||||
|
if url == "" {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: "no url in config"}
|
||||||
|
}
|
||||||
|
maxStatus := int(parseFloat(cfg, "max_status", 500))
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
|
||||||
|
code := resp.StatusCode
|
||||||
|
if code > maxStatus {
|
||||||
|
return ports.CheckResult{
|
||||||
|
State: "critical", Value: float64(code),
|
||||||
|
Message: fmt.Sprintf("HTTP %d exceeds max_status %d", code, maxStatus),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports.CheckResult{State: "ok", Value: float64(code)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tcpChecker checks TCP port reachability.
|
||||||
|
type tcpChecker struct{}
|
||||||
|
|
||||||
|
func NewTCPChecker() ports.Checker { return &tcpChecker{} }
|
||||||
|
|
||||||
|
func (c *tcpChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult {
|
||||||
|
cfg := configMap(def)
|
||||||
|
|
||||||
|
host, _ := parseStr(cfg, "host")
|
||||||
|
port := int(parseFloat(cfg, "port", 0))
|
||||||
|
timeout := time.Duration(parseFloat(cfg, "timeout", 5)) * time.Second
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
d := &net.Dialer{Timeout: timeout}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "critical", Message: err.Error()}
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return ports.CheckResult{State: "ok", Value: float64(port)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pingChecker is the ICMP/connectivity probe. Falls back to TCP ping on
|
||||||
|
// systems without raw socket access.
|
||||||
|
type pingChecker struct{}
|
||||||
|
|
||||||
|
func NewPingChecker() ports.Checker { return &pingChecker{} }
|
||||||
|
|
||||||
|
func (c *pingChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult {
|
||||||
|
cfg := configMap(def)
|
||||||
|
|
||||||
|
host, _ := parseStr(cfg, "host")
|
||||||
|
if host == "" {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: "no host in config"}
|
||||||
|
}
|
||||||
|
port := int(parseFloat(cfg, "port", 80))
|
||||||
|
timeout := time.Duration(parseFloat(cfg, "timeout", 5)) * time.Second
|
||||||
|
|
||||||
|
d := &net.Dialer{Timeout: timeout}
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "critical", Message: err.Error(), Value: -1}
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return ports.CheckResult{State: "ok", Value: 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dnsChecker resolves DNS names.
|
||||||
|
type dnsChecker struct{}
|
||||||
|
|
||||||
|
func NewDNSChecker() ports.Checker { return &dnsChecker{} }
|
||||||
|
|
||||||
|
func (c *dnsChecker) Check(ctx context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult {
|
||||||
|
cfg := configMap(def)
|
||||||
|
|
||||||
|
name, _ := parseStr(cfg, "name")
|
||||||
|
if name == "" {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: "no name in config"}
|
||||||
|
}
|
||||||
|
var r net.Resolver
|
||||||
|
addrs, err := r.LookupHost(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "critical", Message: err.Error()}
|
||||||
|
}
|
||||||
|
return ports.CheckResult{State: "ok", Value: float64(len(addrs))}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// configMap unmarshals a check's JSON config into a map.
|
||||||
|
func configMap(def ports.CheckDef) map[string]any {
|
||||||
|
var m map[string]any
|
||||||
|
if len(def.Config) > 0 {
|
||||||
|
json.Unmarshal(def.Config, &m)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]any{}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
func parseStr(m map[string]any, key string) (string, bool) {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(m map[string]any, key string, def float64) float64 {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return n
|
||||||
|
case int:
|
||||||
|
return float64(n)
|
||||||
|
case json.Number:
|
||||||
|
f, _ := n.Float64()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
105
internal/adapters/probes/ssh.go
Normal file
105
internal/adapters/probes/ssh.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package probes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/actuator"
|
||||||
|
"github.com/dtoro/oikos/internal/core/ports"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHChecker runs ssh-script probes via the actuator. Constructed with a
|
||||||
|
// key source so the caller controls SSH key resolution.
|
||||||
|
type SSHChecker struct {
|
||||||
|
pool *actuator.DialPool
|
||||||
|
signerFn func() (ssh.Signer, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSHChecker(pool *actuator.DialPool, signerFn func() (ssh.Signer, error)) *SSHChecker {
|
||||||
|
return &SSHChecker{pool: pool, signerFn: signerFn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHChecker) Check(ctx context.Context, def ports.CheckDef, target ports.Target) ports.CheckResult {
|
||||||
|
cfg := configMap(def)
|
||||||
|
script, _ := parseStr(cfg, "script")
|
||||||
|
if script == "" {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: "no script in config"}
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := c.signerFn()
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: fmt.Sprintf("signer: %v", err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := actuator.Dial(ctx, actuator.DialOptions{
|
||||||
|
Host: target.Host,
|
||||||
|
User: target.User,
|
||||||
|
Signer: signer,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{State: "unknown", Message: fmt.Sprintf("dial %s: %v", target.Host, err)}
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
cmd := fmt.Sprintf("/opt/oikos/checks/%s", script)
|
||||||
|
if target.Wrap != nil {
|
||||||
|
cmd = target.Wrap(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := actuator.RunCombinedOutput(ctx, client, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return ports.CheckResult{
|
||||||
|
Value: -1,
|
||||||
|
State: "critical",
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseSSHResult(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSSHResult(output []byte) ports.CheckResult {
|
||||||
|
line := strings.TrimSpace(string(output))
|
||||||
|
var value float64
|
||||||
|
if len(line) > 0 {
|
||||||
|
parts := strings.SplitN(line, " ", 3)
|
||||||
|
switch parts[0] {
|
||||||
|
case "OK":
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fmt.Sscanf(parts[1], "%f", &value)
|
||||||
|
}
|
||||||
|
return ports.CheckResult{State: "ok", Value: value, Message: line}
|
||||||
|
case "WARN":
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fmt.Sscanf(parts[1], "%f", &value)
|
||||||
|
}
|
||||||
|
return ports.CheckResult{State: "warning", Value: value, Message: line}
|
||||||
|
case "CRIT":
|
||||||
|
if len(parts) > 1 {
|
||||||
|
fmt.Sscanf(parts[1], "%f", &value)
|
||||||
|
}
|
||||||
|
return ports.CheckResult{State: "critical", Value: value, Message: line}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports.CheckResult{State: "unknown", Message: "unparseable output"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry maps check kinds to Checker implementations.
|
||||||
|
type Registry map[string]ports.Checker
|
||||||
|
|
||||||
|
func NewRegistry() Registry {
|
||||||
|
return Registry{
|
||||||
|
"http": NewHTTPChecker(),
|
||||||
|
"tcp": NewTCPChecker(),
|
||||||
|
"ping": NewPingChecker(),
|
||||||
|
"dns": NewDNSChecker(),
|
||||||
|
"vm-status": nil,
|
||||||
|
"ssh-script": nil,
|
||||||
|
"backup-freshness": nil,
|
||||||
|
"cert-expiry": nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Registry) Get(kind string) ports.Checker { return r[kind] }
|
||||||
|
func (r Registry) Register(kind string, c ports.Checker) { r[kind] = c }
|
||||||
Reference in New Issue
Block a user