Files
backend_fiesta/config/config.go
Suriyakumarvijayanayagam 4474479735 Load .env.<APP_ENV>, validate config at boot, keep secrets out of the image
`main.go` only ever loaded `.env`; the `APP_ENV` switch described in
`.env.local` / `.env.production` did not exist, and a missing variable
surfaced one restart at a time as a log.Fatalf inside db.Connect.

config.Load now picks `.env.<APP_ENV>` (default local) then `.env`, with
real environment winning, reads every setting into one typed Config and
reports everything missing in one message. Production insists on a POS
signing secret; local warns when DB_HOST is not a local address. db,
redis and the image store take the Config instead of reading env
themselves.

Also:
- livehub read MQTT_USERNAME while everything else uses MQTT_USER, so the
  console stream connected to the broker unauthenticated. Both accepted.
- .dockerignore: `COPY . .` was baking .env.production into the image.
  Dockerfile sets APP_ENV=production.
- Drop utils/config.go (dead viper loader) and create_table.go (unused,
  hardcoded production DSN); go mod tidy removes viper.
- .env.example lists every variable the code reads; docs/ENVIRONMENT.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:04:33 +05:30

362 lines
11 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
// 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 != "" }
// 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", ""),
},
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.
func loadEnvFiles() {
appEnv := env("APP_ENV", EnvLocal)
for _, name := range []string{".env." + appEnv, ".env"} {
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.")
}