e63748c344
Adds backend/internal/devsandbox: an idempotent boot-time hook that, when BACKEND_DEV_SANDBOX_EMAIL is set, ensures (1) the configured engine_version row, (2) the real dev user, (3) PlayerCount-1 deterministic dummy users, (4) a private "Dev Sandbox" game with a year-out turn schedule, (5) memberships for every participant via the new lobby.Service.InsertMembershipDirect helper, (6) a drive of the lifecycle to running. Re-running on a populated DB is a no-op; partial states from earlier crashes are recovered. tools/local-dev gains the matching env vars in .env, surfaces them in compose, and acquires a `make build-engine` target that builds galaxy-engine:local-dev from game/Dockerfile (a prerequisite of `up`/`rebuild`). The compose game-state mount is changed from a named volume to a host bind on /tmp/galaxy-game-state so backend's bind-mount source for spawned engine containers resolves on the docker daemon. After `make -C tools/local-dev up`, login as dev@local.test with the dev code 123456 and the Dev Sandbox already shows up in My Games. Per-user behaviour for the same email survives a backend restart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
955 lines
34 KiB
Go
955 lines
34 KiB
Go
// Package config loads process-level backend configuration from environment
|
|
// variables.
|
|
//
|
|
// The variable set is the canonical inventory documented in
|
|
// `backend/README.md` §4. LoadFromEnv populates a Config from environment,
|
|
// applies the documented defaults, then runs Validate. Validate fails fast on
|
|
// any required-but-missing variable so the process never starts in a partially
|
|
// configured state.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
netmail "net/mail"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Environment variable names. The exhaustive set follows README §4.
|
|
const (
|
|
envShutdownTimeout = "BACKEND_SHUTDOWN_TIMEOUT"
|
|
|
|
envLoggingLevel = "BACKEND_LOGGING_LEVEL"
|
|
|
|
envHTTPListenAddr = "BACKEND_HTTP_LISTEN_ADDR"
|
|
envHTTPReadTimeout = "BACKEND_HTTP_READ_TIMEOUT"
|
|
envHTTPWriteTimeout = "BACKEND_HTTP_WRITE_TIMEOUT"
|
|
envHTTPShutdownTimeout = "BACKEND_HTTP_SHUTDOWN_TIMEOUT"
|
|
|
|
envGRPCPushListenAddr = "BACKEND_GRPC_PUSH_LISTEN_ADDR"
|
|
envGRPCPushShutdownTimeout = "BACKEND_GRPC_PUSH_SHUTDOWN_TIMEOUT"
|
|
|
|
envPostgresDSN = "BACKEND_POSTGRES_DSN"
|
|
envPostgresMaxConns = "BACKEND_POSTGRES_MAX_CONNS"
|
|
envPostgresMinConns = "BACKEND_POSTGRES_MIN_CONNS"
|
|
envPostgresOperationTimeout = "BACKEND_POSTGRES_OPERATION_TIMEOUT"
|
|
|
|
envSMTPHost = "BACKEND_SMTP_HOST"
|
|
envSMTPPort = "BACKEND_SMTP_PORT"
|
|
envSMTPUsername = "BACKEND_SMTP_USERNAME"
|
|
envSMTPPassword = "BACKEND_SMTP_PASSWORD"
|
|
envSMTPFrom = "BACKEND_SMTP_FROM"
|
|
envSMTPTLSMode = "BACKEND_SMTP_TLS_MODE"
|
|
|
|
envMailWorkerInterval = "BACKEND_MAIL_WORKER_INTERVAL"
|
|
envMailMaxAttempts = "BACKEND_MAIL_MAX_ATTEMPTS"
|
|
|
|
envDockerHost = "BACKEND_DOCKER_HOST"
|
|
envDockerNetwork = "BACKEND_DOCKER_NETWORK"
|
|
|
|
envGameStateRoot = "BACKEND_GAME_STATE_ROOT"
|
|
|
|
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
|
|
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
|
|
|
|
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
|
|
|
|
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
|
|
envOTelMetricsExporter = "BACKEND_OTEL_METRICS_EXPORTER"
|
|
envOTelProtocol = "BACKEND_OTEL_PROTOCOL"
|
|
envOTelEndpoint = "BACKEND_OTEL_ENDPOINT"
|
|
envOTelPrometheusListenAddr = "BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR"
|
|
envServiceName = "BACKEND_SERVICE_NAME"
|
|
|
|
envFreshnessWindow = "BACKEND_FRESHNESS_WINDOW"
|
|
|
|
envAuthChallengeTTL = "BACKEND_AUTH_CHALLENGE_TTL"
|
|
envAuthChallengeMaxAttempts = "BACKEND_AUTH_CHALLENGE_MAX_ATTEMPTS"
|
|
envAuthChallengeThrottleWindow = "BACKEND_AUTH_CHALLENGE_THROTTLE_WINDOW"
|
|
envAuthChallengeThrottleMax = "BACKEND_AUTH_CHALLENGE_THROTTLE_MAX"
|
|
envAuthUserNameMaxRetries = "BACKEND_AUTH_USERNAME_MAX_RETRIES"
|
|
envAuthDevFixedCode = "BACKEND_AUTH_DEV_FIXED_CODE"
|
|
|
|
envLobbySweeperInterval = "BACKEND_LOBBY_SWEEPER_INTERVAL"
|
|
envLobbyPendingRegistrationTTL = "BACKEND_LOBBY_PENDING_REGISTRATION_TTL"
|
|
envLobbyInviteDefaultTTL = "BACKEND_LOBBY_INVITE_DEFAULT_TTL"
|
|
|
|
envEngineCallTimeout = "BACKEND_ENGINE_CALL_TIMEOUT"
|
|
envEngineProbeTimeout = "BACKEND_ENGINE_PROBE_TIMEOUT"
|
|
|
|
envRuntimeWorkerPoolSize = "BACKEND_RUNTIME_WORKER_POOL_SIZE"
|
|
envRuntimeJobQueueSize = "BACKEND_RUNTIME_JOB_QUEUE_SIZE"
|
|
envRuntimeReconcileInterval = "BACKEND_RUNTIME_RECONCILE_INTERVAL"
|
|
envRuntimeImagePullPolicy = "BACKEND_RUNTIME_IMAGE_PULL_POLICY"
|
|
envRuntimeContainerLogDriver = "BACKEND_RUNTIME_CONTAINER_LOG_DRIVER"
|
|
envRuntimeContainerLogOpts = "BACKEND_RUNTIME_CONTAINER_LOG_OPTS"
|
|
envRuntimeContainerCPUQuota = "BACKEND_RUNTIME_CONTAINER_CPU_QUOTA"
|
|
envRuntimeContainerMemory = "BACKEND_RUNTIME_CONTAINER_MEMORY"
|
|
envRuntimeContainerPIDsLimit = "BACKEND_RUNTIME_CONTAINER_PIDS_LIMIT"
|
|
envRuntimeContainerStateMount = "BACKEND_RUNTIME_CONTAINER_STATE_MOUNT"
|
|
envRuntimeStopGracePeriod = "BACKEND_RUNTIME_STOP_GRACE_PERIOD"
|
|
|
|
envNotificationAdminEmail = "BACKEND_NOTIFICATION_ADMIN_EMAIL"
|
|
envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL"
|
|
envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS"
|
|
|
|
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
|
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
|
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
|
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
|
|
)
|
|
|
|
// Default values applied when an environment variable is absent.
|
|
const (
|
|
defaultShutdownTimeout = 30 * time.Second
|
|
|
|
defaultLoggingLevel = "info"
|
|
|
|
defaultHTTPListenAddr = ":8080"
|
|
defaultHTTPReadTimeout = 30 * time.Second
|
|
defaultHTTPWriteTimeout = 30 * time.Second
|
|
defaultHTTPShutdownTimeout = 15 * time.Second
|
|
|
|
defaultGRPCPushListenAddr = ":8081"
|
|
defaultGRPCPushShutdownTimeout = 10 * time.Second
|
|
|
|
defaultPostgresMaxConns = 25
|
|
defaultPostgresMinConns = 2
|
|
defaultPostgresOperationTimeout = 5 * time.Second
|
|
|
|
defaultSMTPPort = 587
|
|
defaultSMTPTLSMode = "starttls"
|
|
|
|
defaultMailWorkerInterval = 2 * time.Second
|
|
defaultMailMaxAttempts = 8
|
|
|
|
defaultDockerHost = "unix:///var/run/docker.sock"
|
|
|
|
defaultOTelTracesExporter = "otlp"
|
|
defaultOTelMetricsExporter = "otlp"
|
|
defaultOTelProtocol = "grpc"
|
|
defaultOTelPrometheusListenAddr = ":9100"
|
|
defaultServiceName = "galaxy-backend"
|
|
|
|
defaultFreshnessWindow = 5 * time.Minute
|
|
|
|
defaultAuthChallengeTTL = 10 * time.Minute
|
|
defaultAuthChallengeMaxAttempts = 5
|
|
defaultAuthChallengeThrottleWindow = 60 * time.Second
|
|
defaultAuthChallengeThrottleMax = 3
|
|
defaultAuthUserNameMaxRetries = 10
|
|
|
|
defaultLobbySweeperInterval = 60 * time.Second
|
|
defaultLobbyPendingRegistrationTTL = 30 * 24 * time.Hour
|
|
defaultLobbyInviteDefaultTTL = 7 * 24 * time.Hour
|
|
|
|
defaultEngineCallTimeout = 60 * time.Second
|
|
defaultEngineProbeTimeout = 5 * time.Second
|
|
|
|
defaultRuntimeWorkerPoolSize = 4
|
|
defaultRuntimeJobQueueSize = 64
|
|
defaultRuntimeReconcileInterval = 60 * time.Second
|
|
defaultRuntimeImagePullPolicy = "if_missing"
|
|
defaultRuntimeContainerLogDriver = "json-file"
|
|
defaultRuntimeContainerCPUQuota = 2.0
|
|
defaultRuntimeContainerMemory = "512m"
|
|
defaultRuntimeContainerPIDsLimit = 256
|
|
defaultRuntimeContainerStateMount = "/var/lib/galaxy-game"
|
|
defaultRuntimeStopGracePeriod = 10 * time.Second
|
|
|
|
defaultNotificationWorkerInterval = 5 * time.Second
|
|
defaultNotificationMaxAttempts = 8
|
|
|
|
defaultDevSandboxEngineVersion = "0.1.0"
|
|
defaultDevSandboxPlayerCount = 20
|
|
)
|
|
|
|
// Allowed values for the closed-set string options.
|
|
var (
|
|
allowedTracesExporters = []string{"none", "otlp", "stdout"}
|
|
allowedMetricsExporters = []string{"none", "otlp", "stdout", "prometheus"}
|
|
allowedOTelProtocols = []string{"grpc", "http/protobuf"}
|
|
allowedSMTPTLSModes = []string{"none", "starttls", "tls"}
|
|
allowedPullPolicies = []string{"if_missing", "always", "never"}
|
|
)
|
|
|
|
// Config is the top-level backend configuration assembled from environment
|
|
// variables. The zero value is not usable; callers must obtain a Config via
|
|
// DefaultConfig or LoadFromEnv.
|
|
type Config struct {
|
|
// ShutdownTimeout bounds each component's Shutdown call coordinated by
|
|
// the process App lifecycle. Per-listener timeouts (HTTP, gRPC) bound the
|
|
// inner server stop and may be smaller than ShutdownTimeout.
|
|
ShutdownTimeout time.Duration
|
|
|
|
Logging LoggingConfig
|
|
HTTP HTTPConfig
|
|
GRPCPush GRPCPushConfig
|
|
Postgres PostgresConfig
|
|
SMTP SMTPConfig
|
|
Mail MailConfig
|
|
Docker DockerConfig
|
|
Game GameConfig
|
|
Admin AdminBootstrapConfig
|
|
GeoIP GeoIPConfig
|
|
Telemetry TelemetryConfig
|
|
Auth AuthConfig
|
|
Lobby LobbyConfig
|
|
Engine EngineConfig
|
|
Runtime RuntimeConfig
|
|
Notification NotificationConfig
|
|
DevSandbox DevSandboxConfig
|
|
|
|
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
|
// push server to bound the cursor TTL.
|
|
FreshnessWindow time.Duration
|
|
}
|
|
|
|
// DevSandboxConfig configures the boot-time bootstrap implemented in
|
|
// `backend/internal/devsandbox`. When Email is empty the bootstrap
|
|
// is a no-op, which is the production posture. When Email is set —
|
|
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
|
|
// the bootstrap idempotently provisions a real user, the configured
|
|
// number of dummy participants, a private "Dev Sandbox" game, the
|
|
// matching memberships, and drives the lifecycle to `running`. The
|
|
// engine image and engine version refer to a row that the bootstrap
|
|
// also seeds in `engine_versions`.
|
|
type DevSandboxConfig struct {
|
|
Email string
|
|
EngineImage string
|
|
EngineVersion string
|
|
PlayerCount int
|
|
}
|
|
|
|
// LoggingConfig stores the parameters used by the structured logger.
|
|
type LoggingConfig struct {
|
|
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
|
Level string
|
|
}
|
|
|
|
// HTTPConfig configures the public HTTP listener.
|
|
type HTTPConfig struct {
|
|
Addr string
|
|
ReadTimeout time.Duration
|
|
WriteTimeout time.Duration
|
|
ShutdownTimeout time.Duration
|
|
}
|
|
|
|
// GRPCPushConfig configures the gRPC push listener.
|
|
type GRPCPushConfig struct {
|
|
Addr string
|
|
ShutdownTimeout time.Duration
|
|
}
|
|
|
|
// PostgresConfig configures the primary Postgres pool.
|
|
//
|
|
// MinConns mirrors README §4 BACKEND_POSTGRES_MIN_CONNS and is interpreted as
|
|
// the maximum number of idle connections kept warm in the pool — database/sql
|
|
// has no real minimum-pool concept, so this is the closest equivalent. The
|
|
// mapping is documented in `backend/README.md` and `backend/docs/`.
|
|
type PostgresConfig struct {
|
|
DSN string
|
|
MaxConns int
|
|
MinConns int
|
|
OperationTimeout time.Duration
|
|
}
|
|
|
|
// SMTPConfig configures the SMTP relay used by the mail outbox.
|
|
type SMTPConfig struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
From string
|
|
TLSMode string
|
|
}
|
|
|
|
// MailConfig configures the mail outbox worker.
|
|
type MailConfig struct {
|
|
WorkerInterval time.Duration
|
|
MaxAttempts int
|
|
}
|
|
|
|
// DockerConfig configures the Docker client used by the runtime module.
|
|
type DockerConfig struct {
|
|
Host string
|
|
Network string
|
|
}
|
|
|
|
// GameConfig configures the runtime engine container layout.
|
|
type GameConfig struct {
|
|
StateRoot string
|
|
}
|
|
|
|
// AdminBootstrapConfig configures the optional first-admin bootstrap.
|
|
// At startup the admin module inserts a row in `backend.admin_accounts`
|
|
// when User is non-empty and no row with that username exists yet; the
|
|
// insert is idempotent across restarts.
|
|
type AdminBootstrapConfig struct {
|
|
User string
|
|
Password string
|
|
}
|
|
|
|
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
|
|
type GeoIPConfig struct {
|
|
DBPath string
|
|
}
|
|
|
|
// TelemetryConfig configures the OpenTelemetry runtime.
|
|
type TelemetryConfig struct {
|
|
ServiceName string
|
|
TracesExporter string
|
|
MetricsExporter string
|
|
Protocol string
|
|
Endpoint string
|
|
PrometheusListenAddr string
|
|
}
|
|
|
|
// AuthConfig configures the email-code authentication flow implemented in
|
|
// `backend/internal/auth`. ChallengeTTL bounds the lifetime of an issued
|
|
// `auth_challenges` row, ChallengeMaxAttempts caps confirm-email-code
|
|
// attempts per challenge, ChallengeThrottle bounds new-challenge issuance
|
|
// per email, and UserNameMaxRetries caps the retry budget for synthesising
|
|
// a unique `accounts.user_name` at registration.
|
|
type AuthConfig struct {
|
|
ChallengeTTL time.Duration
|
|
ChallengeMaxAttempts int
|
|
ChallengeThrottle AuthChallengeThrottleConfig
|
|
UserNameMaxRetries int
|
|
|
|
// DevFixedCode, when non-empty, makes ConfirmEmailCode accept this
|
|
// literal as a valid code in addition to the bcrypt-verified one
|
|
// stored on the challenge row. The override is intended for the
|
|
// `tools/local-dev` stack so a developer can log in without
|
|
// reading codes out of Mailpit. The variable MUST stay unset in
|
|
// production: validation requires a six-digit decimal value, and
|
|
// the auth service emits a loud startup warning when it picks the
|
|
// override up.
|
|
DevFixedCode string
|
|
}
|
|
|
|
// AuthChallengeThrottleConfig bounds how many un-consumed, non-expired
|
|
// challenges a single email may hold inside a sliding window before the
|
|
// auth service starts reusing the most recent existing challenge instead
|
|
// of issuing a new one.
|
|
type AuthChallengeThrottleConfig struct {
|
|
Window time.Duration
|
|
Max int
|
|
}
|
|
|
|
// EngineConfig configures the per-call timeouts of `engineclient` against
|
|
// running game-engine containers. CallTimeout bounds turn-generation-class
|
|
// operations (init, turn, banish, command, order); ProbeTimeout bounds
|
|
// inspect-style reads (status, report, healthz).
|
|
type EngineConfig struct {
|
|
CallTimeout time.Duration
|
|
ProbeTimeout time.Duration
|
|
}
|
|
|
|
// RuntimeConfig configures the runtime module: worker pool, reconciliation
|
|
// cadence, image-pull policy, and per-container resource defaults applied
|
|
// at engine container creation time.
|
|
type RuntimeConfig struct {
|
|
// WorkerPoolSize bounds the number of concurrent long-running runtime
|
|
// jobs (image pull, container start, restart, patch).
|
|
WorkerPoolSize int
|
|
|
|
// JobQueueSize is the buffered job channel capacity. Once full, new
|
|
// runtime requests block briefly until a worker frees a slot.
|
|
JobQueueSize int
|
|
|
|
// ReconcileInterval bounds how often the runtime reconciler reads the
|
|
// Docker daemon's labelled containers and reconciles them against
|
|
// `runtime_records`.
|
|
ReconcileInterval time.Duration
|
|
|
|
// ImagePullPolicy selects the dockerclient pull behaviour:
|
|
// `if_missing`, `always`, or `never`.
|
|
ImagePullPolicy string
|
|
|
|
// ContainerLogDriver is the Docker log driver applied to every engine
|
|
// container created by the runtime (e.g., `json-file`).
|
|
ContainerLogDriver string
|
|
|
|
// ContainerLogOpts is the comma-separated `key=value` list passed to
|
|
// the log driver. May be empty.
|
|
ContainerLogOpts string
|
|
|
|
// ContainerCPUQuota is the `--cpus` value applied as a resource limit
|
|
// on each engine container.
|
|
ContainerCPUQuota float64
|
|
|
|
// ContainerMemory is the `--memory` value (e.g. `512m`).
|
|
ContainerMemory string
|
|
|
|
// ContainerPIDsLimit is the `--pids-limit` value.
|
|
ContainerPIDsLimit int
|
|
|
|
// ContainerStateMount is the absolute in-container path the per-game
|
|
// state directory is bind-mounted at.
|
|
ContainerStateMount string
|
|
|
|
// StopGracePeriod is the docker stop SIGTERM-to-SIGKILL grace period
|
|
// applied during stop / cancel / restart / patch.
|
|
StopGracePeriod time.Duration
|
|
}
|
|
|
|
// NotificationConfig configures the notification fan-out module
|
|
// implemented in `backend/internal/notification`. AdminEmail receives
|
|
// admin-channel kinds (the `runtime.*` set in `backend/README.md` §10);
|
|
// when empty, admin-email routes are recorded as `skipped`. WorkerInterval
|
|
// bounds how often the route worker scans for due rows; MaxAttempts caps
|
|
// route delivery retries before dead-lettering.
|
|
type NotificationConfig struct {
|
|
AdminEmail string
|
|
WorkerInterval time.Duration
|
|
MaxAttempts int
|
|
}
|
|
|
|
// LobbyConfig configures the lobby module: the periodic sweeper interval,
|
|
// the lifetime of `pending_registration` Race Name Directory entries, and
|
|
// the default expiry applied to invites that omit `expires_at`.
|
|
type LobbyConfig struct {
|
|
// SweeperInterval bounds how often the lobby sweeper goroutine wakes
|
|
// up to release expired pending_registration rows and to auto-close
|
|
// enrollment-expired games.
|
|
SweeperInterval time.Duration
|
|
|
|
// PendingRegistrationTTL bounds how long a `pending_registration`
|
|
// Race Name Directory row stays available for promotion via
|
|
// `lobby.race_name.register` before the sweeper releases it.
|
|
PendingRegistrationTTL time.Duration
|
|
|
|
// InviteDefaultTTL is the expiry applied to invites whose request body
|
|
// omits an explicit `expires_at`.
|
|
InviteDefaultTTL time.Duration
|
|
}
|
|
|
|
// DefaultConfig returns a Config pre-filled with the defaults documented in
|
|
// README §4. The required string fields (Postgres.DSN, SMTP.Host, SMTP.From,
|
|
// Docker.Network, Game.StateRoot, GeoIP.DBPath) remain zero-valued and must be
|
|
// supplied by callers (or by LoadFromEnv).
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
ShutdownTimeout: defaultShutdownTimeout,
|
|
Logging: LoggingConfig{
|
|
Level: defaultLoggingLevel,
|
|
},
|
|
HTTP: HTTPConfig{
|
|
Addr: defaultHTTPListenAddr,
|
|
ReadTimeout: defaultHTTPReadTimeout,
|
|
WriteTimeout: defaultHTTPWriteTimeout,
|
|
ShutdownTimeout: defaultHTTPShutdownTimeout,
|
|
},
|
|
GRPCPush: GRPCPushConfig{
|
|
Addr: defaultGRPCPushListenAddr,
|
|
ShutdownTimeout: defaultGRPCPushShutdownTimeout,
|
|
},
|
|
Postgres: PostgresConfig{
|
|
MaxConns: defaultPostgresMaxConns,
|
|
MinConns: defaultPostgresMinConns,
|
|
OperationTimeout: defaultPostgresOperationTimeout,
|
|
},
|
|
SMTP: SMTPConfig{
|
|
Port: defaultSMTPPort,
|
|
TLSMode: defaultSMTPTLSMode,
|
|
},
|
|
Mail: MailConfig{
|
|
WorkerInterval: defaultMailWorkerInterval,
|
|
MaxAttempts: defaultMailMaxAttempts,
|
|
},
|
|
Docker: DockerConfig{
|
|
Host: defaultDockerHost,
|
|
},
|
|
Telemetry: TelemetryConfig{
|
|
ServiceName: defaultServiceName,
|
|
TracesExporter: defaultOTelTracesExporter,
|
|
MetricsExporter: defaultOTelMetricsExporter,
|
|
Protocol: defaultOTelProtocol,
|
|
PrometheusListenAddr: defaultOTelPrometheusListenAddr,
|
|
},
|
|
FreshnessWindow: defaultFreshnessWindow,
|
|
Auth: AuthConfig{
|
|
ChallengeTTL: defaultAuthChallengeTTL,
|
|
ChallengeMaxAttempts: defaultAuthChallengeMaxAttempts,
|
|
ChallengeThrottle: AuthChallengeThrottleConfig{
|
|
Window: defaultAuthChallengeThrottleWindow,
|
|
Max: defaultAuthChallengeThrottleMax,
|
|
},
|
|
UserNameMaxRetries: defaultAuthUserNameMaxRetries,
|
|
},
|
|
Lobby: LobbyConfig{
|
|
SweeperInterval: defaultLobbySweeperInterval,
|
|
PendingRegistrationTTL: defaultLobbyPendingRegistrationTTL,
|
|
InviteDefaultTTL: defaultLobbyInviteDefaultTTL,
|
|
},
|
|
Engine: EngineConfig{
|
|
CallTimeout: defaultEngineCallTimeout,
|
|
ProbeTimeout: defaultEngineProbeTimeout,
|
|
},
|
|
Notification: NotificationConfig{
|
|
WorkerInterval: defaultNotificationWorkerInterval,
|
|
MaxAttempts: defaultNotificationMaxAttempts,
|
|
},
|
|
DevSandbox: DevSandboxConfig{
|
|
EngineVersion: defaultDevSandboxEngineVersion,
|
|
PlayerCount: defaultDevSandboxPlayerCount,
|
|
},
|
|
Runtime: RuntimeConfig{
|
|
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
|
JobQueueSize: defaultRuntimeJobQueueSize,
|
|
ReconcileInterval: defaultRuntimeReconcileInterval,
|
|
ImagePullPolicy: defaultRuntimeImagePullPolicy,
|
|
ContainerLogDriver: defaultRuntimeContainerLogDriver,
|
|
ContainerCPUQuota: defaultRuntimeContainerCPUQuota,
|
|
ContainerMemory: defaultRuntimeContainerMemory,
|
|
ContainerPIDsLimit: defaultRuntimeContainerPIDsLimit,
|
|
ContainerStateMount: defaultRuntimeContainerStateMount,
|
|
StopGracePeriod: defaultRuntimeStopGracePeriod,
|
|
},
|
|
}
|
|
}
|
|
|
|
// LoadFromEnv loads Config from environment variables, applying the
|
|
// DefaultConfig values for any variable that is not set, and validates the
|
|
// result. The returned Config is safe to use without further modification.
|
|
func LoadFromEnv() (Config, error) {
|
|
cfg := DefaultConfig()
|
|
|
|
shutdownTimeout, err := loadDuration(envShutdownTimeout, cfg.ShutdownTimeout)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.ShutdownTimeout = shutdownTimeout
|
|
|
|
cfg.Logging.Level = loadString(envLoggingLevel, cfg.Logging.Level)
|
|
|
|
cfg.HTTP.Addr = loadString(envHTTPListenAddr, cfg.HTTP.Addr)
|
|
if cfg.HTTP.ReadTimeout, err = loadDuration(envHTTPReadTimeout, cfg.HTTP.ReadTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.HTTP.WriteTimeout, err = loadDuration(envHTTPWriteTimeout, cfg.HTTP.WriteTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.HTTP.ShutdownTimeout, err = loadDuration(envHTTPShutdownTimeout, cfg.HTTP.ShutdownTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
cfg.GRPCPush.Addr = loadString(envGRPCPushListenAddr, cfg.GRPCPush.Addr)
|
|
if cfg.GRPCPush.ShutdownTimeout, err = loadDuration(envGRPCPushShutdownTimeout, cfg.GRPCPush.ShutdownTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
cfg.Postgres.DSN = loadString(envPostgresDSN, cfg.Postgres.DSN)
|
|
if cfg.Postgres.MaxConns, err = loadInt(envPostgresMaxConns, cfg.Postgres.MaxConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Postgres.MinConns, err = loadInt(envPostgresMinConns, cfg.Postgres.MinConns); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Postgres.OperationTimeout, err = loadDuration(envPostgresOperationTimeout, cfg.Postgres.OperationTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
cfg.SMTP.Host = loadString(envSMTPHost, cfg.SMTP.Host)
|
|
if cfg.SMTP.Port, err = loadInt(envSMTPPort, cfg.SMTP.Port); err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.SMTP.Username = loadString(envSMTPUsername, cfg.SMTP.Username)
|
|
cfg.SMTP.Password = loadString(envSMTPPassword, cfg.SMTP.Password)
|
|
cfg.SMTP.From = loadString(envSMTPFrom, cfg.SMTP.From)
|
|
cfg.SMTP.TLSMode = loadString(envSMTPTLSMode, cfg.SMTP.TLSMode)
|
|
|
|
if cfg.Mail.WorkerInterval, err = loadDuration(envMailWorkerInterval, cfg.Mail.WorkerInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Mail.MaxAttempts, err = loadInt(envMailMaxAttempts, cfg.Mail.MaxAttempts); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
cfg.Docker.Host = loadString(envDockerHost, cfg.Docker.Host)
|
|
cfg.Docker.Network = loadString(envDockerNetwork, cfg.Docker.Network)
|
|
|
|
cfg.Game.StateRoot = loadString(envGameStateRoot, cfg.Game.StateRoot)
|
|
|
|
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
|
|
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
|
|
|
|
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
|
|
|
|
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
|
|
cfg.Telemetry.MetricsExporter = strings.ToLower(loadString(envOTelMetricsExporter, cfg.Telemetry.MetricsExporter))
|
|
cfg.Telemetry.Protocol = strings.ToLower(loadString(envOTelProtocol, cfg.Telemetry.Protocol))
|
|
cfg.Telemetry.Endpoint = loadString(envOTelEndpoint, cfg.Telemetry.Endpoint)
|
|
cfg.Telemetry.PrometheusListenAddr = loadString(envOTelPrometheusListenAddr, cfg.Telemetry.PrometheusListenAddr)
|
|
cfg.Telemetry.ServiceName = loadString(envServiceName, cfg.Telemetry.ServiceName)
|
|
|
|
if cfg.FreshnessWindow, err = loadDuration(envFreshnessWindow, cfg.FreshnessWindow); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if cfg.Auth.ChallengeTTL, err = loadDuration(envAuthChallengeTTL, cfg.Auth.ChallengeTTL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Auth.ChallengeMaxAttempts, err = loadInt(envAuthChallengeMaxAttempts, cfg.Auth.ChallengeMaxAttempts); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Auth.ChallengeThrottle.Window, err = loadDuration(envAuthChallengeThrottleWindow, cfg.Auth.ChallengeThrottle.Window); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Auth.ChallengeThrottle.Max, err = loadInt(envAuthChallengeThrottleMax, cfg.Auth.ChallengeThrottle.Max); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Auth.UserNameMaxRetries, err = loadInt(envAuthUserNameMaxRetries, cfg.Auth.UserNameMaxRetries); err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.Auth.DevFixedCode = loadString(envAuthDevFixedCode, cfg.Auth.DevFixedCode)
|
|
|
|
if cfg.Lobby.SweeperInterval, err = loadDuration(envLobbySweeperInterval, cfg.Lobby.SweeperInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Lobby.PendingRegistrationTTL, err = loadDuration(envLobbyPendingRegistrationTTL, cfg.Lobby.PendingRegistrationTTL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Lobby.InviteDefaultTTL, err = loadDuration(envLobbyInviteDefaultTTL, cfg.Lobby.InviteDefaultTTL); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if cfg.Engine.CallTimeout, err = loadDuration(envEngineCallTimeout, cfg.Engine.CallTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Engine.ProbeTimeout, err = loadDuration(envEngineProbeTimeout, cfg.Engine.ProbeTimeout); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if cfg.Runtime.WorkerPoolSize, err = loadInt(envRuntimeWorkerPoolSize, cfg.Runtime.WorkerPoolSize); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Runtime.JobQueueSize, err = loadInt(envRuntimeJobQueueSize, cfg.Runtime.JobQueueSize); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Runtime.ReconcileInterval, err = loadDuration(envRuntimeReconcileInterval, cfg.Runtime.ReconcileInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.Runtime.ImagePullPolicy = strings.ToLower(loadString(envRuntimeImagePullPolicy, cfg.Runtime.ImagePullPolicy))
|
|
cfg.Runtime.ContainerLogDriver = loadString(envRuntimeContainerLogDriver, cfg.Runtime.ContainerLogDriver)
|
|
cfg.Runtime.ContainerLogOpts = loadString(envRuntimeContainerLogOpts, cfg.Runtime.ContainerLogOpts)
|
|
if cfg.Runtime.ContainerCPUQuota, err = loadFloat(envRuntimeContainerCPUQuota, cfg.Runtime.ContainerCPUQuota); err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.Runtime.ContainerMemory = loadString(envRuntimeContainerMemory, cfg.Runtime.ContainerMemory)
|
|
if cfg.Runtime.ContainerPIDsLimit, err = loadInt(envRuntimeContainerPIDsLimit, cfg.Runtime.ContainerPIDsLimit); err != nil {
|
|
return Config{}, err
|
|
}
|
|
cfg.Runtime.ContainerStateMount = loadString(envRuntimeContainerStateMount, cfg.Runtime.ContainerStateMount)
|
|
if cfg.Runtime.StopGracePeriod, err = loadDuration(envRuntimeStopGracePeriod, cfg.Runtime.StopGracePeriod); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
cfg.Notification.AdminEmail = loadString(envNotificationAdminEmail, cfg.Notification.AdminEmail)
|
|
if cfg.Notification.WorkerInterval, err = loadDuration(envNotificationWorkerInterval, cfg.Notification.WorkerInterval); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if cfg.Notification.MaxAttempts, err = loadInt(envNotificationMaxAttempts, cfg.Notification.MaxAttempts); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
|
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
|
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
|
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// Validate enforces the documented invariants from README §4. Required string
|
|
// fields must be non-empty; closed-set string options must match the allowed
|
|
// values; numeric and duration fields must be positive.
|
|
func (c Config) Validate() error {
|
|
if c.ShutdownTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envShutdownTimeout)
|
|
}
|
|
if strings.TrimSpace(c.Logging.Level) == "" {
|
|
return fmt.Errorf("%s must not be empty", envLoggingLevel)
|
|
}
|
|
|
|
if strings.TrimSpace(c.HTTP.Addr) == "" {
|
|
return fmt.Errorf("%s must not be empty", envHTTPListenAddr)
|
|
}
|
|
if c.HTTP.ReadTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envHTTPReadTimeout)
|
|
}
|
|
if c.HTTP.WriteTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envHTTPWriteTimeout)
|
|
}
|
|
if c.HTTP.ShutdownTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envHTTPShutdownTimeout)
|
|
}
|
|
|
|
if strings.TrimSpace(c.GRPCPush.Addr) == "" {
|
|
return fmt.Errorf("%s must not be empty", envGRPCPushListenAddr)
|
|
}
|
|
if c.GRPCPush.ShutdownTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envGRPCPushShutdownTimeout)
|
|
}
|
|
|
|
if strings.TrimSpace(c.Postgres.DSN) == "" {
|
|
return fmt.Errorf("%s must be set", envPostgresDSN)
|
|
}
|
|
if c.Postgres.MaxConns <= 0 {
|
|
return fmt.Errorf("%s must be positive", envPostgresMaxConns)
|
|
}
|
|
if c.Postgres.MinConns < 0 {
|
|
return fmt.Errorf("%s must not be negative", envPostgresMinConns)
|
|
}
|
|
if c.Postgres.MinConns > c.Postgres.MaxConns {
|
|
return fmt.Errorf("%s must not exceed %s", envPostgresMinConns, envPostgresMaxConns)
|
|
}
|
|
if c.Postgres.OperationTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envPostgresOperationTimeout)
|
|
}
|
|
|
|
if strings.TrimSpace(c.SMTP.Host) == "" {
|
|
return fmt.Errorf("%s must be set", envSMTPHost)
|
|
}
|
|
if c.SMTP.Port <= 0 || c.SMTP.Port > 65535 {
|
|
return fmt.Errorf("%s must be a valid TCP port (got %d)", envSMTPPort, c.SMTP.Port)
|
|
}
|
|
if strings.TrimSpace(c.SMTP.From) == "" {
|
|
return fmt.Errorf("%s must be set", envSMTPFrom)
|
|
}
|
|
if !containsString(allowedSMTPTLSModes, c.SMTP.TLSMode) {
|
|
return fmt.Errorf("%s must be one of %v (got %q)", envSMTPTLSMode, allowedSMTPTLSModes, c.SMTP.TLSMode)
|
|
}
|
|
|
|
if c.Mail.WorkerInterval <= 0 {
|
|
return fmt.Errorf("%s must be positive", envMailWorkerInterval)
|
|
}
|
|
if c.Mail.MaxAttempts <= 0 {
|
|
return fmt.Errorf("%s must be positive", envMailMaxAttempts)
|
|
}
|
|
|
|
if strings.TrimSpace(c.Docker.Host) == "" {
|
|
return fmt.Errorf("%s must not be empty", envDockerHost)
|
|
}
|
|
if strings.TrimSpace(c.Docker.Network) == "" {
|
|
return fmt.Errorf("%s must be set", envDockerNetwork)
|
|
}
|
|
|
|
if strings.TrimSpace(c.Game.StateRoot) == "" {
|
|
return fmt.Errorf("%s must be set", envGameStateRoot)
|
|
}
|
|
|
|
if c.Admin.User != "" && c.Admin.Password == "" {
|
|
return fmt.Errorf("%s requires %s", envAdminBootstrapUser, envAdminBootstrapPassword)
|
|
}
|
|
|
|
if strings.TrimSpace(c.GeoIP.DBPath) == "" {
|
|
return fmt.Errorf("%s must be set", envGeoIPDBPath)
|
|
}
|
|
|
|
if !containsString(allowedTracesExporters, c.Telemetry.TracesExporter) {
|
|
return fmt.Errorf("%s must be one of %v (got %q)", envOTelTracesExporter, allowedTracesExporters, c.Telemetry.TracesExporter)
|
|
}
|
|
if !containsString(allowedMetricsExporters, c.Telemetry.MetricsExporter) {
|
|
return fmt.Errorf("%s must be one of %v (got %q)", envOTelMetricsExporter, allowedMetricsExporters, c.Telemetry.MetricsExporter)
|
|
}
|
|
if c.Telemetry.TracesExporter == "otlp" || c.Telemetry.MetricsExporter == "otlp" {
|
|
if !containsString(allowedOTelProtocols, c.Telemetry.Protocol) {
|
|
return fmt.Errorf("%s must be one of %v (got %q)", envOTelProtocol, allowedOTelProtocols, c.Telemetry.Protocol)
|
|
}
|
|
}
|
|
if c.Telemetry.MetricsExporter == "prometheus" && strings.TrimSpace(c.Telemetry.PrometheusListenAddr) == "" {
|
|
return fmt.Errorf("%s must be set when %s is %q", envOTelPrometheusListenAddr, envOTelMetricsExporter, "prometheus")
|
|
}
|
|
if strings.TrimSpace(c.Telemetry.ServiceName) == "" {
|
|
return fmt.Errorf("%s must not be empty", envServiceName)
|
|
}
|
|
|
|
if c.FreshnessWindow <= 0 {
|
|
return fmt.Errorf("%s must be positive", envFreshnessWindow)
|
|
}
|
|
|
|
if c.Auth.ChallengeTTL <= 0 {
|
|
return fmt.Errorf("%s must be positive", envAuthChallengeTTL)
|
|
}
|
|
if c.Auth.ChallengeMaxAttempts <= 0 {
|
|
return fmt.Errorf("%s must be positive", envAuthChallengeMaxAttempts)
|
|
}
|
|
if c.Auth.ChallengeThrottle.Window <= 0 {
|
|
return fmt.Errorf("%s must be positive", envAuthChallengeThrottleWindow)
|
|
}
|
|
if c.Auth.ChallengeThrottle.Max <= 0 {
|
|
return fmt.Errorf("%s must be positive", envAuthChallengeThrottleMax)
|
|
}
|
|
if c.Auth.UserNameMaxRetries <= 0 {
|
|
return fmt.Errorf("%s must be positive", envAuthUserNameMaxRetries)
|
|
}
|
|
if c.Auth.DevFixedCode != "" {
|
|
if !isDecimalString(c.Auth.DevFixedCode, 6) {
|
|
return fmt.Errorf("%s must be a six-digit decimal string when set", envAuthDevFixedCode)
|
|
}
|
|
}
|
|
|
|
if c.Lobby.SweeperInterval <= 0 {
|
|
return fmt.Errorf("%s must be positive", envLobbySweeperInterval)
|
|
}
|
|
if c.Lobby.PendingRegistrationTTL <= 0 {
|
|
return fmt.Errorf("%s must be positive", envLobbyPendingRegistrationTTL)
|
|
}
|
|
if c.Lobby.InviteDefaultTTL <= 0 {
|
|
return fmt.Errorf("%s must be positive", envLobbyInviteDefaultTTL)
|
|
}
|
|
|
|
if c.Engine.CallTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envEngineCallTimeout)
|
|
}
|
|
if c.Engine.ProbeTimeout <= 0 {
|
|
return fmt.Errorf("%s must be positive", envEngineProbeTimeout)
|
|
}
|
|
|
|
if c.Runtime.WorkerPoolSize <= 0 {
|
|
return fmt.Errorf("%s must be positive", envRuntimeWorkerPoolSize)
|
|
}
|
|
if c.Runtime.JobQueueSize <= 0 {
|
|
return fmt.Errorf("%s must be positive", envRuntimeJobQueueSize)
|
|
}
|
|
if c.Runtime.ReconcileInterval <= 0 {
|
|
return fmt.Errorf("%s must be positive", envRuntimeReconcileInterval)
|
|
}
|
|
if !containsString(allowedPullPolicies, c.Runtime.ImagePullPolicy) {
|
|
return fmt.Errorf("%s must be one of %v (got %q)", envRuntimeImagePullPolicy, allowedPullPolicies, c.Runtime.ImagePullPolicy)
|
|
}
|
|
if strings.TrimSpace(c.Runtime.ContainerLogDriver) == "" {
|
|
return fmt.Errorf("%s must not be empty", envRuntimeContainerLogDriver)
|
|
}
|
|
if c.Runtime.ContainerCPUQuota <= 0 {
|
|
return fmt.Errorf("%s must be positive", envRuntimeContainerCPUQuota)
|
|
}
|
|
if strings.TrimSpace(c.Runtime.ContainerMemory) == "" {
|
|
return fmt.Errorf("%s must not be empty", envRuntimeContainerMemory)
|
|
}
|
|
if c.Runtime.ContainerPIDsLimit <= 0 {
|
|
return fmt.Errorf("%s must be positive", envRuntimeContainerPIDsLimit)
|
|
}
|
|
if !strings.HasPrefix(strings.TrimSpace(c.Runtime.ContainerStateMount), "/") {
|
|
return fmt.Errorf("%s must be an absolute path (got %q)", envRuntimeContainerStateMount, c.Runtime.ContainerStateMount)
|
|
}
|
|
if c.Runtime.StopGracePeriod <= 0 {
|
|
return fmt.Errorf("%s must be positive", envRuntimeStopGracePeriod)
|
|
}
|
|
|
|
if c.Notification.WorkerInterval <= 0 {
|
|
return fmt.Errorf("%s must be positive", envNotificationWorkerInterval)
|
|
}
|
|
if c.Notification.MaxAttempts <= 0 {
|
|
return fmt.Errorf("%s must be positive", envNotificationMaxAttempts)
|
|
}
|
|
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
|
|
if _, err := netmail.ParseAddress(email); err != nil {
|
|
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
|
}
|
|
}
|
|
|
|
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
|
|
if _, err := netmail.ParseAddress(email); err != nil {
|
|
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
|
|
}
|
|
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
|
|
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
|
|
}
|
|
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
|
|
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
|
|
}
|
|
if c.DevSandbox.PlayerCount <= 0 {
|
|
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isDecimalString(value string, length int) bool {
|
|
if len(value) != length {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func loadString(name, fallback string) string {
|
|
raw, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback
|
|
}
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return fallback
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func loadInt(name string, fallback int) (int, error) {
|
|
raw, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback, nil
|
|
}
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.Atoi(trimmed)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func loadFloat(name string, fallback float64) (float64, error) {
|
|
raw, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback, nil
|
|
}
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.ParseFloat(trimmed, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func loadDuration(name string, fallback time.Duration) (time.Duration, error) {
|
|
raw, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return fallback, nil
|
|
}
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := time.ParseDuration(trimmed)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s: %w", name, err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func containsString(set []string, value string) bool {
|
|
return slices.Contains(set, value)
|
|
}
|