diff options
Diffstat (limited to 'internal/config')
| -rw-r--r-- | internal/config/config.go | 153 | ||||
| -rw-r--r-- | internal/config/config_test.go | 76 |
2 files changed, 229 insertions, 0 deletions
diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b173dbf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,153 @@ +// Package config loads the bot's settings from the process environment. +// +// The environment is the only source: there is no configuration file and no +// command-line flag. That keeps the token out of the repository and out of the +// image, and it is what the container platform hands the process anyway. +package config + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strings" + "time" +) + +// The environment variables read by [Load]. +const ( + // EnvToken holds the token issued by @BotFather. Required; [Load] fails + // when it is missing or blank. + EnvToken = "TELEGRAM_BOT_TOKEN" + + // EnvCepisBaseURL points the lookups at another instance of the API, + // typically a local one during development. + EnvCepisBaseURL = "CEPIS_BASE_URL" + + // EnvTelegramAPIBaseURL points the bot at another Bot API host. It + // exists for local Bot API servers and for the test suite, which stands + // up a fake Telegram in-process. + EnvTelegramAPIBaseURL = "TELEGRAM_API_BASE_URL" + + // EnvLogLevel selects the minimum level written to stdout: debug, info, + // warn or error. + EnvLogLevel = "LOG_LEVEL" +) + +// ErrMissingToken is returned by [Load] when [EnvToken] is absent or blank. +var ErrMissingToken = errors.New("config: " + EnvToken + " is not set") + +// Config is the fully resolved configuration of one bot process. +// +// Only Token, CepisBaseURL, TelegramAPIBaseURL and LogLevel come from the +// environment. The remaining fields are tuning that has no reason to differ +// between deployments; they are exported so the test suite can shorten the +// waits it would otherwise sit through. +type Config struct { + // Token is the @BotFather token. It is never logged: [Config.LogValue] + // redacts it, and the Telegram client scrubs it out of error messages. + Token string + + // CepisBaseURL is where address lookups go. + CepisBaseURL string + + // TelegramAPIBaseURL is the Bot API host, without a trailing slash and + // without the /bot<token> prefix. + TelegramAPIBaseURL string + + // PollTimeout is the long-polling window asked of getUpdates. Telegram + // holds the request open for this long when there is nothing to send, + // which is what makes polling cheap. + PollTimeout time.Duration + + // RequestTimeout caps a single non-polling HTTP request. + RequestTimeout time.Duration + + // MaxAttempts is how many times a transient failure is retried, the + // first attempt included. + MaxAttempts int + + // BackoffBase and BackoffMax bound the jittered wait between attempts. + BackoffBase time.Duration + BackoffMax time.Duration + + // ShutdownGrace is how long an in-flight update may take to finish + // after a termination signal, and how long the final offset + // confirmation may take. + ShutdownGrace time.Duration + + // LogLevel is the minimum level written to stdout. + LogLevel slog.Level +} + +// Defaults returns the configuration with everything filled in except Token, +// which has no safe default. +func Defaults() Config { + return Config{ + CepisBaseURL: "https://cep.is", + TelegramAPIBaseURL: "https://api.telegram.org", + PollTimeout: 30 * time.Second, + RequestTimeout: 10 * time.Second, + MaxAttempts: 3, + BackoffBase: 500 * time.Millisecond, + BackoffMax: 8 * time.Second, + ShutdownGrace: 10 * time.Second, + LogLevel: slog.LevelInfo, + } +} + +// Load reads the environment on top of [Defaults]. +// +// It fails with [ErrMissingToken] when the token is absent, before any network +// is touched, so a misconfigured deployment dies at start-up rather than +// looking healthy while answering nobody. +func Load() (Config, error) { + cfg := Defaults() + + cfg.Token = strings.TrimSpace(os.Getenv(EnvToken)) + if cfg.Token == "" { + return Config{}, ErrMissingToken + } + + if value := strings.TrimSpace(os.Getenv(EnvCepisBaseURL)); value != "" { + cfg.CepisBaseURL = strings.TrimRight(value, "/") + } + if value := strings.TrimSpace(os.Getenv(EnvTelegramAPIBaseURL)); value != "" { + cfg.TelegramAPIBaseURL = strings.TrimRight(value, "/") + } + if value := strings.TrimSpace(os.Getenv(EnvLogLevel)); value != "" { + level, err := parseLevel(value) + if err != nil { + return Config{}, err + } + cfg.LogLevel = level + } + return cfg, nil +} + +// LogValue implements [log/slog.LogValuer] so that logging the whole +// configuration cannot leak the token, however carelessly it is done. +func (c Config) LogValue() slog.Value { + return slog.GroupValue( + slog.String("cepis_base_url", c.CepisBaseURL), + slog.String("telegram_api_base_url", c.TelegramAPIBaseURL), + slog.Duration("poll_timeout", c.PollTimeout), + slog.Int("max_attempts", c.MaxAttempts), + slog.String("log_level", c.LogLevel.String()), + slog.Bool("token_set", c.Token != ""), + ) +} + +func parseLevel(value string) (slog.Level, error) { + switch strings.ToLower(value) { + case "debug": + return slog.LevelDebug, nil + case "info": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + } + return 0, fmt.Errorf("config: %s must be debug, info, warn or error, got %q", EnvLogLevel, value) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7ae0d95 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,76 @@ +package config + +import ( + "errors" + "log/slog" + "strings" + "testing" +) + +func TestLoadRequiresTheToken(t *testing.T) { + t.Setenv(EnvToken, "") + if _, err := Load(); !errors.Is(err, ErrMissingToken) { + t.Fatalf("Load error = %v, want ErrMissingToken", err) + } +} + +func TestLoadDefaults(t *testing.T) { + t.Setenv(EnvToken, "424242:not-a-real-token") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.CepisBaseURL != "https://cep.is" { + t.Fatalf("base URL = %q", cfg.CepisBaseURL) + } + if cfg.TelegramAPIBaseURL != "https://api.telegram.org" { + t.Fatalf("telegram base URL = %q", cfg.TelegramAPIBaseURL) + } + if cfg.LogLevel != slog.LevelInfo { + t.Fatalf("log level = %v", cfg.LogLevel) + } +} + +func TestLoadOverrides(t *testing.T) { + t.Setenv(EnvToken, "424242:not-a-real-token") + t.Setenv(EnvCepisBaseURL, "http://localhost:8080/") + t.Setenv(EnvTelegramAPIBaseURL, "http://127.0.0.1:9000/") + t.Setenv(EnvLogLevel, "debug") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.CepisBaseURL != "http://localhost:8080" { + t.Fatalf("base URL = %q, the trailing slash should be gone", cfg.CepisBaseURL) + } + if cfg.TelegramAPIBaseURL != "http://127.0.0.1:9000" { + t.Fatalf("telegram base URL = %q", cfg.TelegramAPIBaseURL) + } + if cfg.LogLevel != slog.LevelDebug { + t.Fatalf("log level = %v", cfg.LogLevel) + } +} + +func TestLoadRejectsAnUnknownLogLevel(t *testing.T) { + t.Setenv(EnvToken, "424242:not-a-real-token") + t.Setenv(EnvLogLevel, "verbose") + + if _, err := Load(); err == nil { + t.Fatal("an unknown log level should be an error") + } +} + +func TestLogValueRedactsTheToken(t *testing.T) { + cfg := Defaults() + cfg.Token = "424242:not-a-real-token" + + rendered := cfg.LogValue().String() + if strings.Contains(rendered, cfg.Token) { + t.Fatalf("the token leaked into the log value: %s", rendered) + } + if !strings.Contains(rendered, "token_set=true") { + t.Fatalf("log value = %s", rendered) + } +} |