package scheduler import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/dtoro/oikos/internal/db/sqlcgen" ) // checkBackupFreshness reports whether a backup target has a recent artifact. // // The ontology has carried a `backup-target` type and a `backs-up-to` edge // since the first seed, but nothing ever verified that a backup actually // happened — a silent backup failure looked exactly like a working one. This // makes staleness a Signal like any other, so it flows through the existing // dedup, auto-resolve and notifier path rather than needing its own machinery. // // Config: {"path": "/opt/oikos/backups", "max_age_s": 86400, "host": …} // // Deliberately uses `find -mmin` rather than `-printf '%T@'` or `stat`: // -printf is GNU-only and stat's format flag differs between GNU (-c) and BSD // (-f). The first real target for this check is the pre-deploy pg_dump on the // mac-mini, which is macOS — so a GNU-only probe would have silently reported // "unknown" on the one target that motivated the check. func checkBackupFreshness(ctx context.Context, cd sqlcgen.ListEnabledCheckDefsRow) checkResult { cfg := struct { Path string `json:"path"` MaxAgeS int `json:"max_age_s"` Host string `json:"host"` User string `json:"user"` Port int `json:"port"` }{ MaxAgeS: 86400, // a daily backup that has not run in 24h is stale } if len(cd.Config) > 0 { _ = json.Unmarshal(cd.Config, &cfg) } if cfg.Path == "" || cfg.Host == "" { return checkResult{health: "unknown", signalKind: "backup-misconfigured", evidence: "backup check needs both a path and a host"} } if cfg.Port == 0 { cfg.Port = 22 } if cfg.User == "" { cfg.User = sshUser } if cfg.MaxAgeS <= 0 { cfg.MaxAgeS = 86400 } timeout := time.Duration(cd.TimeoutS) * time.Second if timeout <= 0 { timeout = 30 * time.Second } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() minutes := cfg.MaxAgeS / 60 if minutes < 1 { minutes = 1 } quoted := shellSingleQuote(cfg.Path) // Two questions in one round trip: is there anything at all, and is any of // it recent? "no backups ever" and "backups stopped" are different // failures and deserve different severities. cmd := fmt.Sprintf( `if [ ! -d %s ]; then echo missing; else `+ `f=$(find %s -type f -mmin -%d 2>/dev/null | head -1); `+ `a=$(find %s -type f 2>/dev/null | head -1); `+ `if [ -n "$f" ]; then echo fresh; elif [ -n "$a" ]; then echo stale; else echo empty; fi; fi`, quoted, quoted, minutes, quoted) out, err := sshExec(ctx, cfg.Host, strconv.Itoa(cfg.Port), cfg.User, cmd, timeout) if err != nil { return checkResult{ health: "unknown", signalKind: "backup-unreachable", evidence: fmt.Sprintf("ssh %s: %v", cfg.Host, err), err: err, } } age := time.Duration(cfg.MaxAgeS) * time.Second switch strings.TrimSpace(string(out)) { case "fresh": return checkResult{health: "healthy"} case "stale": return checkResult{ health: "degraded", signalKind: "backup-stale", evidence: fmt.Sprintf("no backup in %s under %s on %s", age, cfg.Path, cfg.Host), } case "empty": return checkResult{ health: "down", signalKind: "backup-missing", evidence: fmt.Sprintf("%s on %s exists but contains no files", cfg.Path, cfg.Host), } case "missing": return checkResult{ health: "down", signalKind: "backup-missing", evidence: fmt.Sprintf("backup directory %s does not exist on %s", cfg.Path, cfg.Host), } } return checkResult{health: "unknown", signalKind: "backup-unreachable", evidence: fmt.Sprintf("unexpected probe output: %q", strings.TrimSpace(string(out)))} } // shellSingleQuote makes a path safe to embed in the remote sh command. Paths // come from check_defs config, which an operator or the agent can write. func shellSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }