Files
oikos/internal/scheduler/scheduler.go
dtoro 5e10437fe3
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
Phase 4 (Performance) + Phase 6 (Infrastructure) completion
Phase 4 — Performance:
- F1: SSH DialPool with key-by-host pooling and 5min idle TTL
- F2: In-memory entity lookup cache (TTL 60s, HTTP resolveEntityID)
- F3: Trigram GIN indexes on entities.slug and entities.name (migration 031)
- F4: Partial index on executions(classification_id) for auto-act (migration 032)
- Added missing RunOutput and RunStreaming in actuator/ (E3 gap fill)

Phase 6 — Infrastructure:
- H1: Infisical image pinned to v0.99.1
- H2: execworker daemon — polls pending executions with per-execution
  advisory locks, recovers orphaned running executions, wired as
  docker-compose service
- H3: splitSQL hardened with block comment and string-literal support,
  6 new edge-case tests (11 total)
- H4: Scheduler acquires pg_try_advisory_lock(0x01c05e6) at startup
2026-08-08 23:46:43 +02:00

1096 lines
34 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"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/dtoro/oikos/internal/actuator"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/db/sqlcgen"
"github.com/dtoro/oikos/internal/health"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/remote"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/sys/unix"
)
var (
sshKeyPath string
sshUser string
sshPool *actuator.DialPool
)
// schedulerLockKey is the advisory-lock key preventing duplicate scheduler
// instances. Must differ from db.migrationLockKey (0x01c05e5).
const schedulerLockKey = 0x01c05e6
// 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"
}
sshPool = actuator.NewDialPool(5 * time.Minute)
defer sshPool.Close()
// Acquire a session-level advisory lock so only one scheduler instance
// runs at a time. If another instance holds the lock, we exit — duplicate
// schedulers would duplicate health checks, signals, metrics, and events.
lockConn, err := pool.Acquire(ctx)
if err != nil {
slog.Error("scheduler: acquire connection for lock", "error", err)
return
}
var locked bool
if err := lockConn.QueryRow(ctx, "SELECT pg_try_advisory_lock($1)", schedulerLockKey).Scan(&locked); err != nil {
lockConn.Release()
slog.Error("scheduler: advisory lock error", "error", err)
return
}
if !locked {
lockConn.Release()
slog.Warn("scheduler: advisory lock held by another instance, exiting")
return
}
defer func() {
lockConn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", schedulerLockKey)
lockConn.Release()
}()
// Liveness probe (plan D5): staleness is 3x the interval so a single
// slow check pass (one host hung on SSH) doesn't flap the container
// unhealthy before the next scheduled tick.
stale := 3 * interval
if stale < 90*time.Second {
stale = 90 * time.Second
}
probe := health.New(stale)
probe.Serve(ctx, cfg.HealthListen)
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Immediate first pass
runCheckPass(ctx, pool)
probe.Bump()
for {
select {
case <-ctx.Done():
slog.Info("scheduler: shutting down")
return
case <-ticker.C:
runCheckPass(ctx, pool)
probe.Bump()
}
}
}
// 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 {
// Still run housekeeping: a fleet with no enabled check_defs is
// precisely the case coverageSweep exists to report, and returning
// here would mean the one situation that most needs reporting is the
// one situation that stays silent.
slog.Debug("scheduler: no enabled check_defs")
housekeeping(ctx, pool)
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.
//
// check_defs.entity_id identifies the *check* (probe) entity itself;
// check_defs.target_id identifies the entity actually being observed (the
// host/service/etc). Health, metrics, and events must attach to the target
// so the observed entity's own record reflects reality — not the internal
// probe. Signals stay keyed by the check entity (cd.EntityID), matching how
// they are created below and resolved elsewhere.
func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) {
q := sqlcgen.New(pool)
start := time.Now()
result := executeCheck(ctx, pool, cd)
// Stamp the run before processing the result: due-ness must advance even
// when a check fails, or a permanently failing check would be re-run on
// every pass instead of at its declared interval. last_health records THIS
// check's own verdict, which is what makes the aggregation below possible.
checkHealth := result.health
if checkHealth == "" {
checkHealth = "healthy"
}
if err := q.MarkCheckRun(ctx, sqlcgen.MarkCheckRunParams{
EntityID: cd.EntityID,
LastHealth: &checkHealth,
}); err != nil {
slog.Error("scheduler: mark check run", "entity", cd.EntitySlug, "error", err)
}
latency := time.Since(start).Milliseconds()
if result.metrics == nil {
result.metrics = make(map[string]float64)
}
result.metrics["probe_latency_ms"] = float64(latency)
targetID := cd.EntityID
if cd.TargetID != nil {
targetID = *cd.TargetID
}
for metric, value := range result.metrics {
_ = q.InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
EntityID: targetID,
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, targetID)
if result.signalKind == "" || result.health == "healthy" {
resolveSignal(ctx, pool, cd.EntityID, targetID, cd.EntitySlug)
// NOT unconditionally "healthy": this check passing says nothing about
// the entity's other checks. Writing healthy here is what let one
// passing probe erase a genuine failure reported by another — and,
// alternating with a failing probe, produced 226 health flips an hour
// on a host that was fine throughout.
applyAggregateHealth(ctx, pool, q, targetID, cd.EntitySlug, prevHealth)
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
}
_ = sig
if prevHealth == "" || prevHealth == "healthy" {
emitSchedulerEvent(ctx, pool, "signal.raised", targetID, severity,
map[string]any{"slug": cd.EntitySlug, "kind": result.signalKind, "evidence": result.evidence})
}
applyAggregateHealth(ctx, pool, q, targetID, cd.EntitySlug, prevHealth)
}
// applyAggregateHealth sets the target's health to the worst verdict across
// all of its enabled checks, and emits health.changed only when that aggregate
// actually moves.
//
// Health is a property of the entity, but each check only ever observes one
// facet of it — reachability, disk, a systemd unit. Letting whichever check
// finished last overwrite the entity's health meant a host with six checks
// reported whichever facet was sampled most recently, so one failing probe and
// five passing ones oscillated forever instead of settling on "degraded".
func applyAggregateHealth(ctx context.Context, pool *db.Pool, q *sqlcgen.Queries,
targetID uuid.UUID, checkSlug, prevHealth string) {
health, err := q.WorstHealthForTarget(ctx, &targetID)
if err != nil {
slog.Error("scheduler: aggregate health", "entity", checkSlug, "error", err)
return
}
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
EntityID: targetID,
Health: health,
LastCheckAt: &[]time.Time{time.Now()}[0],
Details: []byte(`{}`),
})
if prevHealth == health {
return
}
severity := "info"
switch health {
case "down":
severity = "critical"
case "degraded", "stale":
severity = "warning"
}
emitSchedulerEvent(ctx, pool, "health.changed", targetID, severity,
map[string]any{"slug": checkSlug, "from": prevHealth, "to": 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 raised by the given check entity.
// checkID matches how signals are keyed (UpsertSignal uses the check's own
// entity id); targetID is the observed entity whose status this affects.
func resolveSignal(ctx context.Context, pool *db.Pool, checkID, targetID uuid.UUID, slug string) {
// 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'`, checkID)
if err != nil {
return
}
if tag.RowsAffected() > 0 {
emitSchedulerEvent(ctx, pool, "signal.resolved", targetID, "info",
map[string]any{"slug": slug})
slog.Info("scheduler: signal resolved", "entity", slug)
}
// Deliberately does NOT write health. Resolving THIS check's signal says
// nothing about the target's other checks; the caller re-derives health
// from all of them. Forcing "healthy" here was a second path by which one
// passing probe erased another probe's genuine failure.
}
// 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. pool is needed
// by the ssh-script path, which resolves the target's execution endpoint
// (guests route through their Proxmox host; see internal/remote).
func executeCheck(ctx context.Context, pool *db.Pool, 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 "vm-status":
return checkVMStatus(ctx, pool, cd)
case "ping":
return checkPing(ctx, cd)
case "ssh-script":
return checkSSHScript(ctx, pool, cd)
case "backup-freshness":
return checkBackupFreshness(ctx, cd)
case "dns":
return checkDNS(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)
}
staleSweep(ctx, pool)
coverageSweep(ctx, pool)
// Log housekeeping completion
slog.Debug("scheduler: housekeeping done", "pruned_idempotency_before", cutoff.Format(time.RFC3339))
}
// staleMultiplier and staleFloor bound how long an entity can go unobserved
// before its last-known health is no longer trusted. An entity is stale once
// it has gone longer than staleMultiplier times its fastest enabled check's
// interval (or staleFloor, whichever is larger) without a fresh observation —
// covering both a stalled scheduler and a disabled/broken check_def.
const (
staleMultiplier = 3
staleFloor = 5 * time.Minute
)
// staleSweep marks entities whose last observation has aged past their
// check's expected cadence as 'stale', so the system never reports an old
// health value as if it were current. Runs once per housekeeping pass.
func staleSweep(ctx context.Context, pool *db.Pool) {
rows, err := pool.Query(ctx, `
SELECT e.id, e.slug, st.health
FROM entity_status st
JOIN entities e ON e.id = st.entity_id
JOIN (
SELECT target_id, MIN(interval_s) AS min_interval
FROM check_defs
WHERE enabled AND target_id IS NOT NULL
GROUP BY target_id
) iv ON iv.target_id = st.entity_id
WHERE st.health <> 'stale'
AND (st.last_check_at IS NULL
OR st.last_check_at < now() - make_interval(secs => GREATEST(iv.min_interval * $1, $2)))`,
staleMultiplier, int(staleFloor.Seconds()))
if err != nil {
slog.Error("scheduler: stale sweep query", "error", err)
return
}
defer rows.Close()
type staleEntity struct {
id uuid.UUID
slug string
health string
}
var stale []staleEntity
for rows.Next() {
var se staleEntity
if err := rows.Scan(&se.id, &se.slug, &se.health); err != nil {
continue
}
stale = append(stale, se)
}
rows.Close()
for _, se := range stale {
_, err := pool.Exec(ctx,
`UPDATE entity_status SET health = 'stale', updated_at = now() WHERE entity_id = $1`, se.id)
if err != nil {
slog.Error("scheduler: mark stale", "entity", se.slug, "error", err)
continue
}
slog.Warn("scheduler: entity stale", "entity", se.slug, "prev_health", se.health)
emitSchedulerEvent(ctx, pool, "health.stale", se.id, "warning",
map[string]any{"slug": se.slug, "from": se.health, "to": "stale"})
}
}
// 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"`
// MaxStatus accepts a range instead of one exact code. Most services
// sit behind Authentik and answer 302 or 401 — a working service, but
// an exact-match on 200 reports it degraded and raises a signal.
// Unset expected_status means "any response below MaxStatus is fine".
MaxStatus int `json:"max_status"`
Insecure bool `json:"insecure"`
}{
MaxStatus: 500,
}
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 cfg.ExpectedStatus != 0 {
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),
}
}
} else if resp.StatusCode >= cfg.MaxStatus {
return checkResult{
health: "degraded", signalKind: "http",
evidence: fmt.Sprintf("GET %s returned %d (expected below %d)", cfg.URL, resp.StatusCode, cfg.MaxStatus),
}
}
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"}
}
// checkDNS verifies a DNS name resolves, catching a stale or unreachable
// zone. It looks up NS records first (a zone always has NS), falling back to
// an A/AAAA lookup for hostnames. Uses the system resolver; for split-horizon
// correctness reserve an explicit `server` in the config.
func checkDNS(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Name string `json:"name"`
Server string `json:"server"`
}{}
if len(cd.Config) > 0 {
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Name == "" {
return checkResult{health: "healthy"}
}
// Resolve via an explicit server when supplied (split-horizon), else the
// system default resolver.
lookup := func(q string) (int, error) {
r := &net.Resolver{}
if cfg.Server != "" {
r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(cfg.Server, "53"))
}}
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
ns, err := r.LookupNS(ctx, q)
if err == nil && len(ns) > 0 {
return len(ns), nil
}
addrs, err2 := r.LookupHost(ctx, q)
return len(addrs), err2
}
n, err := lookup(cfg.Name)
if err != nil || n == 0 {
return checkResult{
health: "down", signalKind: "dns",
evidence: fmt.Sprintf("DNS resolution failed for %q: %v", cfg.Name, err),
err: err,
}
}
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"`
// Dial is an optional explicit dial address (the TLS terminator's IP)
// for when the hostname doesn't resolve/reach from the scheduler — the
// container has no mesh interface and the host resolver doesn't know
// the split-horizon zone, so *.hubris.network dials Caddy's lab IP
// directly while SNI/cert-read still uses Host.
Dial string `json:"dial"`
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
}
dialHost := cfg.Host
if cfg.Dial != "" {
dialHost = cfg.Dial
}
addr := net.JoinHostPort(dialHost, fmt.Sprintf("%d", cfg.Port))
d := tls.Dialer{Config: &tls.Config{InsecureSkipVerify: true, ServerName: cfg.Host}}
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}
}
// checkVMStatus reports whether a VM is powered on, via `qm status <pve_id>`
// run on its Proxmox host. This is the right reachability probe for a VM that
// blocks ICMP (haos) and has no guest agent: it doesn't need the VM's network
// at all — "status: running" means the VM is up. The command runs on the host
// (not inside the VM), so it uses the host's address with identity wrap.
func checkVMStatus(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
if cd.TargetID == nil {
return checkResult{health: "unknown", evidence: "vm-status needs a target VM"}
}
var pveID, hostAttr string
if err := pool.QueryRow(ctx,
"SELECT attributes->>'pve_id', COALESCE(attributes->>'host','') FROM entities WHERE id = $1",
*cd.TargetID).Scan(&pveID, &hostAttr); err != nil || pveID == "" {
return checkResult{health: "unknown", signalKind: "vm-status",
evidence: fmt.Sprintf("vm %s has no pve_id", cd.EntitySlug)}
}
hostSlug := remote.ResolveProxmoxHostSlug(ctx, pool, *cd.TargetID, hostAttr)
addr, user, err := remote.ResolveHost(ctx, pool, hostSlug, sshUser)
if err != nil {
return checkResult{health: "down", signalKind: "vm-status",
evidence: fmt.Sprintf("resolve proxmox host for %s: %v", cd.EntitySlug, err), err: err}
}
timeout := time.Duration(cd.TimeoutS) * time.Second
if timeout <= 0 {
timeout = 15 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
out, err := sshExec(ctx, addr, "22", user, "qm status "+pveID, timeout)
if err != nil {
return checkResult{health: "down", signalKind: "vm-status",
evidence: fmt.Sprintf("qm status %s on %s: %v", pveID, addr, err), err: err}
}
// `qm status <id>` prints "status: running" (or stopped/paused).
if strings.Contains(string(out), "status: running") {
return checkResult{health: "healthy"}
}
trimmed := strings.TrimSpace(string(out))
if trimmed == "" {
trimmed = "(no output)"
}
return checkResult{health: "down", signalKind: "vm-status",
evidence: fmt.Sprintf("%s not running: %s", cd.EntitySlug, trimmed)}
}
// 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"`
// Port for the TCP fallback below. Defaults to 22; set it for hosts
// that answer on something else (a Home Assistant VM has no sshd).
Port int `json:"port"`
}{}
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 {
// ICMP failing does not mean the host is down — it may mean ICMP is
// simply unavailable from here. On this deployment the scheduler runs
// in Docker on macOS, whose VM network stack does not route ICMP to
// the LAN: loopback pings succeed, every LAN ping fails, and all seven
// ping checks reported "down" for hosts that were demonstrably up
// (including the Docker host itself). Under health aggregation that one
// broken probe was enough to drag each entity to down.
//
// The question this check exists to answer is "is it reachable", and
// ICMP is only one way to ask. Fall back to a TCP connect before
// concluding anything.
if tcpReachable(ctx, cfg.Host, cfg.Port, timeout) {
return checkResult{
health: "healthy",
metrics: map[string]float64{},
}
}
return checkResult{
health: "down", signalKind: "ping",
evidence: fmt.Sprintf("no ICMP or TCP response from %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}
}
// tcpReachable reports whether a TCP handshake completes, used as the
// reachability answer when ICMP is unavailable rather than unanswered.
func tcpReachable(ctx context.Context, host string, port int, timeout time.Duration) bool {
if port == 0 {
port = 22
}
if timeout <= 0 {
timeout = 5 * time.Second
}
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
if err != nil {
return false
}
conn.Close()
return true
}
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 target via SSH.
//
// Routing follows the canonical access model (internal/remote): an LXC or VM
// is NEVER SSH'd into directly — it is reached through its Proxmox host via
// `pct exec`/`qm guest exec`, so a guest needs no lan_ip, sshd, or authorized
// key of its own. Hosts and workstations are reached by direct SSH, resolved
// live so a workstation's login (mac-mini: `user: dtoro`) is honored without
// a re-seed. Services and other entities fall back to the host address baked
// into check config at seed time (their hosting container's address).
func checkSSHScript(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDefsRow) checkResult {
cfg := struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Script string `json:"script"`
// Args is a single positional argument for the script. checkdefaults
// has always written it for process_check.sh, but nothing read it —
// so every process check ran argument-less and process_check.sh
// answered "no service name provided" with health unknown.
Args string `json:"args"`
}{}
if len(cd.Config) > 0 {
_ = json.Unmarshal(cd.Config, &cfg)
}
if cfg.Host == "" || cfg.Script == "" {
return checkResult{health: "healthy"}
}
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
if cfg.Args != "" {
// Single-quote the argument so an entity name can never break out of
// the remote command. The script name itself is allowlisted above.
scriptPath += " '" + strings.ReplaceAll(cfg.Args, "'", `'\''`) + "'"
}
// Resolve the execution endpoint. The resolver handles every target kind:
// LXC/VM host-hop via pct/qm exec; hosts/workstations direct at their own
// address; services route through their hosting compute entity (via the
// provides edge) so a service check reaches the right machine with the
// right user instead of baking an LXC lan_ip and SSHing it as root.
host, port, user := cfg.Host, strconv.Itoa(oru(cfg.Port, 22)), orStr(cfg.User, sshUser)
wrap := func(cmd string) string { return cmd }
targetType := ""
if cd.TargetType != nil {
targetType = *cd.TargetType
}
// target_type was omitted by older writeCheck inserts, so resolve it from
// the target entity when the column is blank — otherwise the guest routing
// below (IsGuest) never triggers and a guest check falls back to its baked
// (often mesh-only) address.
if targetType == "" && cd.TargetID != nil {
if err := pool.QueryRow(ctx, "SELECT type FROM entities WHERE id = $1", *cd.TargetID).Scan(&targetType); err != nil {
targetType = ""
}
}
if cd.TargetID != nil && targetType != "" {
et, err := remote.ResolveExecTargetForCheck(ctx, pool, *cd.TargetID, targetType, sshUser)
if err != nil {
if remote.IsGuest(targetType) {
return checkResult{
health: "down", signalKind: "ssh-script",
evidence: fmt.Sprintf("route guest %s: %v", cd.EntitySlug, err), err: err,
}
}
// Non-guest: log the resolution failure so an opaque ssh "down"
// doesn't hide that the real cause was host/user resolution, then
// fall back to the baked config below.
slog.Warn("scheduler: target resolution failed, using baked config",
"entity", cd.EntitySlug, "target_type", targetType, "error", err)
} else {
host, port, user, wrap = et.Host, "22", et.User, et.Wrap
}
}
output, err := sshExec(ctx, host, port, user, wrap(scriptPath), timeout)
if err != nil {
return checkResult{
health: "down", signalKind: "ssh-script",
evidence: fmt.Sprintf("ssh %s:%s %s: %v", host, 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,
}
}
// oru returns v when nonzero, else def. orStr returns v when non-empty, else def.
func oru(v, def int) int {
if v != 0 {
return v
}
return def
}
func orStr(v, def string) string {
if v != "" {
return v
}
return def
}
var scriptNameRe = regexp.MustCompile(`^[a-z][a-z0-9_-]+\.sh$`)
func allowlistedScript(name string) bool {
return scriptNameRe.MatchString(name)
}
// sshExec runs a command on a remote host over crypto/ssh via the shared
// actuator primitives. It replaced a fork of `os/exec ssh` so the scheduler,
// the MCP execution path, and the actuator share one dial/run/host-key
// implementation (plan E3). The host key is verified through the centralized
// actuator.HostKeyCallback seam. ctx bounds the running command; timeout
// bounds the dial.
func sshExec(ctx context.Context, host, port, user, cmd string, timeout time.Duration) ([]byte, error) {
keyPath := sshKeyPath
if keyPath == "" {
// Preserve the old os/exec-ssh behavior of deferring to a default
// key when no explicit OIKOS_SSH_KEY_PATH is configured: the system
// ssh binary used the agent / ~/.ssh; crypto/ssh has no agent wiring,
// so fall back to SSH_KEY_PATH then ~/.ssh/id_rsa.
keyPath = os.Getenv("SSH_KEY_PATH")
if keyPath == "" {
keyPath = os.Getenv("HOME") + "/.ssh/id_rsa"
}
}
signer, err := actuator.LoadSigner(keyPath)
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
p := 22
if port != "" {
if n, parseErr := strconv.Atoi(port); parseErr == nil && n > 0 {
p = n
}
}
client, err := sshPool.Get(ctx, actuator.DialOptions{
Host: host, Port: p, User: user, Signer: signer, Timeout: timeout,
})
if err != nil {
return nil, fmt.Errorf("ssh %s: %v", host, err)
}
// RunOutput (stdout-only) — the scheduler parses check output as JSON or
// matches it literally, so stderr must not be merged in (RunCombinedOutput
// is for the live-run display path in mcp/httpapi).
out, err := actuator.RunOutput(ctx, client, cmd)
if err != nil {
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