diff --git a/cmd/engine/main.go b/cmd/engine/main.go index e2048ed..bd447b0 100644 --- a/cmd/engine/main.go +++ b/cmd/engine/main.go @@ -46,8 +46,16 @@ func main() { func run() error { configPath := flag.String("config", "", "path to config.yaml (optional; env vars take precedence)") + localMode := flag.Bool("local", false, "zero-config local mode: localhost Postgres, local storage, listen on :7654, no setup (sets VLE_LOCAL_MODE)") flag.Parse() + // --local is sugar for VLE_LOCAL_MODE=true so the CLI flag and the env + // var (used by the all-in-one Docker image) flow through one path in + // config.Load. Set it before Load reads the environment. + if *localMode { + _ = os.Setenv("VLE_LOCAL_MODE", "true") + } + cfg, err := config.Load(*configPath) if err != nil { return fmt.Errorf("load config: %w", err) @@ -56,6 +64,7 @@ func run() error { logger := newLogger(cfg.Log) logger.Info("starting vectorless-engine", "version", version, + "local_mode", config.LocalModeEnabled(), "storage_driver", cfg.Storage.Driver, "queue_driver", cfg.Queue.Driver, "llm_driver", cfg.LLM.Driver, diff --git a/config.example.yaml b/config.example.yaml index 977fe24..141b165 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -13,6 +13,19 @@ # # `vectorless-engine config print` prints the effective config with secrets # redacted; `vectorless-engine config check` validates it and exits 0/1. +# +# ZERO-CONFIG LOCAL MODE +# ---------------------- +# Run `engine --local` (or set VLE_LOCAL_MODE=true) to boot with no config +# at all: it listens on :7654, points at a localhost Postgres +# (postgres://vectorless:vectorless@localhost:5432/vectorless), uses local +# file storage and the Postgres-backed river queue, and requires no API key +# to call the engine. This matches the all-in-one Docker image, where +# Postgres is bundled in the same container. You still supply an LLM +# provider key (e.g. VLE_LLM_ANTHROPIC_API_KEY) for ingestion + retrieval. +# Override any local default with the usual env/flags, e.g. +# VLE_SERVER_ADDR=:9000 or VLE_STORAGE_LOCAL_ROOT=/data/documents. +# Local mode is for dev/local use — do NOT expose it to the public internet. server: addr: ":8080" diff --git a/pkg/config/config.go b/pkg/config/config.go index 70cf5e1..3a3dab1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -790,10 +790,64 @@ func Default() Config { } } +// localDefaultAddr is the canonical local-mode listen address. The whole +// product (engine API + bundled dashboard) is reachable at +// localhost:7654 so every doc, link, and quickstart can say one number. +const localDefaultAddr = ":7654" + +// defaultLocalDatabaseURL is the Postgres DSN local mode assumes when no +// URL is configured. It matches the bundled Postgres in the all-in-one +// Docker image and the dev docker-compose (user/pass/db all "vectorless" +// on localhost:5432), so a bare `engine --local` next to those services +// just connects. +const defaultLocalDatabaseURL = "postgres://vectorless:vectorless@localhost:5432/vectorless?sslmode=disable" + +// LocalModeEnabled reports whether zero-config local mode was requested +// via the VLE_LOCAL_MODE env var (truthy: 1/true/yes/on). The engine's +// --local flag sets this var before Load runs, so the CLI flag and the +// env var (used by the Docker image) share one code path. +func LocalModeEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("VLE_LOCAL_MODE"))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// applyLocalDefaults rewrites the base config for zero-config local +// running: the canonical :7654 port, a localhost Postgres URL matching +// the bundled/dev database, local file storage, and the Postgres-backed +// river queue (no Redis required). It runs on the Default() base BEFORE +// the YAML file and env overrides are applied, so any value the operator +// sets explicitly still wins — local mode only moves the starting point +// so the engine boots with no required configuration. +// +// Auth: the standalone engine (cmd/engine) is already unauthenticated — +// it serves a single logical tenant with no API key — so "local-mode +// auth" needs no extra wiring here. This is dev/local only and must not +// be exposed to the public internet. +func applyLocalDefaults(c *Config) { + c.Server.Addr = localDefaultAddr + c.Database.URL = defaultLocalDatabaseURL + c.Storage.Driver = "local" + if c.Storage.Local.Root == "" { + c.Storage.Local.Root = "./data/documents" + } + c.Queue.Driver = "river" +} + // Load reads configuration from a YAML file (optional) and applies // environment overrides on top. Pass an empty path to skip the file. +// +// When VLE_LOCAL_MODE is truthy (or the engine is run with --local, which +// sets it), zero-config local defaults are applied to the base before the +// file/env layers, so the engine boots on :7654 against a localhost +// Postgres with no required configuration. File and env still override. func Load(path string) (Config, error) { cfg := Default() + if LocalModeEnabled() { + applyLocalDefaults(&cfg) + } if path != "" { data, err := os.ReadFile(path) if err != nil { @@ -869,6 +923,9 @@ func applyEnvOverrides(c *Config) { if v := os.Getenv("VLE_STORAGE_DRIVER"); v != "" { c.Storage.Driver = v } + if v := os.Getenv("VLE_STORAGE_LOCAL_ROOT"); v != "" { + c.Storage.Local.Root = v + } if v := os.Getenv("VLE_QUEUE_DRIVER"); v != "" { c.Queue.Driver = v } diff --git a/pkg/config/config_local_test.go b/pkg/config/config_local_test.go new file mode 100644 index 0000000..5c12f4b --- /dev/null +++ b/pkg/config/config_local_test.go @@ -0,0 +1,99 @@ +package config + +import "testing" + +// TestLocalModeDefaults: with VLE_LOCAL_MODE set, Load with no file and no +// other env boots a complete, valid config — :7654, a localhost Postgres +// URL, local storage, river queue — with nothing else required. +func TestLocalModeDefaults(t *testing.T) { + t.Setenv("VLE_LOCAL_MODE", "true") + + cfg, err := Load("") + if err != nil { + t.Fatalf("local-mode Load() with no other config should succeed, got: %v", err) + } + if cfg.Server.Addr != ":7654" { + t.Errorf("local mode server.addr = %q, want :7654", cfg.Server.Addr) + } + if cfg.Database.URL != defaultLocalDatabaseURL { + t.Errorf("local mode database.url = %q, want %q", cfg.Database.URL, defaultLocalDatabaseURL) + } + if cfg.Storage.Driver != "local" { + t.Errorf("local mode storage.driver = %q, want local", cfg.Storage.Driver) + } + if cfg.Queue.Driver != "river" { + t.Errorf("local mode queue.driver = %q, want river", cfg.Queue.Driver) + } + if cfg.Storage.Local.Root == "" { + t.Error("local mode storage.local.root must be set") + } +} + +// TestLocalModeTruthyForms: the env flag accepts the usual truthy spellings +// and ignores everything else. +func TestLocalModeTruthyForms(t *testing.T) { + for _, v := range []string{"1", "true", "TRUE", "yes", "on"} { + t.Setenv("VLE_LOCAL_MODE", v) + if !LocalModeEnabled() { + t.Errorf("VLE_LOCAL_MODE=%q should enable local mode", v) + } + } + for _, v := range []string{"", "0", "false", "no", "off", "nope"} { + t.Setenv("VLE_LOCAL_MODE", v) + if LocalModeEnabled() { + t.Errorf("VLE_LOCAL_MODE=%q should NOT enable local mode", v) + } + } +} + +// TestLocalModeEnvOverridesWin: local mode only moves the starting point — +// explicit env values still override the local defaults. +func TestLocalModeEnvOverridesWin(t *testing.T) { + t.Setenv("VLE_LOCAL_MODE", "true") + t.Setenv("VLE_SERVER_ADDR", ":9999") + t.Setenv("VLE_DATABASE_URL", "postgres://custom:custom@db:5432/custom?sslmode=disable") + t.Setenv("VLE_STORAGE_LOCAL_ROOT", "/srv/docs") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if cfg.Server.Addr != ":9999" { + t.Errorf("env should override local addr: got %q, want :9999", cfg.Server.Addr) + } + if cfg.Database.URL != "postgres://custom:custom@db:5432/custom?sslmode=disable" { + t.Errorf("env should override local db url, got %q", cfg.Database.URL) + } + if cfg.Storage.Local.Root != "/srv/docs" { + t.Errorf("VLE_STORAGE_LOCAL_ROOT should set storage root, got %q", cfg.Storage.Local.Root) + } +} + +// TestNonLocalModeUnchanged: without the flag the historical defaults hold +// (:8080), and the engine still requires a database URL for the river +// queue — i.e. local mode is the ONLY thing that injects one. +func TestNonLocalModeUnchanged(t *testing.T) { + t.Setenv("VLE_LOCAL_MODE", "") + // Provide a DB URL so validation passes for the river default. + t.Setenv("VLE_DATABASE_URL", "postgres://x:x@localhost:5432/x?sslmode=disable") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + if cfg.Server.Addr != ":8080" { + t.Errorf("non-local addr = %q, want :8080", cfg.Server.Addr) + } +} + +// TestNonLocalModeMissingDBURLFails proves the local-mode injection is what +// removes the "no required config" gap: without it and without a DB URL, +// the river queue fails validation. +func TestNonLocalModeMissingDBURLFails(t *testing.T) { + t.Setenv("VLE_LOCAL_MODE", "") + t.Setenv("VLE_DATABASE_URL", "") + + if _, err := Load(""); err == nil { + t.Fatal("expected validation error for river queue with no database.url, got nil") + } +}