From 30f42a60100fe9124fa4015430092c442eb59b6c Mon Sep 17 00:00:00 2001 From: R Max Espinoza Date: Wed, 4 Feb 2026 22:27:57 +0100 Subject: [PATCH] feat(daemon): add configuration loader --- API_SPEC.md | 8 ++- DEPLOYMENT.md | 9 +++ LEARNING.md | 12 ++++ NEXT_STEPS.md | 11 ++- PHASE_LEDGER.md | 1 + PROGRESS.md | 5 +- TASKS.md | 2 +- cmd/ratelord-d/config.go | 142 +++++++++++++++++++++++++++++++++++++++ cmd/ratelord-d/main.go | 58 ++++++++++------ pkg/api/server.go | 24 +++++-- 10 files changed, 235 insertions(+), 37 deletions(-) create mode 100644 cmd/ratelord-d/config.go diff --git a/API_SPEC.md b/API_SPEC.md index aec155d..373eb8e 100644 --- a/API_SPEC.md +++ b/API_SPEC.md @@ -14,7 +14,13 @@ The daemon exposes a **JSON-over-HTTP** interface on `localhost`. ### 1.2 Configuration * **Default Port**: `8090` (configurable via env `RATELORD_PORT`) -* **Bind Address**: `127.0.0.1` +* **Bind Address**: `127.0.0.1` (override via `RATELORD_LISTEN_ADDR`) +* **Database Path**: `./ratelord.db` (override via `RATELORD_DB_PATH`) +* **Policy Path**: `./policy.json` (override via `RATELORD_POLICY_PATH`) +* **Poll Interval**: `10s` (override via `RATELORD_POLL_INTERVAL`) +* **Web Assets**: + * `RATELORD_WEB_MODE=embedded|dir|off` (default: `embedded`) + * `RATELORD_WEB_DIR` (required when `web_mode=dir`) * **Content-Type**: `application/json` required for all POST bodies. --- diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index df39607..8996e59 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -200,6 +200,15 @@ If you run multiple replicas of `ratelord` (e.g., 3 sidecars for 3 app replicas) ## 6. Configuration & Secrets Management +### Runtime Configuration +- `RATELORD_DB_PATH`: SQLite database path (default `./ratelord.db`). +- `RATELORD_POLICY_PATH`: Policy file path (default `./policy.json`). +- `RATELORD_PORT`: Override daemon port (default `8090`, sets bind to `127.0.0.1`). +- `RATELORD_LISTEN_ADDR`: Full bind address (default `127.0.0.1:8090`). +- `RATELORD_POLL_INTERVAL`: Provider poll interval (default `10s`). +- `RATELORD_WEB_MODE`: `embedded`, `dir`, or `off` (default `embedded`). +- `RATELORD_WEB_DIR`: Directory for web assets when `RATELORD_WEB_MODE=dir`. + ### Policy (`policy.json`) - Treat as code. Version control it. - **Updates**: diff --git a/LEARNING.md b/LEARNING.md index ad2591d..1da1eb3 100644 --- a/LEARNING.md +++ b/LEARNING.md @@ -27,3 +27,15 @@ The "Required Document Set" is complete with 0 implementation code written, pres ### Challenges Encountered 1. **Context Continuity**: Deciding between `--continue` (stateful) and fresh runs (stateless). Fresh runs are safer for disk-sourced truth, but `--continue` can reduce repeated "orientation" overhead. We opted for NO `--continue` in the improved loop to allow the orchestrator to build on its internal reasoning. + +## 2026-01-27: Configuration Implementation + +### What Worked Well +1. **Env-first defaults**: Deriving flag defaults from environment variables kept runtime config flexible while preserving sane defaults. +2. **Centralized normalization**: A single validation/normalization pass reduced drift between CLI, env, and runtime usage. + +### Challenges Encountered +1. **Toolchain availability**: Go tooling was unavailable in the environment, so formatting and test runs required careful reporting. + +### Improvements for Implementation Phase +1. **Bootstrap check**: Add a quick bootstrap check for required toolchains before implementing code changes to reduce rework. diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 93ca1c2..a719a16 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -5,16 +5,13 @@ - **Core Features**: Daemon authority, Event sourcing, Policy Engine, Forecasting, TUI, and initial Mock Provider are live. - **Phase 7 Complete**: Real providers (GitHub, OpenAI) implemented. Dogfooding environment validated forecast accuracy. - **Documentation**: Deployment guide (`DEPLOYMENT.md`) is now available. `CLIENT_SDK_SPEC.md` has been drafted. +- **M1.4 Complete**: Daemon configuration loader now supports env/flag overrides for storage, policy, polling, bind address, and web assets. ## Immediate Actions - 1. **Phase 8: Operations & Expansion**: - - **Epic 18: Web UI**: - - [x] **M18.1-M18.4: Web UI V1**: Dashboard, Scaffolding, and Embedding are live. - - [x] **M18.5: History View**: Deep dive into event log history with filters. - - [x] **M18.6: Identity Explorer**: Detailed view of registered identities and their stats. - - *Note: Epic 18 (Web UI) is now complete.* + 1. **Re-assess Backlog**: + - Confirm if any new milestones are required beyond M1.4. + - Capture new work in `TASKS.md` before starting the next iteration. ## Reference diff --git a/PHASE_LEDGER.md b/PHASE_LEDGER.md index f553881..aca45d7 100644 --- a/PHASE_LEDGER.md +++ b/PHASE_LEDGER.md @@ -42,3 +42,4 @@ - **Action**: Implemented Web UI Dashboard View: Created API client (`web/src/lib/api.ts`), AppShell layout (`web/src/layouts/AppShell.tsx`), Dashboard page (`web/src/pages/Dashboard.tsx`), and updated App.tsx for routing. Built successfully with TypeScript and Vite. - [x] **M18.4: Build Integration** (Epic 18) - Integrated Web UI into daemon using `//go:embed`. Updated Makefile to build web assets and embed them into `cmd/ratelord-d`. Added `--web-dir` flag to serve dev builds or embedded assets by default. - [x] **Epic 18: Web UI Implementation** - Status: Completed. Implemented Dashboard, History, and Identity Explorer. React + Vite + Tailwind stack embedded in Go binary. +- [x] **M1.4: Configuration** (Epic 1) - Added daemon configuration loader with env/flag overrides for storage, policy, polling, listen address, and web assets. diff --git a/PROGRESS.md b/PROGRESS.md index 15483e5..ac1249e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -27,7 +27,7 @@ | pkg/store/types.go | COMMITTED | Agent | 2026-01-25 | | pkg/store/sqlite.go | COMMITTED | Agent | 2026-01-25 | | pkg/store/store_test.go | COMMITTED | Agent | 2026-01-25 | -| pkg/api/server.go | COMMITTED | Agent | 2026-01-25 | +| pkg/api/server.go | UPDATED | Agent | 2026-01-27 | | pkg/engine/projection.go | COMMITTED | Agent | 2026-01-25 | | pkg/engine/usage.go | COMMITTED | Agent | 2026-01-25 | | pkg/engine/policy.go | UPDATED | Agent | 2026-01-25 | @@ -41,7 +41,8 @@ | pkg/engine/poller.go | UPDATED | Agent | 2026-01-25 | | pkg/engine/config.go | COMMITTED | Agent | 2026-01-25 | | pkg/engine/loader.go | COMMITTED | Agent | 2026-01-25 | -| cmd/ratelord-d/main.go | UPDATED | Agent | 2026-01-25 | +| cmd/ratelord-d/main.go | UPDATED | Agent | 2026-01-27 | +| cmd/ratelord-d/config.go | CREATED | Agent | 2026-01-27 | | cmd/ratelord-tui/main.go | CREATED | Agent | 2026-01-25 | | cmd/ratelord-sim/main.go | CREATED | Agent | 2026-01-25 | | Epic 7: Forecasting | COMPLETED | Agent | 2026-01-25 | diff --git a/TASKS.md b/TASKS.md index 2c3da84..58d2b15 100644 --- a/TASKS.md +++ b/TASKS.md @@ -37,7 +37,7 @@ Focus: Getting the process to run, manage its lifecycle, and handle signals corr - [x] **M1.3: Logging & Observability** - Setup structured logging (stdout/stderr). - Emit `system_started` log on boot. -- [ ] **M1.4: Configuration** +- [x] **M1.4: Configuration** - Implement configuration loader (env vars, defaults). - *Note*: Split from M1.1 to ensure atomic commits. diff --git a/cmd/ratelord-d/config.go b/cmd/ratelord-d/config.go new file mode 100644 index 0000000..6f4f0cf --- /dev/null +++ b/cmd/ratelord-d/config.go @@ -0,0 +1,142 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + defaultPollInterval = 10 * time.Second + defaultListenAddr = "127.0.0.1:8090" + defaultWebMode = "embedded" +) + +type Config struct { + DBPath string + PolicyPath string + ListenAddr string + PollInterval time.Duration + WebMode string + WebDir string +} + +func LoadConfig() (Config, error) { + cwd, err := os.Getwd() + if err != nil { + return Config{}, fmt.Errorf("failed to get cwd: %w", err) + } + + defaultDBPath := filepath.Join(cwd, "ratelord.db") + defaultPolicyPath := filepath.Join(cwd, "policy.json") + + dbDefault := envOrDefault("RATELORD_DB_PATH", defaultDBPath) + policyDefault := envOrDefault("RATELORD_POLICY_PATH", defaultPolicyPath) + listenDefault := defaultListenAddr + if portOverride := envOrDefault("RATELORD_PORT", ""); portOverride != "" { + listenDefault = net.JoinHostPort("127.0.0.1", portOverride) + } + listenDefault = envOrDefault("RATELORD_LISTEN_ADDR", listenDefault) + webModeDefault := envOrDefault("RATELORD_WEB_MODE", defaultWebMode) + webDirDefault := envOrDefault("RATELORD_WEB_DIR", "") + + pollDefault, err := envDuration("RATELORD_POLL_INTERVAL", defaultPollInterval) + if err != nil { + return Config{}, err + } + + dbPath := flag.String("db-path", dbDefault, "Path to the SQLite database") + policyPath := flag.String("policy-path", policyDefault, "Path to the policy JSON file") + listenAddr := flag.String("listen-addr", listenDefault, "HTTP listen address") + pollInterval := flag.Duration("poll-interval", pollDefault, "Provider poll interval (e.g. 10s)") + webMode := flag.String("web-mode", webModeDefault, "Web assets mode: embedded, dir, off") + webDir := flag.String("web-dir", webDirDefault, "Web assets directory when web-mode=dir") + + flag.Parse() + + cfg := Config{ + DBPath: strings.TrimSpace(*dbPath), + PolicyPath: strings.TrimSpace(*policyPath), + ListenAddr: strings.TrimSpace(*listenAddr), + PollInterval: *pollInterval, + WebMode: strings.TrimSpace(*webMode), + WebDir: strings.TrimSpace(*webDir), + } + + return normalizeConfig(cfg) +} + +func normalizeConfig(cfg Config) (Config, error) { + var err error + if cfg.DBPath == "" { + return Config{}, fmt.Errorf("db path is required") + } + if cfg.PolicyPath == "" { + return Config{}, fmt.Errorf("policy path is required") + } + if cfg.ListenAddr == "" { + return Config{}, fmt.Errorf("listen address is required") + } + if cfg.PollInterval <= 0 { + return Config{}, fmt.Errorf("poll interval must be positive") + } + + cfg.DBPath, err = filepath.Abs(cfg.DBPath) + if err != nil { + return Config{}, fmt.Errorf("resolve db path: %w", err) + } + cfg.PolicyPath, err = filepath.Abs(cfg.PolicyPath) + if err != nil { + return Config{}, fmt.Errorf("resolve policy path: %w", err) + } + + cfg.WebMode = strings.ToLower(cfg.WebMode) + switch cfg.WebMode { + case "embedded", "dir", "off": + default: + return Config{}, fmt.Errorf("invalid web mode %q (expected embedded, dir, or off)", cfg.WebMode) + } + + if cfg.WebMode == "dir" { + if cfg.WebDir == "" { + return Config{}, fmt.Errorf("web dir is required when web mode is dir") + } + cfg.WebDir, err = filepath.Abs(cfg.WebDir) + if err != nil { + return Config{}, fmt.Errorf("resolve web dir: %w", err) + } + if info, err := os.Stat(cfg.WebDir); err != nil { + return Config{}, fmt.Errorf("stat web dir: %w", err) + } else if !info.IsDir() { + return Config{}, fmt.Errorf("web dir is not a directory: %s", cfg.WebDir) + } + } + + return cfg, nil +} + +func envOrDefault(key, fallback string) string { + if value, ok := os.LookupEnv(key); ok { + return strings.TrimSpace(value) + } + return fallback +} + +func envDuration(key string, fallback time.Duration) (time.Duration, error) { + if value, ok := os.LookupEnv(key); ok { + value = strings.TrimSpace(value) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("invalid %s duration: %w", key, err) + } + return parsed, nil + } + return fallback, nil +} diff --git a/cmd/ratelord-d/main.go b/cmd/ratelord-d/main.go index 800142e..1a15f4c 100644 --- a/cmd/ratelord-d/main.go +++ b/cmd/ratelord-d/main.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/signal" - "path/filepath" "syscall" "time" @@ -29,21 +28,27 @@ func main() { // M1.3: Emit system_started log on boot (structured) fmt.Println(`{"level":"info","msg":"system_started","component":"ratelord-d"}`) - // Configuration (M1.4 placeholder: hardcoded DB path for now) - cwd, err := os.Getwd() + cfg, err := LoadConfig() if err != nil { - panic(fmt.Sprintf("failed to get cwd: %v", err)) + fmt.Printf(`{"level":"fatal","msg":"config_load_failed","error":"%v"}`+"\n", err) + os.Exit(1) } - dbPath := filepath.Join(cwd, "ratelord.db") - policyPath := filepath.Join(cwd, "policy.json") + fmt.Printf( + `{"level":"info","msg":"config_loaded","db_path":"%s","policy_path":"%s","listen_addr":"%s","poll_interval":"%s","web_mode":"%s"}`+"\n", + cfg.DBPath, + cfg.PolicyPath, + cfg.ListenAddr, + cfg.PollInterval, + cfg.WebMode, + ) // M2.1: Initialize SQLite Store - st, err := store.NewStore(dbPath) + st, err := store.NewStore(cfg.DBPath) if err != nil { fmt.Printf(`{"level":"fatal","msg":"failed_to_init_store","error":"%v"}`+"\n", err) os.Exit(1) } - fmt.Printf(`{"level":"info","msg":"store_initialized","path":"%s"}`+"\n", dbPath) + fmt.Printf(`{"level":"info","msg":"store_initialized","path":"%s"}`+"\n", cfg.DBPath) // M4.2: Initialize Identity Projection identityProj := engine.NewIdentityProjection() @@ -94,10 +99,10 @@ func main() { // M9.3: Initial Policy Load var policyCfg *engine.PolicyConfig - if cfg, err := engine.LoadPolicyConfig(policyPath); err == nil { - policyCfg = cfg - policyEngine.UpdatePolicies(cfg) - fmt.Printf(`{"level":"info","msg":"policy_loaded","path":"%s","policies_count":%d}`+"\n", policyPath, len(cfg.Policies)) + if policyConfig, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil { + policyCfg = policyConfig + policyEngine.UpdatePolicies(policyConfig) + fmt.Printf(`{"level":"info","msg":"policy_loaded","path":"%s","policies_count":%d}`+"\n", cfg.PolicyPath, len(policyConfig.Policies)) } else if !os.IsNotExist(err) { // Log error if file exists but failed to load; ignore if missing (default mode) fmt.Printf(`{"level":"error","msg":"failed_to_load_policy","error":"%v"}`+"\n", err) @@ -105,7 +110,7 @@ func main() { // M6.3: Initialize Polling Orchestrator // Use the new Poller to drive the provider loop - poller := engine.NewPoller(st, 10*time.Second, forecaster) // Poll every 10s for demo + poller := engine.NewPoller(st, cfg.PollInterval, forecaster) // Register the mock provider (M6.2) // IMPORTANT: For the demo, we assume the mock provider is available in the 'pkg/provider' package via a factory or similar, // but currently it resides in 'pkg/provider/mock.go' which is in package 'provider'. @@ -154,14 +159,23 @@ func main() { // M3.1: Start HTTP Server (in background) // Use NewServerWithPoller to enable debug endpoints srv := api.NewServerWithPoller(st, identityProj, usageProj, policyEngine, poller) + srv.SetAddr(cfg.ListenAddr) // Load and set web assets - webAssets, err := web.Assets() - if err != nil { - fmt.Printf(`{"level":"error","msg":"failed_to_load_web_assets","error":"%v"}`+"\n", err) - } else { - srv.SetStaticFS(webAssets) - fmt.Println(`{"level":"info","msg":"web_assets_loaded"}`) + switch cfg.WebMode { + case "off": + fmt.Println(`{"level":"info","msg":"web_assets_disabled"}`) + case "dir": + srv.SetStaticFS(os.DirFS(cfg.WebDir)) + fmt.Printf(`{"level":"info","msg":"web_assets_loaded","mode":"dir","path":"%s"}`+"\n", cfg.WebDir) + default: + webAssets, err := web.Assets() + if err != nil { + fmt.Printf(`{"level":"error","msg":"failed_to_load_web_assets","error":"%v"}`+"\n", err) + } else { + srv.SetStaticFS(webAssets) + fmt.Printf(`{"level":"info","msg":"web_assets_loaded","mode":"embedded"}` + "\n") + } } go func() { @@ -181,9 +195,9 @@ func main() { sig := <-sigs if sig == syscall.SIGHUP { fmt.Println(`{"level":"info","msg":"reload_signal_received"}`) - if cfg, err := engine.LoadPolicyConfig(policyPath); err == nil { - policyEngine.UpdatePolicies(cfg) - fmt.Printf(`{"level":"info","msg":"policy_reloaded","policies_count":%d}`+"\n", len(cfg.Policies)) + if policyConfig, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil { + policyEngine.UpdatePolicies(policyConfig) + fmt.Printf(`{"level":"info","msg":"policy_reloaded","policies_count":%d}`+"\n", len(policyConfig.Policies)) } else { fmt.Printf(`{"level":"error","msg":"failed_to_reload_policy","error":"%v"}`+"\n", err) } diff --git a/pkg/api/server.go b/pkg/api/server.go index 53ecac7..4874379 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -27,6 +27,8 @@ type Server struct { policy *engine.PolicyEngine poller *engine.Poller staticFS fs.FS + mux *http.ServeMux + staticOn bool } // NewServer creates a new API server instance @@ -47,6 +49,7 @@ func NewServerWithPoller(st *store.Store, identities *engine.IdentityProjection, usage: usage, policy: policy, poller: poller, + mux: mux, } mux.HandleFunc("/v1/intent", s.handleIntent) @@ -58,10 +61,7 @@ func NewServerWithPoller(st *store.Store, identities *engine.IdentityProjection, mux.HandleFunc("/debug/provider/inject", s.handleDebugInject) } - // Static file handler (catch-all for SPA) - if s.staticFS != nil { - mux.Handle("/", s.handleStatic()) - } + s.registerStaticHandler() // Middleware: Logging & Panic Recovery handler := withLogging(withRecovery(mux)) @@ -80,6 +80,22 @@ func NewServerWithPoller(st *store.Store, identities *engine.IdentityProjection, // SetStaticFS sets the filesystem for serving static web assets func (s *Server) SetStaticFS(fs fs.FS) { s.staticFS = fs + s.registerStaticHandler() +} + +// SetAddr updates the server listen address. +func (s *Server) SetAddr(addr string) { + if addr != "" { + s.server.Addr = addr + } +} + +func (s *Server) registerStaticHandler() { + if s.mux == nil || s.staticFS == nil || s.staticOn { + return + } + s.mux.Handle("/", s.handleStatic()) + s.staticOn = true } // Start runs the HTTP server (blocking)