Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
version: 2

builds:
- main: ./cmd/tusk
- id: tusk
main: ./cmd/tusk
binary: tusk
env:
- CGO_ENABLED=0
Expand All @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
134 changes: 134 additions & 0 deletions cmd/tuskd/main.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
13 changes: 13 additions & 0 deletions cmd/tuskd/main_suite_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
106 changes: 106 additions & 0 deletions cmd/tuskd/main_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})