fix(deploy): harden health and status config

This commit is contained in:
tiennm99 committed 2026-06-22 14:16:15 +07:00
1 parent 956b2a98b4
commit 9f5a594cf7
14 files changed
+109 -46

No files matched your search

-1
View File
@@ -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
+1
View File
@@ -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"]
+3 -1
View File
@@ -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.
+4 -1
View File
@@ -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)
+1 -1
View File
@@ -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"]
+5 -1
View File
@@ -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.
+5 -3
View File
@@ -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:<hash>` | 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.
+1 -1
View File
@@ -20,7 +20,7 @@ const helpText = `<b>OpenAI Status Bot</b>
/status [component] - show current OpenAI status
/components - show all component statuses
/subscribe &lt;incident|component|all&gt; - set notification types
/subscribe component &lt;name|all&gt; - filter component updates
/subscribe component &lt;name|id|all&gt; - filter component updates
/history [count] - show recent incidents, default 5, max 10
/uptime - show component health overview
/info - show chat and subscription details
+6 -29
View File
@@ -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":
+9 -5
View File
@@ -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", "")
+44
View File
@@ -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)
}
}
+21
View File
@@ -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())
}
}
+7 -1
View File
@@ -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{
+2 -2
View File
@@ -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")
}