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
17 changes: 10 additions & 7 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ type SyncRunReport struct {
Err error
}

// Config configures one agent listener. Fields are validated by New; all
// of them are required except TLSCert/TLSKey (which must be set together
// or not at all — empty pair means plain HTTP).
// Config configures one agent. Fields are validated by New: Version is
// always required; Listen is optional (empty selects listener-less,
// scheduler-only mode, F35); Token is required only when Listen is set (an
// HTTP surface needs a bearer token); TLSCert/TLSKey must be set together
// or not at all — empty pair means plain HTTP.
type Config struct {
// Listen is the bind address passed to net.Listen, e.g. "0.0.0.0:8443".
Listen string
Expand Down Expand Up @@ -191,10 +193,11 @@ func (s *Server) CertFingerprint() (string, error) {
func (s *Server) Addr() string { return s.cfg.Listen }

func validateConfig(cfg Config) error {
if cfg.Listen == "" {
return errors.New("agent: Config.Listen is required")
}
if cfg.Token == "" {
// An empty Listen selects listener-less mode (F35): the agent runs only
// its background schedulers, so neither a bind address nor a bearer
// token is required. A token is required only when there is an HTTP
// surface to protect.
if cfg.Listen != "" && cfg.Token == "" {
return errors.New("agent: Config.Token is required")
}
if (cfg.TLSCert == "") != (cfg.TLSKey == "") {
Expand Down
38 changes: 37 additions & 1 deletion agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ func TestNewRejectsBadConfig(t *testing.T) {
cfg Config
want string
}{
{"no listen", Config{Token: "t", Version: "v"}, "Listen is required"},
{"no token", Config{Listen: ":0", Version: "v"}, "Token is required"},
{"half tls", Config{Listen: ":0", Token: "t", TLSCert: "c", Version: "v"}, "must be set together"},
{"no version", Config{Listen: ":0", Token: "t"}, "Version is required"},
Expand All @@ -79,6 +78,43 @@ func TestNewRejectsBadConfig(t *testing.T) {
}
}

// TestNewListenerLess covers F35: an empty Listen (and no Token) is a
// valid scheduler-only agent, and the server reports no listener/TLS.
func TestNewListenerLess(t *testing.T) {
srv, err := New(Config{Version: "v"}, openTestStore(t))
if err != nil {
t.Fatalf("New (listener-less): %v", err)
}
if srv.Addr() != "" {
t.Fatalf("Addr = %q, want empty for a listener-less agent", srv.Addr())
}
if srv.HasTLS() {
t.Fatal("listener-less agent unexpectedly reports TLS")
}
}

// TestRunSchedulersReturnsOnCancel pins that the listener-less run path
// blocks until its context is cancelled and then returns cleanly, without
// ever binding a listener.
func TestRunSchedulersReturnsOnCancel(t *testing.T) {
srv, err := New(Config{Version: "v"}, openTestStore(t))
if err != nil {
t.Fatalf("New: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- srv.RunSchedulers(ctx) }()
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("RunSchedulers: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunSchedulers did not return after context cancel")
}
}

// TestNewDefaultsLoggerToDiscard pins the contract that callers may
// leave Config.Logger nil and the agent will still be safe to use:
// New() substitutes a discard logger so the future scheduler can
Expand Down
20 changes: 20 additions & 0 deletions agent/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
}
}

// RunSchedulers runs the background scan + cadence loops without binding
// an HTTP listener — the listener-less agent mode (F35) for cadence-only
// machines that never receive peer syncs. It blocks until ctx is
// cancelled, then waits for an in-flight loop tick to finish its volume,
// mirroring Serve's shutdown discipline minus the HTTP server. The two
// loops are gated exactly as under Serve (scan only when ScanInterval is
// set; scheduler only when a volume declares a cadence), so an agent with
// nothing scheduled runs no goroutines and simply waits for cancellation.
func (s *Server) RunSchedulers(ctx context.Context) error {
loopCtx, cancelLoops := context.WithCancel(ctx)
defer cancelLoops()
var loopWG sync.WaitGroup
s.startScanLoop(loopCtx, &loopWG, s.scanLogger())
s.startSchedulerLoop(loopCtx, &loopWG)
<-ctx.Done()
cancelLoops()
loopWG.Wait()
return nil
}

// startScanLoop spins up the drift-detection scheduler in a sibling
// goroutine, but only when ScanInterval is set. The WaitGroup lets
// Serve block on the loop's clean exit during shutdown so a tick
Expand Down
45 changes: 45 additions & 0 deletions cmd/squirrel/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ func runAgent(cmd *cobra.Command) error {
if err != nil {
return err
}
return serveAgent(cmd, cfg, srv, logger)
}

// serveAgent dispatches the built server to its run path: the listener-less
// scheduler-only run (F35) when [agent] listen is empty, or the HTTP server
// otherwise. Split from runAgent so the setup phase stays compact.
func serveAgent(cmd *cobra.Command, cfg *config.Config, srv *agent.Server, logger *slog.Logger) error {
// Listener-less mode (F35): no `listen`, so run the schedulers without an
// HTTP server. Refuse an agent that would do nothing at all — a
// scheduler-only agent with no cadences and no scan is silent
// degradation, not a valid config.
if cfg.Agent.Listen == "" {
if !agentHasWork(cfg) {
return fmt.Errorf("listener-less agent has nothing to run: set [agent] listen to receive peer syncs, or configure a cadence (a volume's sync_every, index_every, or hook.interval, or [agent] scan_interval) in %s", cfg.Path)
}
logSchedulerOnlyStartup(logger)
return srv.RunSchedulers(cmd.Context())
}
// Bind first so a port-in-use (or any other listen failure) surfaces
// as a CLI error and never logs a misleading "agent listening" line.
// We also log the listener's resolved Addr so `:0` (and other
Expand All @@ -88,6 +106,33 @@ func runAgent(cmd *cobra.Command) error {
return srv.Serve(cmd.Context(), ln)
}

// agentHasWork reports whether a listener-less agent has any background
// work: a drift-scan interval, or at least one volume with a scheduler
// cadence (sync, standalone index, or interval hook). It mirrors the
// agent scheduler's own "is anything scheduled" gate so the CLI refuses a
// do-nothing agent rather than letting it idle silently.
func agentHasWork(cfg *config.Config) bool {
if cfg.Agent.ScanInterval > 0 {
return true
}
for _, v := range cfg.Volumes {
if v.SyncEvery > 0 || v.IndexEvery > 0 {
return true
}
if v.Hook != nil && v.Hook.Interval > 0 {
return true
}
}
return false
}

// logSchedulerOnlyStartup emits the listener-less counterpart of the
// "agent listening" banner so a journal shows the agent came up in
// scheduler-only mode with no bound port.
func logSchedulerOnlyStartup(logger *slog.Logger) {
logger.Info("agent scheduler running", "listener", "disabled", "version", agentVersion)
}

// openAgentStore extends the standard resolveDBPath precedence with the
// agent-specific override: --db > cfg.Agent.DB > cfg.DB > default. We
// can't reuse openStore directly because it doesn't know about the
Expand Down
85 changes: 85 additions & 0 deletions cmd/squirrel/agent_listenerless_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package main

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)

// TestCLIAgentListenerLessRunsScheduler covers F35 end-to-end: an [agent]
// block with no `listen` starts the scheduler without binding an HTTP
// listener, logs the scheduler-only banner, and shuts down cleanly on
// context cancel. The volume declares an index cadence (no sync_to), so no
// rclone binary is needed to bring the agent up.
func TestCLIAgentListenerLessRunsScheduler(t *testing.T) {
dir := t.TempDir()
vol := filepath.Join(dir, "photos")
if err := os.MkdirAll(vol, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(vol, "a.jpg"), "x")
dbPath := filepath.Join(dir, "index.db")
cfgPath := filepath.Join(dir, "config.toml")
body := fmt.Sprintf("db = %q\n\n[agent]\n\n[volumes.photos]\npath = %q\nindex_every = \"1h\"\n", dbPath, vol)
if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil {
t.Fatal(err)
}

isolateConfig(t)
ctx, cancel := context.WithCancel(context.Background())
buf := &syncBuf{}
root := newRootCmd()
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs([]string{"--config", cfgPath, "agent"})
done := make(chan error, 1)
go func() { done <- root.ExecuteContext(ctx) }()

deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) && !strings.Contains(buf.String(), "agent scheduler running") {
time.Sleep(20 * time.Millisecond)
}
if !strings.Contains(buf.String(), "agent scheduler running") {
cancel()
t.Fatalf("scheduler-only banner never appeared:\n%s", buf.String())
}
if strings.Contains(buf.String(), "agent listening") {
cancel()
t.Fatalf("listener-less agent unexpectedly bound a listener:\n%s", buf.String())
}

cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("agent exited with error: %v\noutput:\n%s", err, buf.String())
}
case <-time.After(5 * time.Second):
t.Fatalf("agent did not shut down within timeout\noutput:\n%s", buf.String())
}
}

// TestCLIAgentListenerLessNoWork guards the fail-fast: a listener-less
// agent with no cadence and no scan has nothing to run and must refuse to
// start rather than idle silently.
func TestCLIAgentListenerLessNoWork(t *testing.T) {
dir := t.TempDir()
vol := filepath.Join(dir, "photos")
if err := os.MkdirAll(vol, 0o755); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(dir, "index.db")
cfgPath := filepath.Join(dir, "config.toml")
body := fmt.Sprintf("db = %q\n\n[agent]\n\n[volumes.photos]\npath = %q\n", dbPath, vol)
if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
_, err := runCLIExpectErr(t, "--config", cfgPath, "agent")
if !strings.Contains(err.Error(), "nothing to run") {
t.Fatalf("expected nothing-to-run error, got %v", err)
}
}
24 changes: 16 additions & 8 deletions config/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ const (
// callers try to start without it.
type Agent struct {
// Listen is the TCP address the agent binds to, e.g. "0.0.0.0:8443".
// Required.
// Optional: an empty value selects listener-less, scheduler-only mode
// (F35) — the agent runs its cadences without binding an HTTP server.
Listen string
// DB optionally overrides the top-level db for the agent process. The
// agent binary resolves --db > Agent.DB > top-level db > default; an
Expand All @@ -28,9 +29,10 @@ type Agent struct {
// empty disables TLS (plain HTTP).
TLSCert string
TLSKey string
// Token is the resolved bearer token literal. Required: an agent
// without a token is an unauthenticated open port and we refuse to
// start one.
// Token is the resolved bearer token literal. Required only when Listen
// is set: an agent with a listener but no token is an unauthenticated
// open port and we refuse to start one. A listener-less agent has no
// HTTP surface and needs no token.
Token string
// PeerTokens maps a per-peer bearer token to the node name that
// presents it, so the agent can recover an authenticated caller
Expand Down Expand Up @@ -84,9 +86,9 @@ type rawPeerAuth struct {
}

func resolveAgent(r *rawAgent) (*Agent, error) {
if r.Listen == "" {
return nil, errors.New("listen is required")
}
// An empty listen is the listener-less mode (F35): the agent runs the
// background schedulers (index/sync/audit cadences) without binding an
// HTTP server, for cadence-only machines that never receive peer syncs.
a := &Agent{Listen: r.Listen}
if r.DB != "" {
expanded, err := expandPath(r.DB)
Expand Down Expand Up @@ -162,7 +164,13 @@ func resolveAgentTLS(r *rawTLS, a *Agent) error {

func resolveAgentAuth(r *rawAuth, a *Agent) error {
if r == nil || r.Token == nil {
return errors.New("auth.token is required (no agent without authentication)")
if a.Listen == "" {
// Listener-less agent (F35): there is no HTTP surface to
// authenticate, so a bearer token is not required — and peer
// tokens would have no listener to guard either.
return nil
}
return errors.New("auth.token is required when [agent] listen is set (the listener needs a bearer token; omit listen for a listener-less, scheduler-only agent)")
}
// resolveSecret takes a map[string]any and pulls the named key. We pass
// a synthetic single-entry map so the same code handles plain strings
Expand Down
23 changes: 15 additions & 8 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -979,14 +979,21 @@ auth = { }
}
}

func TestLoadAgentMissingListen(t *testing.T) {
p := writeConfig(t, `
[agent]
auth = { token = "x" }
`)
_, err := Load(p)
if err == nil || !strings.Contains(err.Error(), "listen is required") {
t.Fatalf("expected listen-required error, got %v", err)
// TestLoadAgentListenerLess covers F35: an [agent] block without `listen`
// is the listener-less mode — valid, scheduler-only. A bare block needs no
// auth, and an auth token without a listener is tolerated (unused).
func TestLoadAgentListenerLess(t *testing.T) {
for _, body := range []string{
"\n[agent]\n",
"\n[agent]\nauth = { token = \"x\" }\n",
} {
cfg, err := Load(writeConfig(t, body))
if err != nil {
t.Fatalf("Load(%q): %v", body, err)
}
if cfg.Agent == nil || cfg.Agent.Listen != "" {
t.Fatalf("expected listener-less agent (empty Listen), got %+v", cfg.Agent)
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion design/friction-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ bytes ever traverse, purely to enable durability pulls.
*Partially addressed in #171: `squirrel config check` now stats and
flags a missing node byte-path; load-time validation is still absent.*

**F35 · S3 — cadence-only machines must still run the full agent.**
**F35 · S3 — ~~cadence-only machines must still run the full agent.~~ (fixed in #175)**
A machine that never receives (laptop) runs the HTTP listener and
must configure `[agent] listen` + auth token anyway, because the
scheduler lives inside the agent. A listener that exists to be unused
Expand Down
6 changes: 3 additions & 3 deletions design/reference-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,9 +187,9 @@ db = "~/.squirrel/index.db"
node_name = "laptop"

[agent]
listen = "127.0.0.1:8443" # never receives; agent runs only for the cadences
[agent.auth]
token = { env = "SQUIRREL_AGENT_TOKEN" }
# No `listen`: this roaming laptop never receives peer syncs, so the agent
# runs the sync/index schedulers *without* an HTTP listener (F35). With no
# listener there is nothing to authenticate, so no [agent.auth] token either.

[volumes.photos]
path = "~/Pictures"
Expand Down
28 changes: 26 additions & 2 deletions docs/src/content/docs/guides/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,32 @@ The agent requires an `[agent]` block in config.
schedule.
- **Hook firing** — fires per-volume [hooks](/squirrel/guides/hooks/) on change
(after each successful index run) and on their `interval`.
- **HTTP server** — exposes a health endpoint and drives the state the
[TUI](/squirrel/guides/tui/) and desktop app read.
- **HTTP server** — exposes a health endpoint, serves peer syncs, and drives
the state the [TUI](/squirrel/guides/tui/) and desktop app read. Only when
`[agent] listen` is set (see below).

## Listener-less (cadence-only) machines

A machine that only *originates* content — a roaming laptop that pushes to a
hub and never receives peer syncs — has no reason to run an HTTP listener. Omit
`[agent] listen` and the agent runs its schedulers (index/sync/audit cadences)
**without binding a listener**; with no listener there is nothing to
authenticate, so `[agent.auth]` is optional too.

```toml
[agent]
# no `listen`: schedulers only, no HTTP server

[volumes.photos]
path = "~/Pictures"
sync_to = ["nas"]
sync_every = "1h"
```

When `listen` *is* set, behaviour is unchanged: the agent runs the schedulers
**and** the HTTP server (and then a bearer token is required). A listener-less
agent with no cadence and no `scan_interval` has nothing to do and refuses to
start, rather than idling silently.

## Database path precedence

Expand Down
Loading
Loading