diff --git a/agent/agent.go b/agent/agent.go index afc6d8c..2c08802 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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 @@ -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 == "") { diff --git a/agent/agent_test.go b/agent/agent_test.go index 9606f21..9f36fc0 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -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"}, @@ -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 diff --git a/agent/serve.go b/agent/serve.go index 8d13d34..544336c 100644 --- a/agent/serve.go +++ b/agent/serve.go @@ -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 diff --git a/cmd/squirrel/agent.go b/cmd/squirrel/agent.go index f8b224f..e0e6130 100644 --- a/cmd/squirrel/agent.go +++ b/cmd/squirrel/agent.go @@ -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 @@ -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 diff --git a/cmd/squirrel/agent_listenerless_test.go b/cmd/squirrel/agent_listenerless_test.go new file mode 100644 index 0000000..8629c1e --- /dev/null +++ b/cmd/squirrel/agent_listenerless_test.go @@ -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) + } +} diff --git a/config/agent.go b/config/agent.go index 16d02ef..559faa0 100644 --- a/config/agent.go +++ b/config/agent.go @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/config/config_test.go b/config/config_test.go index 44d247f..a0b4f9f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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) + } } } diff --git a/design/friction-log.md b/design/friction-log.md index 496c68f..2c5a9df 100644 --- a/design/friction-log.md +++ b/design/friction-log.md @@ -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 diff --git a/design/reference-setup.md b/design/reference-setup.md index 5a06d82..993340f 100644 --- a/design/reference-setup.md +++ b/design/reference-setup.md @@ -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" diff --git a/docs/src/content/docs/guides/agent.md b/docs/src/content/docs/guides/agent.md index 87cecd4..594cf78 100644 --- a/docs/src/content/docs/guides/agent.md +++ b/docs/src/content/docs/guides/agent.md @@ -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 diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 5ddf661..3fec920 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -136,3 +136,9 @@ Required to run [`squirrel agent`](/squirrel/guides/agent/). Configures the unattended cadence (`index_every`, `sync_every`), scheduled audits, and an optional agent-specific `db` path (which takes precedence over the top-level `db` when the agent runs). + +`listen` is optional. When set (e.g. `0.0.0.0:8443`), the agent binds an HTTP +server for peer syncs and the health endpoint, and a bearer token is required. +When omitted, the agent runs its schedulers only — the *listener-less* mode for +cadence-only machines that never receive peer syncs; `[agent.auth]` is then +optional. See [The agent](/squirrel/guides/agent/#listener-less-cadence-only-machines).