diff --git a/API_SPEC.md b/API_SPEC.md index aec155d..2c4d1c6 100644 --- a/API_SPEC.md +++ b/API_SPEC.md @@ -13,8 +13,8 @@ The daemon exposes a **JSON-over-HTTP** interface on `localhost`. * **Local-First**: Bound strictly to `127.0.0.1` (or a Unix Domain Socket in future strict modes). ### 1.2 Configuration -* **Default Port**: `8090` (configurable via env `RATELORD_PORT`) -* **Bind Address**: `127.0.0.1` +* **Default Address**: `127.0.0.1:8090` (configurable via env `RATELORD_ADDR` or CLI `--addr`) +* **Port Override**: `RATELORD_PORT` (legacy; used when `RATELORD_ADDR` is unset) * **Content-Type**: `application/json` required for all POST bodies. --- diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index df39607..755c11c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -11,7 +11,8 @@ This guide covers deployment via Systemd, Docker, and Kubernetes. - **Configuration**: - `policy.json`: Defines pools, windows, and burst limits. - Environment Variables: Store sensitive tokens (e.g., `GITHUB_TOKEN`, `OPENAI_API_KEY`). -- **Network**: Exposes an HTTP API on port `8090`. + - Daemon settings: `RATELORD_DB_PATH`, `RATELORD_POLICY_PATH` (or `RATELORD_CONFIG_PATH`), `RATELORD_ADDR`, `RATELORD_POLL_INTERVAL`, `RATELORD_WEB_ASSETS_MODE`, `RATELORD_WEB_DIR`. +- **Network**: Exposes an HTTP API on port `8090` (default bind `127.0.0.1:8090`). - **Signals**: - `SIGINT` / `SIGTERM`: Graceful shutdown (checkpoints state). - `SIGHUP`: Hot-reload `policy.json`. @@ -57,7 +58,7 @@ Type=simple User=ratelord Group=ratelord # Adjust path to binary -ExecStart=/usr/local/bin/ratelord-d --config /etc/ratelord/policy.json --db /var/lib/ratelord/ratelord.db --addr 127.0.0.1:8090 +ExecStart=/usr/local/bin/ratelord-d --policy /etc/ratelord/policy.json --db /var/lib/ratelord/ratelord.db --addr 127.0.0.1:8090 ExecReload=/bin/kill -HUP $MAINPID KillMode=process Restart=on-failure @@ -107,7 +108,7 @@ COPY policy.json /etc/ratelord/policy.json # State volume VOLUME /data ENV RATELORD_DB_PATH=/data/ratelord.db -ENV RATELORD_CONFIG_PATH=/etc/ratelord/policy.json +ENV RATELORD_POLICY_PATH=/etc/ratelord/policy.json ENV RATELORD_ADDR=0.0.0.0:8090 CMD ["ratelord-d"] @@ -168,7 +169,7 @@ spec: args: - "--addr=127.0.0.1:8090" - "--db=/data/ratelord.db" - - "--config=/etc/config/policy.json" + - "--policy=/etc/config/policy.json" volumeMounts: - name: ratelord-data mountPath: /data diff --git a/LEARNING.md b/LEARNING.md index ad2591d..368d934 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. **Spec-to-implementation alignment**: Updating deployment and API docs before code changes kept configuration expectations consistent with actual runtime behavior. +2. **Explicit defaults**: Centralizing defaults for DB path, policy path, and poll interval simplified daemon startup and made overrides easy to reason about. + +### Challenges Encountered +1. **Legacy configuration naming**: Existing docs referenced `RATELORD_CONFIG_PATH`, requiring backward-compatible support while introducing a clearer `RATELORD_POLICY_PATH`. + +### Improvements for Implementation Phase +1. **Config validation surface**: Add a dedicated validation pass (with better error messaging) as more config knobs accumulate. diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 93ca1c2..c59e26a 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -2,19 +2,13 @@ ## Current Context - **v1.0.0 Released**: The system has been tagged and released. -- **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. +- **Core Features**: Daemon authority, Event sourcing, Policy Engine, Forecasting, TUI, Web UI, and real providers are live. +- **Configuration Implemented**: Daemon config now supports env/flag overrides for DB path, policy path, poll interval, listen address, and web assets mode. +- **Documentation**: Deployment guide (`DEPLOYMENT.md`) and client SDK spec (`CLIENT_SDK_SPEC.md`) are available. ## 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. **Define the next milestone**: All tasks in `TASKS.md` are complete; queue the next phase or open issues for post-1.0 improvements. ## Reference diff --git a/PHASE_LEDGER.md b/PHASE_LEDGER.md index f553881..6b26540 100644 --- a/PHASE_LEDGER.md +++ b/PHASE_LEDGER.md @@ -42,3 +42,6 @@ - **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. + +## 2026-01-27 +- [x] **M1.4: Configuration** (Epic 1) - Added daemon config loader with env/flag overrides for DB path, policy path, poll interval, listen address, and web assets mode. Updated deployment and API docs plus tracking files. diff --git a/PROGRESS.md b/PROGRESS.md index 15483e5..3c5255b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 | @@ -55,6 +56,7 @@ | M11.2: Implement client-side Wait/Modify logic | COMPLETED | Feature | 2026-01-25 | | M12.1: Persist Provider State | COMPLETED | Fix | 2026-01-26 | | M12.2: TUI Verification | COMPLETED | Terminal | 2026-01-26 | +| M1.4: Configuration | COMPLETED | Agent | 2026-01-27 | | Epic 14: GitHub Provider | COMPLETED | Agent | 2026-01-26 | | Epic 15: OpenAI Provider | COMPLETED | Agent | 2026-01-26 | | pkg/client/types.go | CREATED | Implement | 2026-01-26 | 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..2c8799c --- /dev/null +++ b/cmd/ratelord-d/config.go @@ -0,0 +1,154 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + defaultAddr = "127.0.0.1:8090" + defaultPollInterval = 10 * time.Second + defaultWebAssetsMode = "embedded" +) + +type Config struct { + DBPath string + PolicyPath string + Addr string + PollInterval time.Duration + WebAssetsMode string + WebDir string +} + +func LoadConfig(args []string) (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") + + dbPath := envOrDefault("RATELORD_DB_PATH", defaultDBPath) + policyPath := envOrDefaultWithFallback([]string{"RATELORD_POLICY_PATH", "RATELORD_CONFIG_PATH"}, defaultPolicyPath) + addr := addrFromEnv(defaultAddr) + pollInterval := defaultPollInterval + if pollIntervalEnv := os.Getenv("RATELORD_POLL_INTERVAL"); pollIntervalEnv != "" { + parsed, err := time.ParseDuration(pollIntervalEnv) + if err != nil { + return Config{}, fmt.Errorf("invalid RATELORD_POLL_INTERVAL: %w", err) + } + pollInterval = parsed + } + webAssetsMode := envOrDefault("RATELORD_WEB_ASSETS_MODE", defaultWebAssetsMode) + webDir := os.Getenv("RATELORD_WEB_DIR") + + flagSet := flag.NewFlagSet("ratelord-d", flag.ContinueOnError) + flagSet.SetOutput(io.Discard) + flagDB := flagSet.String("db", dbPath, "path to SQLite database") + flagPolicy := flagSet.String("policy", policyPath, "path to policy JSON") + flagAddr := flagSet.String("addr", addr, "HTTP listen address") + flagPollInterval := flagSet.String("poll-interval", pollInterval.String(), "provider poll interval") + flagWebAssets := flagSet.String("web-assets", webAssetsMode, "web assets mode: embedded|fs|off") + flagWebDir := flagSet.String("web-dir", webDir, "web assets directory when web-assets=fs") + + if err := flagSet.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flagSet.SetOutput(os.Stdout) + flagSet.PrintDefaults() + return Config{}, err + } + return Config{}, err + } + + pollIntervalParsed, err := time.ParseDuration(*flagPollInterval) + if err != nil { + return Config{}, fmt.Errorf("invalid poll interval: %w", err) + } + + resolvedDBPath := resolvePath(*flagDB, cwd) + resolvedPolicyPath := resolvePath(*flagPolicy, cwd) + mode := normalizeWebAssetsMode(*flagWebAssets) + + config := Config{ + DBPath: resolvedDBPath, + PolicyPath: resolvedPolicyPath, + Addr: strings.TrimSpace(*flagAddr), + PollInterval: pollIntervalParsed, + WebAssetsMode: mode, + WebDir: strings.TrimSpace(*flagWebDir), + } + + if config.Addr == "" { + return Config{}, errors.New("addr cannot be empty") + } + + if config.WebAssetsMode == "fs" { + if config.WebDir == "" { + return Config{}, errors.New("web-assets=fs requires web-dir") + } + config.WebDir = resolvePath(config.WebDir, cwd) + } + + if config.WebAssetsMode != "embedded" && config.WebAssetsMode != "fs" && config.WebAssetsMode != "off" { + return Config{}, fmt.Errorf("unsupported web-assets mode: %s", config.WebAssetsMode) + } + + return config, nil +} + +func envOrDefault(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func envOrDefaultWithFallback(keys []string, fallback string) string { + for _, key := range keys { + if value := os.Getenv(key); value != "" { + return value + } + } + return fallback +} + +func addrFromEnv(fallback string) string { + if value := os.Getenv("RATELORD_ADDR"); value != "" { + return value + } + if port := os.Getenv("RATELORD_PORT"); port != "" { + return fmt.Sprintf("127.0.0.1:%s", port) + } + return fallback +} + +func resolvePath(path string, cwd string) string { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return trimmed + } + if filepath.IsAbs(trimmed) { + return trimmed + } + return filepath.Join(cwd, trimmed) +} + +func normalizeWebAssetsMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "embedded": + return "embedded" + case "fs", "dir", "directory": + return "fs" + case "off", "disabled", "none": + return "off" + default: + return strings.ToLower(strings.TrimSpace(mode)) + } +} diff --git a/cmd/ratelord-d/config_test.go b/cmd/ratelord-d/config_test.go new file mode 100644 index 0000000..447146d --- /dev/null +++ b/cmd/ratelord-d/config_test.go @@ -0,0 +1,567 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadConfig_Defaults(t *testing.T) { + // Setup: Create temp dir and cd into it + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + config, err := LoadConfig([]string{}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // Verify defaults + if config.Addr != defaultAddr { + t.Errorf("expected default addr %s, got %s", defaultAddr, config.Addr) + } + if config.PollInterval != defaultPollInterval { + t.Errorf("expected default poll interval %v, got %v", defaultPollInterval, config.PollInterval) + } + if config.WebAssetsMode != defaultWebAssetsMode { + t.Errorf("expected default web assets mode %s, got %s", defaultWebAssetsMode, config.WebAssetsMode) + } + if !strings.HasSuffix(config.DBPath, "ratelord.db") { + t.Errorf("expected DBPath to end with ratelord.db, got %s", config.DBPath) + } + if !strings.HasSuffix(config.PolicyPath, "policy.json") { + t.Errorf("expected PolicyPath to end with policy.json, got %s", config.PolicyPath) + } +} + +func TestLoadConfig_EnvVars(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + // Set env vars + os.Setenv("RATELORD_DB_PATH", "/custom/db.db") + os.Setenv("RATELORD_POLICY_PATH", "/custom/policy.json") + os.Setenv("RATELORD_ADDR", "0.0.0.0:9000") + os.Setenv("RATELORD_POLL_INTERVAL", "30s") + os.Setenv("RATELORD_WEB_ASSETS_MODE", "off") + defer func() { + os.Unsetenv("RATELORD_DB_PATH") + os.Unsetenv("RATELORD_POLICY_PATH") + os.Unsetenv("RATELORD_ADDR") + os.Unsetenv("RATELORD_POLL_INTERVAL") + os.Unsetenv("RATELORD_WEB_ASSETS_MODE") + }() + + config, err := LoadConfig([]string{}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.DBPath != "/custom/db.db" { + t.Errorf("expected DBPath from env /custom/db.db, got %s", config.DBPath) + } + if config.PolicyPath != "/custom/policy.json" { + t.Errorf("expected PolicyPath from env /custom/policy.json, got %s", config.PolicyPath) + } + if config.Addr != "0.0.0.0:9000" { + t.Errorf("expected Addr from env 0.0.0.0:9000, got %s", config.Addr) + } + if config.PollInterval != 30*time.Second { + t.Errorf("expected PollInterval from env 30s, got %v", config.PollInterval) + } + if config.WebAssetsMode != "off" { + t.Errorf("expected WebAssetsMode from env off, got %s", config.WebAssetsMode) + } +} + +func TestLoadConfig_FlagOverridesEnv(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + // Set env vars + os.Setenv("RATELORD_ADDR", "env-addr:8080") + os.Setenv("RATELORD_POLL_INTERVAL", "15s") + defer func() { + os.Unsetenv("RATELORD_ADDR") + os.Unsetenv("RATELORD_POLL_INTERVAL") + }() + + // Flags should override env + config, err := LoadConfig([]string{ + "-addr", "flag-addr:9090", + "-poll-interval", "25s", + }) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.Addr != "flag-addr:9090" { + t.Errorf("expected flag to override env for addr, got %s", config.Addr) + } + if config.PollInterval != 25*time.Second { + t.Errorf("expected flag to override env for poll-interval, got %v", config.PollInterval) + } +} + +func TestLoadConfig_InvalidPollInterval_Env(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + os.Setenv("RATELORD_POLL_INTERVAL", "invalid") + defer os.Unsetenv("RATELORD_POLL_INTERVAL") + + _, err = LoadConfig([]string{}) + if err == nil { + t.Fatal("expected error for invalid RATELORD_POLL_INTERVAL, got nil") + } + if !strings.Contains(err.Error(), "invalid RATELORD_POLL_INTERVAL") { + t.Errorf("expected error message to contain 'invalid RATELORD_POLL_INTERVAL', got: %v", err) + } +} + +func TestLoadConfig_InvalidPollInterval_Flag(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + _, err = LoadConfig([]string{"-poll-interval", "not-a-duration"}) + if err == nil { + t.Fatal("expected error for invalid poll-interval flag, got nil") + } + if !strings.Contains(err.Error(), "invalid poll interval") { + t.Errorf("expected error message to contain 'invalid poll interval', got: %v", err) + } +} + +func TestLoadConfig_EmptyAddr(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + _, err = LoadConfig([]string{"-addr", ""}) + if err == nil { + t.Fatal("expected error for empty addr, got nil") + } + if !strings.Contains(err.Error(), "addr cannot be empty") { + t.Errorf("expected error about empty addr, got: %v", err) + } +} + +func TestLoadConfig_EmptyAddr_Whitespace(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + _, err = LoadConfig([]string{"-addr", " "}) + if err == nil { + t.Fatal("expected error for whitespace-only addr, got nil") + } + if !strings.Contains(err.Error(), "addr cannot be empty") { + t.Errorf("expected error about empty addr, got: %v", err) + } +} + +func TestLoadConfig_WebAssets_FS_MissingWebDir(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + _, err = LoadConfig([]string{"-web-assets", "fs"}) + if err == nil { + t.Fatal("expected error when web-assets=fs but web-dir is missing, got nil") + } + if !strings.Contains(err.Error(), "web-assets=fs requires web-dir") { + t.Errorf("expected error about missing web-dir, got: %v", err) + } +} + +func TestLoadConfig_WebAssets_FS_WithWebDir(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + webDir := filepath.Join(tmpDir, "web") + config, err := LoadConfig([]string{"-web-assets", "fs", "-web-dir", webDir}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.WebAssetsMode != "fs" { + t.Errorf("expected WebAssetsMode fs, got %s", config.WebAssetsMode) + } + if config.WebDir != webDir { + t.Errorf("expected WebDir %s, got %s", webDir, config.WebDir) + } +} + +func TestLoadConfig_InvalidWebAssetsMode(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + _, err = LoadConfig([]string{"-web-assets", "invalid-mode"}) + if err == nil { + t.Fatal("expected error for invalid web-assets mode, got nil") + } + if !strings.Contains(err.Error(), "unsupported web-assets mode") { + t.Errorf("expected error about unsupported web-assets mode, got: %v", err) + } +} + +func TestLoadConfig_PathResolution_Relative(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + config, err := LoadConfig([]string{ + "-db", "data/ratelord.db", + "-policy", "config/policy.json", + }) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + expectedDB := filepath.Join(tmpDir, "data/ratelord.db") + expectedPolicy := filepath.Join(tmpDir, "config/policy.json") + + if config.DBPath != expectedDB { + t.Errorf("expected DBPath %s, got %s", expectedDB, config.DBPath) + } + if config.PolicyPath != expectedPolicy { + t.Errorf("expected PolicyPath %s, got %s", expectedPolicy, config.PolicyPath) + } +} + +func TestLoadConfig_PathResolution_Absolute(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + absDBPath := "/absolute/path/to/db.db" + absPolicyPath := "/absolute/path/to/policy.json" + + config, err := LoadConfig([]string{ + "-db", absDBPath, + "-policy", absPolicyPath, + }) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.DBPath != absDBPath { + t.Errorf("expected DBPath %s, got %s", absDBPath, config.DBPath) + } + if config.PolicyPath != absPolicyPath { + t.Errorf("expected PolicyPath %s, got %s", absPolicyPath, config.PolicyPath) + } +} + +func TestLoadConfig_EnvPortOnly(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + os.Setenv("RATELORD_PORT", "3000") + defer os.Unsetenv("RATELORD_PORT") + + config, err := LoadConfig([]string{}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + expected := "127.0.0.1:3000" + if config.Addr != expected { + t.Errorf("expected addr %s from RATELORD_PORT, got %s", expected, config.Addr) + } +} + +func TestLoadConfig_EnvAddrOverridesPort(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + os.Setenv("RATELORD_ADDR", "0.0.0.0:8080") + os.Setenv("RATELORD_PORT", "3000") + defer func() { + os.Unsetenv("RATELORD_ADDR") + os.Unsetenv("RATELORD_PORT") + }() + + config, err := LoadConfig([]string{}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + // RATELORD_ADDR should take precedence over RATELORD_PORT + if config.Addr != "0.0.0.0:8080" { + t.Errorf("expected RATELORD_ADDR to override RATELORD_PORT, got %s", config.Addr) + } +} + +func TestLoadConfig_PolicyPathFallback(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + // Test that RATELORD_CONFIG_PATH is used as fallback for RATELORD_POLICY_PATH + os.Setenv("RATELORD_CONFIG_PATH", "/fallback/config.json") + defer os.Unsetenv("RATELORD_CONFIG_PATH") + + config, err := LoadConfig([]string{}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.PolicyPath != "/fallback/config.json" { + t.Errorf("expected PolicyPath from RATELORD_CONFIG_PATH fallback, got %s", config.PolicyPath) + } +} + +func TestLoadConfig_PolicyPathPrimaryOverFallback(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + // Test that RATELORD_POLICY_PATH takes precedence over RATELORD_CONFIG_PATH + os.Setenv("RATELORD_POLICY_PATH", "/primary/policy.json") + os.Setenv("RATELORD_CONFIG_PATH", "/fallback/config.json") + defer func() { + os.Unsetenv("RATELORD_POLICY_PATH") + os.Unsetenv("RATELORD_CONFIG_PATH") + }() + + config, err := LoadConfig([]string{}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.PolicyPath != "/primary/policy.json" { + t.Errorf("expected PolicyPath from RATELORD_POLICY_PATH, got %s", config.PolicyPath) + } +} + +func TestNormalizeWebAssetsMode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", "embedded"}, + {"embedded", "embedded"}, + {"EMBEDDED", "embedded"}, + {" embedded ", "embedded"}, + {"fs", "fs"}, + {"FS", "fs"}, + {"dir", "fs"}, + {"directory", "fs"}, + {"off", "off"}, + {"OFF", "off"}, + {"disabled", "off"}, + {"none", "off"}, + {"unknown", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeWebAssetsMode(tt.input) + if result != tt.expected { + t.Errorf("normalizeWebAssetsMode(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestResolvePath(t *testing.T) { + cwd := "/test/cwd" + + tests := []struct { + name string + input string + expected string + }{ + {"empty", "", ""}, + {"whitespace", " ", ""}, + {"absolute", "/absolute/path", "/absolute/path"}, + {"relative", "relative/path", "/test/cwd/relative/path"}, + {"with_whitespace", " relative/path ", "/test/cwd/relative/path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolvePath(tt.input, cwd) + if result != tt.expected { + t.Errorf("resolvePath(%q, %q) = %q, want %q", tt.input, cwd, result, tt.expected) + } + }) + } +} + +func TestLoadConfig_WebDirResolution(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + // Test that web-dir gets resolved when web-assets=fs + config, err := LoadConfig([]string{ + "-web-assets", "fs", + "-web-dir", "relative/web", + }) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + expectedWebDir := filepath.Join(tmpDir, "relative/web") + if config.WebDir != expectedWebDir { + t.Errorf("expected WebDir to be resolved to %s, got %s", expectedWebDir, config.WebDir) + } +} + +func TestLoadConfig_WebAssets_Embedded(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + config, err := LoadConfig([]string{"-web-assets", "embedded"}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.WebAssetsMode != "embedded" { + t.Errorf("expected WebAssetsMode embedded, got %s", config.WebAssetsMode) + } + // WebDir can be empty for embedded mode +} + +func TestLoadConfig_WebAssets_Off(t *testing.T) { + tmpDir := t.TempDir() + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get working directory: %v", err) + } + defer os.Chdir(oldWd) + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory: %v", err) + } + + config, err := LoadConfig([]string{"-web-assets", "off"}) + if err != nil { + t.Fatalf("LoadConfig failed: %v", err) + } + + if config.WebAssetsMode != "off" { + t.Errorf("expected WebAssetsMode off, got %s", config.WebAssetsMode) + } +} diff --git a/cmd/ratelord-d/main.go b/cmd/ratelord-d/main.go index 800142e..c76207b 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,20 @@ 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(os.Args[1:]) if err != nil { - panic(fmt.Sprintf("failed to get cwd: %v", err)) + fmt.Printf(`{"level":"fatal","msg":"failed_to_load_config","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","addr":"%s","poll_interval":"%s","web_assets_mode":"%s"}`+"\n", cfg.DBPath, cfg.PolicyPath, cfg.Addr, cfg.PollInterval, cfg.WebAssetsMode) // 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 +92,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 loaded, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil { + policyCfg = loaded + policyEngine.UpdatePolicies(loaded) + fmt.Printf(`{"level":"info","msg":"policy_loaded","path":"%s","policies_count":%d}`+"\n", cfg.PolicyPath, len(loaded.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 +103,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 +152,27 @@ 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.Addr) // 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.WebAssetsMode { + case "embedded": + 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","mode":"embedded"}`) + } + case "fs": + if _, err := os.Stat(cfg.WebDir); err != nil { + fmt.Printf(`{"level":"error","msg":"web_assets_dir_unavailable","path":"%s","error":"%v"}`+"\n", cfg.WebDir, err) + } else { + srv.SetStaticFS(os.DirFS(cfg.WebDir)) + fmt.Printf(`{"level":"info","msg":"web_assets_loaded","mode":"fs","path":"%s"}`+"\n", cfg.WebDir) + } + case "off": + fmt.Println(`{"level":"info","msg":"web_assets_disabled"}`) } go func() { @@ -181,9 +192,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 loaded, err := engine.LoadPolicyConfig(cfg.PolicyPath); err == nil { + policyEngine.UpdatePolicies(loaded) + fmt.Printf(`{"level":"info","msg":"policy_reloaded","policies_count":%d}`+"\n", len(loaded.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..90f00cf 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -82,6 +82,11 @@ func (s *Server) SetStaticFS(fs fs.FS) { s.staticFS = fs } +// SetAddr sets the HTTP listen address. +func (s *Server) SetAddr(addr string) { + s.server.Addr = addr +} + // Start runs the HTTP server (blocking) func (s *Server) Start() error { fmt.Printf(`{"level":"info","msg":"server_starting","addr":"%s"}`+"\n", s.server.Addr)