Files
Maven/cmd/mavpoll/main.go
kami 6316354518 zenmoney: bound the day fact to its own day and stamp when it was read
The day total rolls over at midnight and the poller had nothing to write until
the first spend of the new day, so at 09:00 the latest money_today fact was
yesterday's spending and looked perfectly fresh. The value now carries the
first instant of the window it covers, and a today question that the stored
window does not cover is refused rather than answered with yesterday's number.
Staleness was measured off the fact timestamp, which only moved when the figure
moved, so a quiet month was reported as data from three days ago while being
current. The value now carries when it was last read and the poller writes on
every read.

Amounts in an instrument the window diff never named were spoken with a numeric
instrument id as the currency. Instruments are resolved from one cursor-zero
diff, cached for the process, and an amount still unnamed is dropped from
speech rather than recited wrongly. "сколько я потратил вчера" was answered
with the month total, a real number to a different question, and is now
refused by naming the two windows she keeps. Income questions led with the
spending.

Found in review of #62.
2026-08-01 14:16:56 +04:00

481 lines
17 KiB
Go

// mavpoll — the env poller module.
//
// maven doesn't collect metrics; netdata and uptime-kuma already do, tuned to
// the box. This is a thin adapter: it reads their alarms/status and writes
// `facts (kind=env, source=poll:*)` through core's IPC socket. Key-free,
// restart-free, fail-independent — a crashing poller can't touch the store key
// (it never had it), worst case a stale env fact until the next tick.
//
// Two sources, each its own provenance (the loop's rules trust source):
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
//
// The zenmoney source is why the token lives HERE and not in core: the poller
// already owns every other third-party credential, it holds no store key, and
// core never needs to know an account exists to answer a question about a fact
// the poller wrote. It is off unless -zenmoney-token-file is given.
//
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
// still lights up an end-to-end nudge).
//
// Append-only discipline: a fact is written only when its value CHANGED vs the
// latest for that key+source. A poller that wrote every 60s would churn the
// facts table for nothing; the store is the audit trail, not a metrics sink.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/zenmoney"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "mavpoll:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("mavpoll", flag.ContinueOnError)
socket := fs.String("socket", "", "core IPC socket path (required)")
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
timeout := fs.Duration("timeout", 8*time.Second, "per-request HTTP timeout")
if err := fs.Parse(args); err != nil {
return err
}
if *socket == "" {
return fmt.Errorf("-socket is required")
}
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
}
// The token is read from a file, never taken as a flag value: an argv token
// is visible in `ps` to every user on the box and lands in the compose file
// and the shell history. Read once at start — a rotated token means a
// restart, which is cheaper than re-reading his credential every hour.
var zen *zenmoney.Client
if *zenTokenFile != "" {
raw, err := os.ReadFile(*zenTokenFile)
if err != nil {
return fmt.Errorf("read zenmoney token: %w", err)
}
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
if err != nil {
return err
}
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
core, err := ipc.DialWait(*socket, 60*time.Second)
if err != nil {
return err
}
defer core.Close()
p := &poller{
core: core,
http: &http.Client{Timeout: *timeout},
netdataURL: strings.TrimRight(*netdataURL, "/"),
kumaURL: *kumaURL,
kumaKey: *kumaKey,
wgIface: *wgIface,
wgCmd: *wgCmd,
zen: zen,
zenEvery: *zenInterval,
}
// The token is never logged, not even its length.
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
t := time.NewTicker(*interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
log.Printf("mavpoll: bye")
return nil
case <-t.C:
p.pollOnce(ctx)
}
}
}
type poller struct {
core ipc.CoreAPI
http *http.Client
netdataURL string
kumaURL string
kumaKey string
wgIface string
wgCmd string
// zen is nil unless a token file was configured — money tracking is a
// capability, off by default like weather and telegram.
zen *zenmoney.Client
zenEvery time.Duration
zenLast time.Time
}
// pollOnce — one sweep of both sources. A failure in one source logs and does
// NOT abort the other: netdata being down shouldn't blind kuma and vice versa.
func (p *poller) pollOnce(ctx context.Context) {
now := time.Now()
if p.netdataURL != "" {
if err := p.pollNetdata(ctx, now); err != nil {
log.Printf("mavpoll: netdata: %v", err)
}
}
if p.kumaURL != "" {
if err := p.pollKuma(ctx, now); err != nil {
log.Printf("mavpoll: kuma: %v", err)
}
}
if p.wgIface != "" {
if err := p.pollWg(ctx); err != nil {
log.Printf("mavpoll: wg: %v", err)
}
}
// Money on its own, much slower cadence: a bank feed that updates hourly
// polled every minute is 60 pointless reads of his financial history.
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
p.zenLast = now
if err := p.pollZenmoney(ctx, now); err != nil {
log.Printf("mavpoll: zenmoney: %v", err)
}
}
}
// ---- zenmoney: spending/income totals → money facts ------------------------
// pollZenmoney reads today's and this month's totals and writes them as
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
//
// Two properties this function exists to hold:
//
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
// to encode a summary built from zero transactions, so a poller that cannot
// reach the API leaves the last good fact in place rather than overwriting
// it with a zero Maven would then recite as fact.
// - Nothing about the money leaves the box except the diff request itself, to
// the service that already holds his bank sessions. The totals are written
// to the store and read back only when he asks; they are never search input
// and no tick rule fires on them.
//
// Both windows are read from one diff call each. Two calls an hour against an
// API whose whole job is this is not worth caching.
//
// The write is UNCONDITIONAL, unlike every other poll in this file. The
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
// for money that made ts mean "last changed" while the reader was asking it "as
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
// that was current. The value now carries its own read stamp, so it differs
// every poll anyway and there is nothing left for the dedupe to catch.
// moneyWindow — one fact key and the period it covers.
type moneyWindow struct {
key string
from, to time.Time
}
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
dFrom, dTo := zenmoney.DayWindow(now)
mFrom, mTo := zenmoney.MonthWindow(now)
windows := []moneyWindow{
{zenmoney.KeySpentToday, dFrom, dTo},
{zenmoney.KeySpentMonth, mFrom, mTo},
}
var firstErr error
for _, w := range windows {
sum, err := p.zen.Since(ctx, w.from, w.to)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
val, ok := sum.Value(now)
if !ok {
// Nothing read. Silence, not a zero. The last good fact stays, and
// the window stamp inside it is what stops core reciting yesterday's
// day total as today's after midnight.
continue
}
if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// ---- wireguard: latest handshake → presence signal -------------------------
// pollWg reads `wg show <iface> latest-handshakes` and writes a wg_handshake
// fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not
// now(). Presence decays from the real handshake instant, so the fact's ts must
// be that instant. We write only when the handshake ADVANCES vs the last fact,
// so a quiet tunnel produces no churn (and presence just decays out, τ=20min).
//
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
func (p *poller) pollWg(ctx context.Context) error {
fields := strings.Fields(p.wgCmd)
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
if err != nil {
return fmt.Errorf("run %s: %w", p.wgCmd, err)
}
maxTs := parseMaxHandshake(string(out))
if maxTs == 0 {
return nil // no peer has ever handshaked → drop out of presence
}
hs := time.Unix(maxTs, 0)
prev, err := p.core.LatestFactBySource(ctx, "wg_handshake", "infer:wg")
if err == nil && !hs.After(prev.Ts) {
return nil // not newer → no churn
}
if err != nil && err != ipc.ErrNoFact && !isNoFact(err) {
return fmt.Errorf("read wg_handshake: %w", err)
}
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: hs, Kind: "env", Key: "wg_handshake", Value: `"up"`,
Source: "infer:wg", Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write wg_handshake: %w", err)
}
log.Printf("mavpoll: wg_handshake @ %s (infer:wg)", hs.Format(time.RFC3339))
return nil
}
// parseMaxHandshake — max last-field unix ts across `wg show latest-handshakes`
// lines. Handles both the per-iface form (`<pubkey>\t<ts>`) and the `all` form
// (`<iface>\t<pubkey>\t<ts>`); the timestamp is always the last field. 0 = none.
func parseMaxHandshake(out string) int64 {
var max int64
for _, line := range strings.Split(out, "\n") {
f := strings.Fields(line)
if len(f) == 0 {
continue
}
ts, err := strconv.ParseInt(f[len(f)-1], 10, 64)
if err == nil && ts > max {
max = ts
}
}
return max
}
// ---- netdata: active alarms → aggregate severity ---------------------------
// netdata /api/v1/alarms?active=true returns {"alarms": {"<chart.name>": {...,
// "status": "WARNING"|"CRITICAL"|"CLEAR"|...}}}. We only need the max active
// severity; a rule fires on "critical". The per-alarm detail lives in netdata's
// own UI — we don't re-store it (YAGNI; add a per-alarm fact when a rule needs
// one specific alarm by name).
type netdataAlarms struct {
Alarms map[string]struct {
Status string `json:"status"`
} `json:"alarms"`
}
func (p *poller) pollNetdata(ctx context.Context, now time.Time) error {
body, err := p.get(ctx, p.netdataURL+"/api/v1/alarms?active=true", "")
if err != nil {
return err
}
var a netdataAlarms
if err := json.Unmarshal(body, &a); err != nil {
return fmt.Errorf("decode alarms: %w", err)
}
return p.writeIfChanged(ctx, "netdata_alarm", "poll:netdata", maxSeverity(a), now)
}
// maxSeverity reduces active alarms to the aggregate the rule consumes.
func maxSeverity(a netdataAlarms) string {
sev := "clear"
for _, al := range a.Alarms {
switch strings.ToUpper(al.Status) {
case "CRITICAL":
return "critical" // highest — short-circuit
case "WARNING":
sev = "warning"
}
}
return sev
}
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
// only needs "something is down".
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
if err != nil {
return err
}
down, seen := kumaAnyDown(body)
if !seen {
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
}
val := "up"
if down {
val = "down"
}
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
}
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
// lines matched at all (wrong endpoint or auth rejected before the body).
func kumaAnyDown(body []byte) (down, seen bool) {
for _, line := range strings.Split(string(body), "\n") {
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
continue
}
seen = true
v, err := strconv.ParseFloat(m[2], 64)
if err != nil {
continue
}
if v == 0 {
down = true
}
}
return down, seen
}
// ---- helpers ---------------------------------------------------------------
// writeIfChanged writes a `facts(kind=env)` row only when val differs from the
// latest fact for (key, source). Values are stored JSON-encoded (the store's
// convention: `"down"`, `"critical"`), matching how rules compare f.Value.
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
jv, _ := json.Marshal(val) // string never fails to marshal
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == string(jv):
return nil // unchanged → no churn
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
}
_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now,
Kind: "env",
Key: key,
Value: string(jv),
Source: source,
Confidence: 1.0, // a direct reading, not an inference
})
if err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
log.Printf("mavpoll: %s=%s (%s)", key, val, source)
return nil
}
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
// money facts store an object, not a string). Kept separate rather than
// generalising writeIfChanged, because the string-valued env facts encoding
// their own value is the convention the rules rely on.
//
// The log line names the key and the source, never the figures: mavpoll's log
// is not the place his spending ends up.
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == jsonVal:
return nil
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
}
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
log.Printf("mavpoll: %s updated (%s)", key, source)
return nil
}
// writeMoneyFact writes a money fact every poll, with no value comparison. See
// the comment above pollZenmoney for why this one does not go through
// writeIfChangedRaw.
//
// The log line names the key only, never the figures: mavpoll's log is not the
// place his spending ends up.
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: zenmoney.Source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check.
func isNoFact(err error) bool {
return errors.Is(err, ipc.ErrNoFact)
}
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if basicUser != "" {
req.SetBasicAuth("", basicUser) // kuma: API key as password, empty username
}
resp, err := p.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
return body, nil
}