Files
chorus/internal/config/config.go
T

199 lines
7.0 KiB
Go

package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
type Environment string
const (
Development Environment = "development"
Test Environment = "test"
Production Environment = "production"
)
type Config struct {
Environment Environment
DBDSN string
ListenAddress string
StorageRoot string
SessionKey string
SessionTTL time.Duration
LoginAttempts int
LoginWindow time.Duration
MaxPromptBytes int
MaxImages int
MaxImageBytes int64
MaxUploadBytes int64
MaxImagePixels uint64
HistoryLimit int
WorkerLeaseDuration time.Duration
WorkerPollInterval time.Duration
ProviderHTTPTimeout time.Duration
ProviderMaxResponseBytes int64
TestDisableWorker bool
}
func Load() (Config, error) {
return LoadFromLookup(os.LookupEnv)
}
func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) {
read := func(name string) string {
value, _ := lookup(name)
return strings.TrimSpace(value)
}
environment := Environment(read("CHORUS_ENV"))
if environment == "" {
environment = Development
}
if environment != Development && environment != Test && environment != Production {
return Config{}, fmt.Errorf("CHORUS_ENV must be development, test, or production")
}
cfg := Config{
Environment: environment,
DBDSN: read("CHORUS_DSN"),
ListenAddress: read("CHORUS_LISTEN_ADDRESS"),
StorageRoot: read("CHORUS_STORAGE_ROOT"),
SessionKey: read("CHORUS_SESSION_KEY"),
}
if environment != Production {
if cfg.StorageRoot == "" {
cfg.StorageRoot = "var/storage"
}
if cfg.SessionKey == "" {
generated := make([]byte, 32)
if _, err := rand.Read(generated); err != nil {
return Config{}, fmt.Errorf("generate development session key")
}
cfg.SessionKey = base64.RawURLEncoding.EncodeToString(generated)
}
}
if cfg.ListenAddress == "" {
cfg.ListenAddress = "127.0.0.1:8080"
}
var missing []string
if cfg.DBDSN == "" {
missing = append(missing, "CHORUS_DSN")
}
if environment == Production {
for name, value := range map[string]string{
"CHORUS_SESSION_KEY": cfg.SessionKey,
"CHORUS_STORAGE_ROOT": cfg.StorageRoot,
} {
if value == "" {
missing = append(missing, name)
}
}
for _, name := range []string{"CHORUS_SESSION_TTL_MINUTES", "CHORUS_LOGIN_ATTEMPTS", "CHORUS_LOGIN_WINDOW_SECONDS", "CHORUS_MAX_PROMPT_BYTES", "CHORUS_MAX_IMAGES", "CHORUS_MAX_IMAGE_BYTES", "CHORUS_MAX_UPLOAD_BYTES", "CHORUS_MAX_IMAGE_PIXELS", "CHORUS_HISTORY_LIMIT", "CHORUS_WORKER_LEASE_SECONDS", "CHORUS_WORKER_POLL_MILLISECONDS", "CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS", "CHORUS_PROVIDER_MAX_RESPONSE_BYTES"} {
if read(name) == "" {
missing = append(missing, name)
}
}
}
if len(missing) > 0 {
return Config{}, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", "))
}
var err error
if cfg.SessionTTL, err = durationConfig(read("CHORUS_SESSION_TTL_MINUTES"), 480, time.Minute); err != nil {
return Config{}, fmt.Errorf("CHORUS_SESSION_TTL_MINUTES is invalid")
}
if cfg.LoginWindow, err = durationConfig(read("CHORUS_LOGIN_WINDOW_SECONDS"), 60, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_LOGIN_WINDOW_SECONDS is invalid")
}
if cfg.LoginAttempts, err = intConfig(read("CHORUS_LOGIN_ATTEMPTS"), 5); err != nil {
return Config{}, fmt.Errorf("CHORUS_LOGIN_ATTEMPTS is invalid")
}
if cfg.MaxPromptBytes, err = intConfig(read("CHORUS_MAX_PROMPT_BYTES"), 8000); err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_PROMPT_BYTES is invalid")
}
if cfg.MaxImages, err = intConfig(read("CHORUS_MAX_IMAGES"), 8); err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGES is invalid")
}
maxImageBytes, err := intConfig(read("CHORUS_MAX_IMAGE_BYTES"), 10<<20)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_BYTES is invalid")
}
cfg.MaxImageBytes = int64(maxImageBytes)
maxUploadBytes, err := intConfig(read("CHORUS_MAX_UPLOAD_BYTES"), 32<<20)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_UPLOAD_BYTES is invalid")
}
cfg.MaxUploadBytes = int64(maxUploadBytes)
maxPixels, err := intConfig(read("CHORUS_MAX_IMAGE_PIXELS"), 40_000_000)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_PIXELS is invalid")
}
cfg.MaxImagePixels = uint64(maxPixels)
if cfg.HistoryLimit, err = intConfig(read("CHORUS_HISTORY_LIMIT"), 50); err != nil {
return Config{}, fmt.Errorf("CHORUS_HISTORY_LIMIT is invalid")
}
if cfg.WorkerLeaseDuration, err = durationConfig(read("CHORUS_WORKER_LEASE_SECONDS"), 60, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_WORKER_LEASE_SECONDS is invalid")
}
if cfg.WorkerPollInterval, err = durationConfig(read("CHORUS_WORKER_POLL_MILLISECONDS"), 250, time.Millisecond); err != nil {
return Config{}, fmt.Errorf("CHORUS_WORKER_POLL_MILLISECONDS is invalid")
}
if cfg.ProviderHTTPTimeout, err = durationConfig(read("CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS"), 45, time.Second); err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS is invalid")
}
providerMaxResponseBytes, err := intConfig(read("CHORUS_PROVIDER_MAX_RESPONSE_BYTES"), 32<<20)
if err != nil {
return Config{}, fmt.Errorf("CHORUS_PROVIDER_MAX_RESPONSE_BYTES is invalid")
}
cfg.ProviderMaxResponseBytes = int64(providerMaxResponseBytes)
if value := read("CHORUS_TEST_DISABLE_WORKER"); value != "" {
if cfg.TestDisableWorker, err = strconv.ParseBool(value); err != nil {
return Config{}, fmt.Errorf("CHORUS_TEST_DISABLE_WORKER is invalid")
}
if cfg.TestDisableWorker && environment != Test {
return Config{}, fmt.Errorf("CHORUS_TEST_DISABLE_WORKER is only allowed in test")
}
}
if cfg.MaxUploadBytes < cfg.MaxImageBytes || cfg.MaxImages > 32 || cfg.HistoryLimit > 200 {
return Config{}, fmt.Errorf("portal limits are inconsistent")
}
if cfg.WorkerLeaseDuration <= cfg.ProviderHTTPTimeout+5*time.Second {
return Config{}, fmt.Errorf("worker lease must exceed provider HTTP timeout by more than 5 seconds")
}
return cfg, nil
}
func (c Config) ValidateProduction() error {
if c.Environment != Production {
return nil
}
if c.DBDSN == "" || len(c.SessionKey) < 32 || c.StorageRoot == "" || c.SessionTTL <= 0 || c.MaxImageBytes <= 0 || c.MaxUploadBytes < c.MaxImageBytes || c.WorkerLeaseDuration <= c.ProviderHTTPTimeout+5*time.Second || c.ProviderMaxResponseBytes <= 0 {
return errors.New("production configuration is incomplete")
}
return nil
}
func intConfig(value string, fallback int) (int, error) {
if value == "" {
return fallback, nil
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return 0, errors.New("invalid positive integer")
}
return parsed, nil
}
func durationConfig(value string, fallback int, unit time.Duration) (time.Duration, error) {
parsed, err := intConfig(value, fallback)
if err != nil {
return 0, err
}
return time.Duration(parsed) * unit, nil
}