From 56d78c12efbc0765b1c8a2dd7f1ed674892a9205 Mon Sep 17 00:00:00 2001 From: Fraser Isbester Date: Mon, 6 Jul 2026 17:07:32 -0700 Subject: [PATCH] feat(tuskd): add headless rules daemon Add cmd/tuskd, a standalone binary that runs a profile's rules engine without any TUI dependency (no tview/tcell linked), resolving issue #5's "deployable as a daemon without all the TUI bloat". It reuses the existing config, db, and rules packages: loads a profile, compiles its rules, and evaluates them against a live snapshot on a configurable interval (-i, default 2s), honoring readonly/dry_run. Fetch errors for a single resource are logged and skipped rather than crashing the loop, and it shuts down cleanly on SIGINT/SIGTERM. - .goreleaser.yml: build and ship the tuskd binary alongside tusk - README: Daemon section with install, usage, and a systemd unit - tests: evaluate() with a fake snapshotter (incl. partial fetch failure) and runDaemon() context-cancel shutdown Closes #5 Co-Authored-By: Claude Opus 4.8 (1M context) --- .goreleaser.yml | 16 ++++- README.md | 40 +++++++++++ cmd/tuskd/main.go | 134 +++++++++++++++++++++++++++++++++++ cmd/tuskd/main_suite_test.go | 13 ++++ cmd/tuskd/main_test.go | 106 +++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 cmd/tuskd/main.go create mode 100644 cmd/tuskd/main_suite_test.go create mode 100644 cmd/tuskd/main_test.go diff --git a/.goreleaser.yml b/.goreleaser.yml index 03d8dbb..4f9151b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,7 +1,8 @@ version: 2 builds: - - main: ./cmd/tusk + - id: tusk + main: ./cmd/tusk binary: tusk env: - CGO_ENABLED=0 @@ -13,6 +14,19 @@ builds: - arm64 ldflags: - -s -w + - id: tuskd + main: ./cmd/tuskd + binary: tuskd + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w archives: - formats: diff --git a/README.md b/README.md index eb05c45..7b7ecab 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,46 @@ tusk 'postgres://user:pass@localhost:5432/mydb' tusk -P production ``` +## Daemon + +`tuskd` runs a profile's rules engine headlessly — the same rules, evaluation, and +auto-remediation as the TUI, with no terminal UI. Use it to enforce rules continuously +in the background (e.g. under systemd). + +**Install:** +```bash +go install github.com/fraser-isbester/tusk/cmd/tuskd@latest +``` +(`tuskd` is also included in the [release archives](https://github.com/fraser-isbester/tusk/releases) alongside `tusk`.) + +**Run:** +```bash +# Evaluate the profile's rules every 2s (default) +tuskd -P production + +# Custom polling interval +tuskd -P production -i 5s +``` + +`tuskd` requires the selected profile to define at least one rule; it reads the same +`~/.config/tusk/config.yaml` as `tusk`, honors `readonly`/`dry_run`, and shuts down +cleanly on `SIGINT`/`SIGTERM`. + +**systemd unit** (`/etc/systemd/system/tuskd.service`): +```ini +[Unit] +Description=Tusk rules daemon +After=network.target + +[Service] +ExecStart=/usr/local/bin/tuskd -P production +Restart=on-failure +User=tusk + +[Install] +WantedBy=multi-user.target +``` + ## Configuration `~/.config/tusk/config.yaml` diff --git a/cmd/tuskd/main.go b/cmd/tuskd/main.go new file mode 100644 index 0000000..1840fb1 --- /dev/null +++ b/cmd/tuskd/main.go @@ -0,0 +1,134 @@ +// Command tuskd is the Tusk daemon: it continuously polls PostgreSQL and +// evaluates the rules engine without any TUI dependencies. It reuses the same +// config, database, and rules packages as the tusk TUI, so a profile's rules +// behave identically whether enforced interactively or headless. +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/fraser-isbester/tusk/internal/config" + "github.com/fraser-isbester/tusk/internal/db" + "github.com/fraser-isbester/tusk/internal/rules" +) + +func main() { + var profileFlag string + var intervalFlag time.Duration + + rootCmd := &cobra.Command{ + Use: "tuskd", + Short: "Tusk daemon — continuously monitors PostgreSQL and enforces rules", + Long: "A lightweight daemon that polls PostgreSQL and evaluates rules without any TUI dependencies.", + RunE: func(_ *cobra.Command, _ []string) error { + if intervalFlag <= 0 { + return fmt.Errorf("interval must be positive, got %s", intervalFlag) + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + profile, err := cfg.ResolveProfile(profileFlag) + if err != nil { + return fmt.Errorf("resolving profile: %w", err) + } + + profileName := profileFlag + if profileName == "" { + profileName = cfg.DefaultProfile + } + + if len(profile.Rules) == 0 { + return fmt.Errorf("no rules configured in profile %q", profileName) + } + + database, err := db.New(profile.ConnectionString()) + if err != nil { + return fmt.Errorf("connecting to database: %w", err) + } + defer database.Close() + + compiled, err := rules.BuildRules(profile.Rules, profile.Readonly) + if err != nil { + return fmt.Errorf("compiling rules: %w", err) + } + + engine := rules.NewEngine(compiled, database, 5*time.Minute, 1000) + + log.Printf("tuskd: profile=%s rules=%d interval=%s", profileName, engine.RuleCount(), intervalFlag) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + return runDaemon(ctx, engine, database, intervalFlag) + }, + } + + rootCmd.Flags().StringVarP(&profileFlag, "profile", "P", "", "connection profile name from config file") + rootCmd.Flags().DurationVarP(&intervalFlag, "interval", "i", 2*time.Second, "polling interval") + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +// snapshotter fetches the live database state that the engine evaluates against. +// *db.DB satisfies it; a fake is used in tests. Keeping the daemon's evaluate +// loop behind this interface makes it testable without a real database. +type snapshotter interface { + GetActiveQueries(ctx context.Context) ([]db.Query, error) + GetTransactions(ctx context.Context) ([]db.Transaction, error) + GetLocks(ctx context.Context) ([]db.Lock, error) +} + +// runDaemon polls the database on interval and evaluates rules until ctx is +// canceled (the caller wires ctx to SIGINT/SIGTERM). +func runDaemon(ctx context.Context, engine *rules.Engine, source snapshotter, interval time.Duration) error { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + evaluate(ctx, engine, source) + + for { + select { + case <-ctx.Done(): + log.Println("tuskd: shutting down") + return nil + case <-ticker.C: + evaluate(ctx, engine, source) + } + } +} + +// evaluate fetches one snapshot and runs the engine against it. Fetch errors for +// a single resource are logged and that resource is skipped for the tick — a +// transient database blip should not crash the daemon. +func evaluate(ctx context.Context, engine *rules.Engine, source snapshotter) { + queries, err := source.GetActiveQueries(ctx) + if err != nil { + log.Printf("tuskd: fetch queries: %v", err) + } + txns, err := source.GetTransactions(ctx) + if err != nil { + log.Printf("tuskd: fetch transactions: %v", err) + } + locks, err := source.GetLocks(ctx) + if err != nil { + log.Printf("tuskd: fetch locks: %v", err) + } + + engine.Evaluate(ctx, rules.Snapshot{ + Queries: queries, + Transactions: txns, + Locks: locks, + }) +} diff --git a/cmd/tuskd/main_suite_test.go b/cmd/tuskd/main_suite_test.go new file mode 100644 index 0000000..ad7a2a1 --- /dev/null +++ b/cmd/tuskd/main_suite_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTuskd(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Tuskd Suite") +} diff --git a/cmd/tuskd/main_test.go b/cmd/tuskd/main_test.go new file mode 100644 index 0000000..60f3090 --- /dev/null +++ b/cmd/tuskd/main_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/tusk/internal/db" + "github.com/fraser-isbester/tusk/internal/rules" +) + +// fakeSource is a test snapshotter returning canned data (and optional errors). +type fakeSource struct { + queries []db.Query + txns []db.Transaction + locks []db.Lock + queryErr error + txnErr error + lockErr error + calls int +} + +func (f *fakeSource) GetActiveQueries(_ context.Context) ([]db.Query, error) { + f.calls++ + return f.queries, f.queryErr +} +func (f *fakeSource) GetTransactions(_ context.Context) ([]db.Transaction, error) { + return f.txns, f.txnErr +} +func (f *fakeSource) GetLocks(_ context.Context) ([]db.Lock, error) { + return f.locks, f.lockErr +} + +func mustRule(name, resource, expr, action string) rules.Rule { + built, err := rules.BuildRules([]rules.RuleConfig{ + {Name: name, Resource: resource, When: expr, Action: action, DryRun: true}, + }, false) + Expect(err).NotTo(HaveOccurred()) + return built[0] +} + +var _ = Describe("evaluate", func() { + var engine *rules.Engine + + BeforeEach(func() { + // nil db is safe: every rule is dry-run, so no action touches the DB. + engine = rules.NewEngine(nil, nil, 5*time.Minute, 100) + engine.UpdateRules([]rules.Rule{ + mustRule("slow-query", "query", "duration > duration('1s')", "cancel"), + mustRule("long-tx", "transaction", "xact_duration > duration('30s')", "log"), + }) + }) + + It("records violations from the fetched snapshot", func() { + src := &fakeSource{ + queries: []db.Query{ + {ResourceBase: db.ResourceBase{PID: 42, State: "active"}, Duration: 5 * time.Second}, + }, + } + evaluate(context.Background(), engine, src) + + violations := engine.RecentViolations() + Expect(violations).To(HaveLen(1)) + Expect(violations[0].RuleName).To(Equal("slow-query")) + Expect(violations[0].PID).To(Equal(42)) + }) + + It("continues evaluating healthy resources when one fetch errors", func() { + src := &fakeSource{ + queryErr: errors.New("connection reset"), + txns: []db.Transaction{ + {ResourceBase: db.ResourceBase{PID: 10, State: "idle in transaction"}, XactDuration: 60 * time.Second}, + }, + } + // Must not panic despite the query fetch error; the transaction still matches. + Expect(func() { evaluate(context.Background(), engine, src) }).NotTo(Panic()) + + violations := engine.RecentViolations() + Expect(violations).To(HaveLen(1)) + Expect(violations[0].RuleName).To(Equal("long-tx")) + Expect(violations[0].PID).To(Equal(10)) + }) +}) + +var _ = Describe("runDaemon", func() { + It("evaluates once immediately and returns when the context is canceled", func() { + engine := rules.NewEngine(nil, nil, 5*time.Minute, 100) + engine.UpdateRules([]rules.Rule{mustRule("r", "query", "true", "log")}) + src := &fakeSource{ + queries: []db.Query{{ResourceBase: db.ResourceBase{PID: 1, State: "active"}}}, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // already canceled: the loop should do its initial evaluate, then exit. + + done := make(chan error, 1) + go func() { done <- runDaemon(ctx, engine, src, time.Hour) }() + + Eventually(done, 2*time.Second).Should(Receive(BeNil())) + Expect(src.calls).To(BeNumerically(">=", 1)) // initial evaluate ran + Expect(engine.ViolatedPIDs()).To(HaveKey(1)) + }) +})