From 9f5a594cf7fb37c4f158c7ec4ae11ffbab6d04a5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 22 Jun 2026 14:16:15 +0700 Subject: [PATCH] fix(deploy): harden health and status config --- .env.example | 1 - Dockerfile | 1 + README.md | 4 +++- cmd/openai-status-bot/main.go | 5 +++- docker-compose.yml | 2 +- docs/setup-guide.md | 6 ++++- docs/system-architecture.md | 8 ++++--- internal/bot/format.go | 2 +- internal/config/config.go | 35 +++++---------------------- internal/config/config_test.go | 14 +++++++---- internal/health/server.go | 44 ++++++++++++++++++++++++++++++++++ internal/health/server_test.go | 21 ++++++++++++++++ internal/openai/client.go | 8 ++++++- internal/openai/client_test.go | 4 ++-- 14 files changed, 109 insertions(+), 46 deletions(-) create mode 100644 internal/health/server.go create mode 100644 internal/health/server_test.go diff --git a/.env.example b/.env.example index 29574b9..5f67d54 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ TELEGRAM_BOT_TOKEN= REDIS_URL=redis://localhost:6379/0 -OPENAI_STATUS_BASE_URL=https://status.openai.com POLL_INTERVAL=1m HTTP_TIMEOUT=10s LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile index 4e09b9c..4120c03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,5 @@ FROM alpine:3.21 RUN adduser -D -H appuser USER appuser COPY --from=build /out/openai-status-bot /usr/local/bin/openai-status-bot +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 ENTRYPOINT ["openai-status-bot"] diff --git a/README.md b/README.md index 1813cfe..887a97e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Telegram bot that watches [OpenAI Status](https://status.openai.com/) every minu - Supports Telegram supergroup topics via `message_thread_id` - Clears an existing Telegram webhook before long polling, for migration from webhook deployments - Registers the Telegram command menu on startup +- Exposes a local health endpoint for container health checks - Includes Docker Compose for local Redis + bot runtime ## Bot Commands @@ -58,13 +59,14 @@ go run ./cmd/openai-status-bot |----------|---------|-------------| | `TELEGRAM_BOT_TOKEN` | required | Telegram bot token from BotFather | | `REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL, e.g. `redis://:password@localhost:6379/0` | -| `OPENAI_STATUS_BASE_URL` | `https://status.openai.com` | OpenAI status page base URL | | `POLL_INTERVAL` | `1m` | Status check interval, valid `5s`-`1h` | | `HTTP_TIMEOUT` | `10s` | HTTP request timeout, valid `1s`-`5m` | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`/`warning`, or `error` | Percent-encode Redis usernames or passwords that contain URL-reserved characters such as `@`, `:`, `/`, `#`, or `%`. +The bot always reads OpenAI status from `https://status.openai.com`. + ## Notes The first successful poll seeds Redis and does not send historical incidents. Notifications start from later changes. diff --git a/cmd/openai-status-bot/main.go b/cmd/openai-status-bot/main.go index 14442c7..151099e 100644 --- a/cmd/openai-status-bot/main.go +++ b/cmd/openai-status-bot/main.go @@ -10,6 +10,7 @@ import ( "github.com/redis/go-redis/v9" "github.com/tiennm99/openai-status-bot/internal/bot" "github.com/tiennm99/openai-status-bot/internal/config" + "github.com/tiennm99/openai-status-bot/internal/health" openai "github.com/tiennm99/openai-status-bot/internal/openai" "github.com/tiennm99/openai-status-bot/internal/poller" "github.com/tiennm99/openai-status-bot/internal/redisstore" @@ -31,6 +32,8 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + go health.Run(ctx, logger) + redisClient := redis.NewClient(cfg.RedisOptions) if err := redisClient.Ping(ctx).Err(); err != nil { logger.Error("connect redis", "network", cfg.RedisOptions.Network, "addr", cfg.RedisOptions.Addr, "db", cfg.RedisOptions.DB, "tls", cfg.RedisOptions.TLSConfig != nil, "error", err) @@ -39,7 +42,7 @@ func main() { defer redisClient.Close() store := redisstore.New(redisClient) - statusClient := openai.NewClient(cfg.OpenAIStatusBaseURL, cfg.HTTPTimeout) + statusClient := openai.NewClient(cfg.HTTPTimeout) telegramClient := telegram.NewClient(cfg.TelegramBotToken, cfg.HTTPTimeout) if err := telegramClient.DeleteWebhook(ctx); err != nil { logger.Error("delete telegram webhook", "error", err) diff --git a/docker-compose.yml b/docker-compose.yml index 17c69ff..855218d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: redis: image: redis:7-alpine ports: - - "6379:6379" + - "127.0.0.1:6379:6379" volumes: - redis-data:/data command: ["redis-server", "--appendonly", "yes"] diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 2c3717b..ec3d737 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -15,6 +15,8 @@ cp .env.example .env docker compose up --build ``` +The bundled Redis service is for local development and binds to `127.0.0.1:6379` on the host. + ## Bot-Only Docker Run Use this when Redis is hosted outside Docker Compose: @@ -31,7 +33,7 @@ docker compose -f docker-compose.bot.yml up -d --build Start Redis: ```bash -docker run --rm -p 6379:6379 redis:7-alpine +docker run --rm -p 127.0.0.1:6379:6379 redis:7-alpine ``` Run bot: @@ -51,3 +53,5 @@ go build -o /tmp/openai-status-bot ./cmd/openai-status-bot ``` Then send `/start` to the Telegram bot and run `/status`. If the same token was previously used by a webhook deployment, startup clears that webhook before long polling begins. + +Docker images include a health check against `http://127.0.0.1:8080/healthz` inside the container. diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 0c5adf6..425c276 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -15,8 +15,8 @@ Redis stores subscribers, subscription settings, polling checkpoints, delivery r 2. Bot stores chat ID, optional topic thread ID, and default subscription settings in Redis. 3. Users can adjust settings with `/subscribe` and inspect them with `/info`. 4. Poller fetches OpenAI status JSON: - - `GET /api/v2/summary.json` - - `GET /api/v2/incidents.json` + - `GET https://status.openai.com/api/v2/summary.json` + - `GET https://status.openai.com/api/v2/incidents.json` 5. Poller compares fetched state with Redis checkpoints and builds notification events without mutating checkpoints. 6. Events are sent to eligible subscribers, respecting incident/component preferences and component ID filters. 7. Component and incident checkpoints are written only after delivery succeeds or terminal subscriber failures are removed. @@ -28,6 +28,7 @@ Redis stores subscribers, subscription settings, polling checkpoints, delivery r | `openai-status:subscribers` | set | Telegram chat or topic subscribers | | `openai-status:subscriber-settings` | hash | Subscription types and component ID filters by subscriber key | | `openai-status:component-statuses` | hash | Last seen component status by component ID | +| `openai-status:pending-component-events` | hash | Component changes saved before fan-out so retryable delivery failures can be resumed | | `openai-status:incident-updates` | set | Legacy seen incident update IDs, retained as write-through metadata; version hash is authoritative | | `openai-status:incident-update-versions` | hash | Seen incident update version by update ID | | `openai-status:event-delivery:` | set | Temporary per-event subscriber delivery state for retry isolation | @@ -38,7 +39,7 @@ Subscriber set members are `chatID` or `chatID:threadID`. Missing settings defau ## Runtime -The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it calls `deleteWebhook` before long polling, registers the Telegram command menu, and then starts polling. Redis is configured with `REDIS_URL`. Docker Compose starts Redis and the bot. +The service uses Telegram `getUpdates`, so it does not need a public webhook URL. On startup it starts a local health endpoint at `127.0.0.1:8080/healthz`, calls `deleteWebhook` before long polling, registers the Telegram command menu, and then starts polling. Redis is configured with `REDIS_URL`. The OpenAI status source is fixed to `https://status.openai.com`. Docker Compose starts Redis and the bot for local development; the bundled Redis port is bound to `127.0.0.1` on the host. ## Failure Behavior @@ -46,6 +47,7 @@ The service uses Telegram `getUpdates`, so it does not need a public webhook URL - OpenAI fetch failures are logged and retried on the next interval. - Retryable Telegram failures do not advance component or incident checkpoints. - Successful per-subscriber deliveries are tracked temporarily, so retrying one failed subscriber does not resend to already-delivered subscribers. +- Pending component events are stored before delivery and removed only after successful delivery or terminal subscriber cleanup. - Telegram 403 and selected terminal 400 errors remove the unreachable subscriber, then delivery continues. - Malformed subscriber keys are removed from Redis and surfaced as an error for the current poll instead of being skipped silently. - Redis connection failure at startup exits the process. diff --git a/internal/bot/format.go b/internal/bot/format.go index 22150bf..2e9bb68 100644 --- a/internal/bot/format.go +++ b/internal/bot/format.go @@ -20,7 +20,7 @@ const helpText = `OpenAI Status Bot /status [component] - show current OpenAI status /components - show all component statuses /subscribe <incident|component|all> - set notification types -/subscribe component <name|all> - filter component updates +/subscribe component <name|id|all> - filter component updates /history [count] - show recent incidents, default 5, max 10 /uptime - show component health overview /info - show chat and subscription details diff --git a/internal/config/config.go b/internal/config/config.go index 5f6519d..d530afc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "fmt" "log/slog" - "net/url" "os" "strings" "time" @@ -12,12 +11,11 @@ import ( ) type Config struct { - TelegramBotToken string - RedisOptions *redis.Options - OpenAIStatusBaseURL string - PollInterval time.Duration - HTTPTimeout time.Duration - LogLevel slog.Level + TelegramBotToken string + RedisOptions *redis.Options + PollInterval time.Duration + HTTPTimeout time.Duration + LogLevel slog.Level } const ( @@ -31,8 +29,7 @@ const ( func LoadFromEnv() (Config, error) { cfg := Config{ - TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), - OpenAIStatusBaseURL: strings.TrimRight(getEnv("OPENAI_STATUS_BASE_URL", "https://status.openai.com"), "/"), + TelegramBotToken: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), } if cfg.TelegramBotToken == "" { return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required") @@ -42,9 +39,6 @@ func LoadFromEnv() (Config, error) { if err != nil { return Config{}, err } - if err := validateBaseURL("OPENAI_STATUS_BASE_URL", cfg.OpenAIStatusBaseURL); err != nil { - return Config{}, err - } cfg.PollInterval, err = parseDurationEnv("POLL_INTERVAL", time.Minute, minPollInterval, maxPollInterval) if err != nil { @@ -95,23 +89,6 @@ func parseRedisURL(key, value string) (*redis.Options, error) { return options, nil } -func validateBaseURL(key, value string) error { - parsed, err := url.Parse(value) - if err != nil { - return fmt.Errorf("%s must be a valid URL: %w", key, err) - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return fmt.Errorf("%s must use http or https", key) - } - if parsed.Host == "" { - return fmt.Errorf("%s must include a host", key) - } - if parsed.ForceQuery || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf("%s must not include query or fragment", key) - } - return nil -} - func parseLogLevel(value string) (slog.Level, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "debug": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 899a813..7f4bae3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -54,6 +54,15 @@ func TestLoadFromEnvAcceptsRedisURLCredentials(t *testing.T) { } } +func TestLoadFromEnvIgnoresOpenAIStatusBaseURL(t *testing.T) { + setMinimalEnv(t) + t.Setenv("OPENAI_STATUS_BASE_URL", "not a url") + + if _, err := LoadFromEnv(); err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } +} + func TestLoadFromEnvDoesNotLeakRedisURLCredentialsInParseErrors(t *testing.T) { setMinimalEnv(t) t.Setenv("REDIS_URL", "redis://:secret%zz@localhost:6379/0") @@ -96,10 +105,6 @@ func TestLoadFromEnvRejectsInvalidValues(t *testing.T) { {name: "redis url db above range", key: "REDIS_URL", value: "redis://localhost:6379/16", wantErr: "REDIS_URL database must be between 0 and 15"}, {name: "poll interval too small", key: "POLL_INTERVAL", value: "1ns", wantErr: "POLL_INTERVAL must be between 5s and 1h0m0s"}, {name: "http timeout too small", key: "HTTP_TIMEOUT", value: "500ms", wantErr: "HTTP_TIMEOUT must be between 1s and 5m0s"}, - {name: "base url missing scheme", key: "OPENAI_STATUS_BASE_URL", value: "status.openai.com", wantErr: "OPENAI_STATUS_BASE_URL must use http or https"}, - {name: "base url with bare query marker", key: "OPENAI_STATUS_BASE_URL", value: "https://status.openai.com?", wantErr: "OPENAI_STATUS_BASE_URL must not include query or fragment"}, - {name: "base url with query", key: "OPENAI_STATUS_BASE_URL", value: "https://status.openai.com?foo=bar", wantErr: "OPENAI_STATUS_BASE_URL must not include query or fragment"}, - {name: "base url with fragment", key: "OPENAI_STATUS_BASE_URL", value: "https://status.openai.com#api", wantErr: "OPENAI_STATUS_BASE_URL must not include query or fragment"}, {name: "invalid log level", key: "LOG_LEVEL", value: "verbose", wantErr: "LOG_LEVEL must be one of"}, } @@ -123,7 +128,6 @@ func setMinimalEnv(t *testing.T) { t.Helper() t.Setenv("TELEGRAM_BOT_TOKEN", "token") t.Setenv("REDIS_URL", "") - t.Setenv("OPENAI_STATUS_BASE_URL", "") t.Setenv("POLL_INTERVAL", "") t.Setenv("HTTP_TIMEOUT", "") t.Setenv("LOG_LEVEL", "") diff --git a/internal/health/server.go b/internal/health/server.go new file mode 100644 index 0000000..4d049b3 --- /dev/null +++ b/internal/health/server.go @@ -0,0 +1,44 @@ +package health + +import ( + "context" + "errors" + "log/slog" + "net/http" + "time" +) + +const ( + Address = "127.0.0.1:8080" + Path = "/healthz" +) + +func Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(Path, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + return mux +} + +func Run(ctx context.Context, logger *slog.Logger) { + server := &http.Server{ + Addr: Address, + Handler: Handler(), + ReadHeaderTimeout: 3 * time.Second, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Warn("shutdown health server", "error", err) + } + }() + + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Warn("health server stopped", "error", err) + } +} diff --git a/internal/health/server_test.go b/internal/health/server_test.go new file mode 100644 index 0000000..bbd0c22 --- /dev/null +++ b/internal/health/server_test.go @@ -0,0 +1,21 @@ +package health + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHandlerReturnsOK(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, Path, nil) + res := httptest.NewRecorder() + + Handler().ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", res.Code, http.StatusOK) + } + if res.Body.String() != "ok\n" { + t.Fatalf("body = %q, want ok", res.Body.String()) + } +} diff --git a/internal/openai/client.go b/internal/openai/client.go index 4f7fbb4..b548593 100644 --- a/internal/openai/client.go +++ b/internal/openai/client.go @@ -14,7 +14,13 @@ type Client struct { httpClient *http.Client } -func NewClient(baseURL string, timeout time.Duration) *Client { +const BaseURL = "https://status.openai.com" + +func NewClient(timeout time.Duration) *Client { + return newClient(BaseURL, timeout) +} + +func newClient(baseURL string, timeout time.Duration) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), httpClient: &http.Client{ diff --git a/internal/openai/client_test.go b/internal/openai/client_test.go index 97ca842..c2d7e1f 100644 --- a/internal/openai/client_test.go +++ b/internal/openai/client_test.go @@ -23,7 +23,7 @@ func TestFetchSummary(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, time.Second) + client := newClient(server.URL, time.Second) summary, err := client.FetchSummary(context.Background()) if err != nil { t.Fatalf("FetchSummary returned error: %v", err) @@ -39,7 +39,7 @@ func TestFetchIncidentsReturnsHTTPError(t *testing.T) { })) defer server.Close() - client := NewClient(server.URL, time.Second) + client := newClient(server.URL, time.Second) if _, err := client.FetchIncidents(context.Background()); err == nil { t.Fatal("expected HTTP error") }