508 lines
17 KiB
Go
508 lines
17 KiB
Go
// Package config is the one place the process reads its environment.
|
|
//
|
|
// Two jobs, in order:
|
|
//
|
|
// 1. Pick the right `.env` file for the environment we are in and load it.
|
|
// 2. Read every setting into a typed Config and refuse to start if anything
|
|
// required is missing — all of it, in one message, before a single
|
|
// connection is attempted.
|
|
//
|
|
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
|
|
// `.env.production` described an `APP_ENV` switch that did not exist, so the
|
|
// only way to run against production was to overwrite `.env` by hand, and the
|
|
// only way to find out a variable was missing was a `log.Fatalf` from inside
|
|
// `db.Connect()` — one variable per restart.
|
|
//
|
|
// # Which file loads
|
|
//
|
|
// `APP_ENV` names the environment and defaults to "local":
|
|
//
|
|
// go run . → .env.local, then .env
|
|
// APP_ENV=production go run . → .env.production, then .env
|
|
//
|
|
// `.env` is a shared base loaded after the environment file. godotenv never
|
|
// overwrites a variable that is already set, so the order of precedence is:
|
|
//
|
|
// real environment > .env.<APP_ENV> > .env
|
|
//
|
|
// Neither file has to exist. On the deployed host every value comes from the
|
|
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
|
|
// Kubernetes) and there is no file at all — which is exactly why the
|
|
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
|
|
// image carries no `.env.*`, so it cannot fall back to localhost values that
|
|
// happen to be lying around in the build context.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
// Environment names. Anything else is accepted (a staging file works the same
|
|
// way) but only these two change behaviour.
|
|
const (
|
|
EnvLocal = "local"
|
|
EnvProduction = "production"
|
|
)
|
|
|
|
// Config is everything the process reads from its environment.
|
|
//
|
|
// A few settings are still read directly with os.Getenv at the point of use,
|
|
// because they are consulted per request or per connection rather than once
|
|
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
|
|
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
|
|
// and POS_* settings in package messaging. They are listed and validated here
|
|
// so that a misconfiguration is still caught at startup.
|
|
type Config struct {
|
|
// AppEnv is the value of APP_ENV: "local" or "production".
|
|
AppEnv string
|
|
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
|
|
Port string
|
|
|
|
DB DBConfig
|
|
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
|
|
Redis RedisConfig
|
|
S3 S3Config
|
|
MQTT MQTTConfig
|
|
Embedding EmbeddingConfig
|
|
// Assistant is the model behind Nearle Buddy. Empty provider = no typed
|
|
// questions; the tools still work.
|
|
Assistant AssistantConfig
|
|
|
|
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
|
|
// unset, matching utils/postoken.go.
|
|
POSTokenSecret string
|
|
JWTSecret string
|
|
UserContextKey string
|
|
GeocoderAPIKey string
|
|
}
|
|
|
|
// DBConfig is one Postgres connection.
|
|
type DBConfig struct {
|
|
Host string
|
|
Port string
|
|
Name string
|
|
User string
|
|
Password string
|
|
}
|
|
|
|
// Enabled reports whether a host was configured at all. Only meaningful for
|
|
// the optional catalogue connection; the main database is required.
|
|
func (d DBConfig) Enabled() bool { return d.Host != "" }
|
|
|
|
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
|
|
// Host empty means presence is disabled and bills still commit.
|
|
type RedisConfig struct {
|
|
Host string
|
|
Port string
|
|
User string
|
|
Password string
|
|
DB int
|
|
}
|
|
|
|
func (r RedisConfig) Enabled() bool { return r.Host != "" }
|
|
|
|
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
|
|
type S3Config struct {
|
|
Enabled bool // USE_S3=true
|
|
Endpoint string
|
|
Bucket string
|
|
AccessKey string
|
|
SecretKey string
|
|
Region string
|
|
}
|
|
|
|
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
|
|
// means the MQTT ingest and the console live stream stay quiet.
|
|
type MQTTConfig struct {
|
|
URL string
|
|
User string
|
|
Password string
|
|
ClientID string
|
|
}
|
|
|
|
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
|
|
|
|
// EmbeddingConfig is the text-embedding model behind the scan-to-product
|
|
// search (services/scanService.go). It MUST be the model that filled the
|
|
// catalogue's `embedding` column — vectors from two different models are not
|
|
// comparable, and pgvector will happily rank garbage. Optional: with no
|
|
// provider the search falls back to plain text matching.
|
|
type EmbeddingConfig struct {
|
|
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
|
|
Model string
|
|
APIKey string
|
|
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
|
|
Dimensions int // 0 = the model's default
|
|
}
|
|
|
|
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
|
|
|
|
// AssistantConfig is the model behind Nearle Buddy.
|
|
//
|
|
// Optional, like the embedder. With no provider the assistant refuses typed
|
|
// questions and says so — the tools still work and still answer correctly,
|
|
// because they are ordinary Go functions; only the part that turns a sentence
|
|
// into a tool call is missing.
|
|
//
|
|
// ── Why three models and not one ────────────────────────────────────────────
|
|
//
|
|
// An agent names a TIER, never a model. "Which branch is underperforming?"
|
|
// and "why is the cancel rate high?" want different amounts of thinking, and
|
|
// wiring a model name into an agent means changing every agent to change
|
|
// provider. The tiers are the stable vocabulary; this map is the only place a
|
|
// model name appears.
|
|
//
|
|
// `ASSISTANT_MODEL` alone sets all three, which is the sane default for a
|
|
// deployment that has not thought about it yet.
|
|
type AssistantConfig struct {
|
|
Provider string // "openai" — any OpenAI-compatible endpoint
|
|
BaseURL string // default https://api.openai.com/v1
|
|
APIKey string
|
|
// Tier → model name. Empty falls back to Balanced, which falls back to
|
|
// ASSISTANT_MODEL.
|
|
Fast string
|
|
Balanced string
|
|
Deep string
|
|
}
|
|
|
|
func (a AssistantConfig) Enabled() bool { return a.Why() == "" }
|
|
|
|
// Why says what is missing, or "" when the assistant can run.
|
|
//
|
|
// A sentence rather than a bool, because "off" is the same answer for four
|
|
// different mistakes: no provider, no model, no key, a provider nobody
|
|
// recognises. Without this the only symptom is a disabled composer, and the
|
|
// difference between "we have not switched it on" and "somebody misspelled a
|
|
// variable" is invisible from the outside — which is exactly where this was
|
|
// stuck.
|
|
// The model is reported before the provider, and that order matters. Since
|
|
// `assistantProvider` derives the provider from the model, an empty provider
|
|
// means the model is empty too — and naming ASSISTANT_PROVIDER first would send
|
|
// an operator to set a variable they no longer need, while the one they
|
|
// actually missed went unmentioned.
|
|
func (a AssistantConfig) Why() string {
|
|
if a.Balanced == "" {
|
|
return "ASSISTANT_MODEL is not set; give it the provider's model name, " +
|
|
"for example openai/gpt-oss-120b"
|
|
}
|
|
switch a.Provider {
|
|
case "openai", "groq", "ollama", "together", "compatible":
|
|
case "":
|
|
// Not reachable through Load, which derives it. Reachable when
|
|
// something builds this struct by hand, and silence would be worse.
|
|
return "ASSISTANT_PROVIDER is not set and could not be derived"
|
|
default:
|
|
return "ASSISTANT_PROVIDER is " + a.Provider + ", which is not one this server speaks"
|
|
}
|
|
// A local provider needs no credential; a hosted one always does, and a
|
|
// missing key otherwise surfaces as a 401 from the provider on the first
|
|
// question rather than as a configuration problem.
|
|
if a.APIKey == "" && !isLocalEndpoint(a.BaseURL) {
|
|
where := a.BaseURL
|
|
if where == "" {
|
|
// Empty means the OpenAI default, which is emphatically not local.
|
|
// "and is not a local endpoint" is how that read before.
|
|
where = "the default https://api.openai.com/v1"
|
|
}
|
|
return "ASSISTANT_API_KEY is not set, and " + where + " is not a local endpoint"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isLocalEndpoint reports whether a base URL is something running beside us.
|
|
//
|
|
// Ollama and LM Studio need no key, and demanding one would refuse the setup a
|
|
// developer is most likely to have on their own machine.
|
|
func isLocalEndpoint(baseURL string) bool {
|
|
url := strings.ToLower(baseURL)
|
|
return strings.Contains(url, "localhost") ||
|
|
strings.Contains(url, "127.0.0.1") ||
|
|
strings.Contains(url, "host.docker.internal")
|
|
}
|
|
|
|
// ModelFor resolves a tier to a model name, falling back rather than failing.
|
|
//
|
|
// A missing `fast` model should answer a cheap question with the balanced one,
|
|
// not refuse it. A deployment that sets one model gets one model everywhere.
|
|
func (a AssistantConfig) ModelFor(tier string) string {
|
|
switch tier {
|
|
case "fast":
|
|
if a.Fast != "" {
|
|
return a.Fast
|
|
}
|
|
case "deep":
|
|
if a.Deep != "" {
|
|
return a.Deep
|
|
}
|
|
}
|
|
return a.Balanced
|
|
}
|
|
|
|
// assistantProvider reads the provider, defaulting to the one shape this
|
|
// server speaks.
|
|
//
|
|
// A deployment that names a model and a key has said what it wants; making it
|
|
// also name a protocol it has no choice about is a variable that exists only to
|
|
// be forgotten.
|
|
func assistantProvider() string {
|
|
if named := strings.ToLower(strings.TrimSpace(env("ASSISTANT_PROVIDER", ""))); named != "" {
|
|
return named
|
|
}
|
|
if strings.TrimSpace(env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", ""))) != "" {
|
|
return "openai"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsProduction is true under APP_ENV=production.
|
|
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
|
|
|
|
// Load picks and loads the environment files, reads every setting and
|
|
// validates them. The returned error lists every problem at once.
|
|
func Load() (*Config, error) {
|
|
loadEnvFiles()
|
|
|
|
cfg := &Config{
|
|
AppEnv: env("APP_ENV", EnvLocal),
|
|
Port: env("APP_PORT", "1122"),
|
|
|
|
DB: DBConfig{
|
|
Host: env("DB_HOST", ""),
|
|
Port: env("DB_PORT", "5433"),
|
|
Name: env("DB_NAME", ""),
|
|
User: env("DB_USER", ""),
|
|
Password: env("DB_PASSWORD", ""),
|
|
},
|
|
Catalogue: DBConfig{
|
|
Host: env("CATALOGUE_DB_HOST", ""),
|
|
Port: env("CATALOGUE_DB_PORT", "5432"),
|
|
Name: env("CATALOGUE_DB_NAME", ""),
|
|
User: env("CATALOGUE_DB_USER", ""),
|
|
Password: env("CATALOGUE_DB_PASSWORD", ""),
|
|
},
|
|
Redis: RedisConfig{
|
|
Host: env("REDIS_HOST", ""),
|
|
Port: env("REDIS_PORT", "6379"),
|
|
User: env("REDIS_USER", "default"),
|
|
Password: env("REDIS_PASSWORD", ""),
|
|
},
|
|
S3: S3Config{
|
|
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
|
|
Endpoint: env("S3_ENDPOINT", ""),
|
|
Bucket: env("S3_BUCKET", ""),
|
|
AccessKey: env("S3_ACCESS_KEY", ""),
|
|
SecretKey: env("S3_SECRET_KEY", ""),
|
|
Region: env("S3_REGION", ""),
|
|
},
|
|
MQTT: MQTTConfig{
|
|
URL: env("MQTT_URL", ""),
|
|
// MQTT_USERNAME is accepted because livehub.go read that name for
|
|
// a while, so an existing deployment may still set it.
|
|
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
|
|
Password: env("MQTT_PASSWORD", ""),
|
|
ClientID: env("MQTT_CLIENT_ID", ""),
|
|
},
|
|
|
|
Embedding: EmbeddingConfig{
|
|
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
|
|
Model: env("EMBEDDING_MODEL", ""),
|
|
APIKey: env("EMBEDDING_API_KEY", ""),
|
|
BaseURL: env("EMBEDDING_BASE_URL", ""),
|
|
},
|
|
|
|
Assistant: AssistantConfig{
|
|
// Defaults to "openai" when a model is named, because every endpoint
|
|
// this speaks is OpenAI-compatible and the base URL is what actually
|
|
// distinguishes them. One less variable to set, and one less way to
|
|
// have the assistant silently off.
|
|
Provider: assistantProvider(),
|
|
BaseURL: env("ASSISTANT_BASE_URL", ""),
|
|
APIKey: env("ASSISTANT_API_KEY", ""),
|
|
Fast: env("ASSISTANT_MODEL_FAST", ""),
|
|
// ASSISTANT_MODEL alone sets every tier, for a deployment that has
|
|
// not thought about tiers yet.
|
|
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", "")),
|
|
Deep: env("ASSISTANT_MODEL_DEEP", ""),
|
|
},
|
|
|
|
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
|
|
JWTSecret: env("JWT_SECRET_KEY", ""),
|
|
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
|
|
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
|
|
}
|
|
|
|
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
|
|
cfg.Redis.DB = db
|
|
} else {
|
|
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
|
|
}
|
|
|
|
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
|
|
n, err := strconv.Atoi(dims)
|
|
if err != nil || n < 0 {
|
|
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
|
|
}
|
|
cfg.Embedding.Dimensions = n
|
|
}
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// MustLoad is Load for main(): every problem is printed and the process exits.
|
|
func MustLoad() *Config {
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
|
|
}
|
|
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
|
|
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
|
|
return cfg
|
|
}
|
|
|
|
// validate collects every problem rather than stopping at the first, so one
|
|
// restart is enough to learn everything that is wrong.
|
|
func (c *Config) validate() error {
|
|
var problems []string
|
|
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
|
|
|
|
if c.DB.Host == "" {
|
|
missing("DB_HOST")
|
|
}
|
|
if c.DB.User == "" {
|
|
missing("DB_USER")
|
|
}
|
|
if c.DB.Password == "" {
|
|
missing("DB_PASSWORD")
|
|
}
|
|
if c.DB.Name == "" {
|
|
missing("DB_NAME")
|
|
}
|
|
|
|
// Optional subsystems are either fully configured or absent. Half a
|
|
// configuration used to be skipped with a warning, which reads as "fine"
|
|
// in a log and turns into "why are there no images" a week later.
|
|
if c.Catalogue.Enabled() {
|
|
if c.Catalogue.User == "" {
|
|
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
|
|
}
|
|
if c.Catalogue.Password == "" {
|
|
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
|
|
}
|
|
if c.Catalogue.Name == "" {
|
|
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
|
|
}
|
|
}
|
|
if c.S3.Enabled {
|
|
if c.S3.Endpoint == "" {
|
|
missing("S3_ENDPOINT (USE_S3=true)")
|
|
}
|
|
if c.S3.Bucket == "" {
|
|
missing("S3_BUCKET (USE_S3=true)")
|
|
}
|
|
if c.S3.AccessKey == "" {
|
|
missing("S3_ACCESS_KEY (USE_S3=true)")
|
|
}
|
|
if c.S3.SecretKey == "" {
|
|
missing("S3_SECRET_KEY (USE_S3=true)")
|
|
}
|
|
if c.S3.Region == "" {
|
|
missing("S3_REGION (USE_S3=true)")
|
|
}
|
|
}
|
|
|
|
if c.Embedding.Enabled() {
|
|
switch c.Embedding.Provider {
|
|
case "openai", "gemini":
|
|
default:
|
|
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
|
|
}
|
|
if c.Embedding.Model == "" {
|
|
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
|
|
}
|
|
if c.Embedding.APIKey == "" {
|
|
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
|
|
}
|
|
}
|
|
|
|
if c.IsProduction() {
|
|
// utils/postoken.go refuses to sign with a short secret at request
|
|
// time; catching it here means the first till login is not the first
|
|
// anyone hears of it.
|
|
secret := c.POSTokenSecret
|
|
if secret == "" {
|
|
secret = c.JWTSecret
|
|
}
|
|
if strings.TrimSpace(secret) == "" {
|
|
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
|
|
} else if len(strings.TrimSpace(secret)) < 16 {
|
|
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
|
|
}
|
|
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
|
|
// Not fatal: a dump restored on another machine on the LAN is a valid
|
|
// local setup. But `.env.local` pointing at the live host is the
|
|
// mistake every comment in that file warns about, so say it out loud.
|
|
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
|
|
c.AppEnv, c.DB.Host)
|
|
}
|
|
|
|
if len(problems) == 0 {
|
|
return nil
|
|
}
|
|
return errors.New(strings.Join(problems, "\n"))
|
|
}
|
|
|
|
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
|
|
//
|
|
// APP_ENV is read from the real environment before any file, so a file cannot
|
|
// change which environment it is loaded for.
|
|
// `.env.secrets` is read FIRST and is the only one of these git does not track.
|
|
// godotenv never overwrites a value already set, so first read wins — which is
|
|
// what makes this file the place a key belongs. Every other file here is in the
|
|
// repository, so a secret written to one is a secret published; there was
|
|
// previously nowhere to put a key at all, and the answer was "export it in your
|
|
// shell every time", which is the kind of instruction people route around.
|
|
// envFileOrder is the read order, and the order is the rule: godotenv never
|
|
// overwrites a value already set, so whichever file names a variable first is
|
|
// the one that decides it.
|
|
func envFileOrder(appEnv string) []string {
|
|
return []string{".env.secrets", ".env." + appEnv, ".env"}
|
|
}
|
|
|
|
func loadEnvFiles() {
|
|
for _, name := range envFileOrder(env("APP_ENV", EnvLocal)) {
|
|
if _, err := os.Stat(name); err != nil {
|
|
continue
|
|
}
|
|
if err := godotenv.Load(name); err != nil {
|
|
log.Printf("config: could not read %s: %v", name, err)
|
|
continue
|
|
}
|
|
log.Printf("config: loaded %s", name)
|
|
}
|
|
}
|
|
|
|
func env(key, fallback string) string {
|
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func isLocalHost(host string) bool {
|
|
switch strings.ToLower(host) {
|
|
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
|
|
return true
|
|
}
|
|
return strings.HasPrefix(host, "127.")
|
|
}
|