mirror of
https://github.com/tiennm99/openai-status-bot.git
synced 2026-10-14 16:17:55 +00:00
fix(deploy): harden health and status config
This commit is contained in:
1 parent
956b2a98b4
commit
9f5a594cf7
14 files changed
+109
-46
No files matched your search
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -20,7 +20,7 @@ const helpText = `<b>OpenAI Status Bot</b>
|
||||
/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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user