From 407878d92ed413639527d1d42a2b19e230463384 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 8 Apr 2026 16:38:50 -0400 Subject: [PATCH] feat(gateway): unify source configuration and validation Signed-off-by: Yordis Prieto --- devops/docker/compose/.env.example | 83 +- devops/docker/compose/compose.yml | 216 +-- .../Dockerfile | 10 +- .../compose/services/trogon-gateway/README.md | 89 + .../services/trogon-source-github/README.md | 69 - .../services/trogon-source-linear/Dockerfile | 45 - .../services/trogon-source-linear/README.md | 72 - .../services/trogon-source-slack/Dockerfile | 45 - .../services/trogon-source-slack/README.md | 107 -- rsworkspace/Cargo.lock | 142 +- rsworkspace/Cargo.toml | 8 + rsworkspace/crates/acp-nats-stdio/src/main.rs | 4 +- rsworkspace/crates/acp-nats-ws/src/main.rs | 4 +- rsworkspace/crates/acp-telemetry/src/lib.rs | 38 +- rsworkspace/crates/acp-telemetry/src/log.rs | 33 +- .../crates/acp-telemetry/src/metric.rs | 5 +- .../crates/acp-telemetry/src/service_name.rs | 4 + rsworkspace/crates/acp-telemetry/src/trace.rs | 5 +- .../crates/acp-telemetry/tests/lifecycle.rs | 2 +- rsworkspace/crates/trogon-gateway/Cargo.toml | 33 + rsworkspace/crates/trogon-gateway/src/cli.rs | 16 + .../crates/trogon-gateway/src/config.rs | 1567 +++++++++++++++++ rsworkspace/crates/trogon-gateway/src/http.rs | 159 ++ rsworkspace/crates/trogon-gateway/src/main.rs | 166 ++ .../crates/trogon-gateway/src/streams.rs | 107 ++ .../trogon-nats/src/jetstream/claim_check.rs | 34 +- .../crates/trogon-nats/src/jetstream/mod.rs | 2 + .../src/jetstream/stream_max_age.rs | 45 + .../crates/trogon-source-discord/Cargo.toml | 4 - .../trogon-source-discord/src/config.rs | 353 +--- .../trogon-source-discord/src/constants.rs | 10 - .../src/gateway_runner.rs | 46 + .../crates/trogon-source-discord/src/lib.rs | 4 +- .../crates/trogon-source-discord/src/main.rs | 157 -- .../trogon-source-discord/src/server.rs | 158 +- .../crates/trogon-source-github/Cargo.toml | 4 - .../crates/trogon-source-github/src/config.rs | 168 +- .../trogon-source-github/src/constants.rs | 9 - .../crates/trogon-source-github/src/lib.rs | 2 - .../crates/trogon-source-github/src/main.rs | 65 - .../crates/trogon-source-github/src/server.rs | 167 +- .../crates/trogon-source-gitlab/Cargo.toml | 4 - .../crates/trogon-source-gitlab/src/config.rs | 180 +- .../trogon-source-gitlab/src/constants.rs | 9 - .../crates/trogon-source-gitlab/src/lib.rs | 5 - .../crates/trogon-source-gitlab/src/main.rs | 65 - .../crates/trogon-source-gitlab/src/server.rs | 153 +- .../src/webhook_secret.rs | 73 - .../crates/trogon-source-linear/Cargo.toml | 4 - .../crates/trogon-source-linear/src/config.rs | 376 +--- .../trogon-source-linear/src/constants.rs | 12 - .../crates/trogon-source-linear/src/lib.rs | 3 +- .../crates/trogon-source-linear/src/main.rs | 65 - .../crates/trogon-source-linear/src/server.rs | 178 +- .../crates/trogon-source-slack/Cargo.toml | 4 - .../crates/trogon-source-slack/src/config.rs | 240 +-- .../trogon-source-slack/src/constants.rs | 10 - .../crates/trogon-source-slack/src/lib.rs | 4 +- .../crates/trogon-source-slack/src/main.rs | 64 - .../crates/trogon-source-slack/src/server.rs | 226 +-- .../trogon-source-telegram/src/config.rs | 163 +- .../trogon-source-telegram/src/constants.rs | 9 - .../crates/trogon-source-telegram/src/lib.rs | 2 - .../crates/trogon-source-telegram/src/main.rs | 65 - .../trogon-source-telegram/src/server.rs | 178 +- .../crates/trogon-std/src/dirs/system.rs | 74 +- rsworkspace/crates/trogon-std/src/duration.rs | 84 + rsworkspace/crates/trogon-std/src/lib.rs | 4 + .../crates/trogon-std/src/secret_string.rs | 70 + .../crates/trogon-std/src/time/system.rs | 10 + 70 files changed, 3213 insertions(+), 3378 deletions(-) rename devops/docker/compose/services/{trogon-source-github => trogon-gateway}/Dockerfile (82%) create mode 100644 devops/docker/compose/services/trogon-gateway/README.md delete mode 100644 devops/docker/compose/services/trogon-source-github/README.md delete mode 100644 devops/docker/compose/services/trogon-source-linear/Dockerfile delete mode 100644 devops/docker/compose/services/trogon-source-linear/README.md delete mode 100644 devops/docker/compose/services/trogon-source-slack/Dockerfile delete mode 100644 devops/docker/compose/services/trogon-source-slack/README.md create mode 100644 rsworkspace/crates/trogon-gateway/Cargo.toml create mode 100644 rsworkspace/crates/trogon-gateway/src/cli.rs create mode 100644 rsworkspace/crates/trogon-gateway/src/config.rs create mode 100644 rsworkspace/crates/trogon-gateway/src/http.rs create mode 100644 rsworkspace/crates/trogon-gateway/src/main.rs create mode 100644 rsworkspace/crates/trogon-gateway/src/streams.rs create mode 100644 rsworkspace/crates/trogon-nats/src/jetstream/stream_max_age.rs create mode 100644 rsworkspace/crates/trogon-source-discord/src/gateway_runner.rs delete mode 100644 rsworkspace/crates/trogon-source-discord/src/main.rs delete mode 100644 rsworkspace/crates/trogon-source-github/src/main.rs delete mode 100644 rsworkspace/crates/trogon-source-gitlab/src/main.rs delete mode 100644 rsworkspace/crates/trogon-source-gitlab/src/webhook_secret.rs delete mode 100644 rsworkspace/crates/trogon-source-linear/src/main.rs delete mode 100644 rsworkspace/crates/trogon-source-slack/src/main.rs delete mode 100644 rsworkspace/crates/trogon-source-telegram/src/main.rs create mode 100644 rsworkspace/crates/trogon-std/src/duration.rs create mode 100644 rsworkspace/crates/trogon-std/src/secret_string.rs diff --git a/devops/docker/compose/.env.example b/devops/docker/compose/.env.example index 09f6c8c81..f72a98fba 100644 --- a/devops/docker/compose/.env.example +++ b/devops/docker/compose/.env.example @@ -1,61 +1,56 @@ +# --- Gateway --- +# TROGON_GATEWAY_PORT=8080 + # --- NATS --- # NATS_URL=nats://localhost:4222 # --- GitHub Source --- -# GITHUB_WEBHOOK_SECRET= -# GITHUB_WEBHOOK_PORT=8080 -# GITHUB_SUBJECT_PREFIX=github -# GITHUB_STREAM_NAME=GITHUB -# GITHUB_STREAM_MAX_AGE_SECS=604800 -# GITHUB_NATS_ACK_TIMEOUT_SECS=10 +# TROGON_SOURCE_GITHUB_WEBHOOK_SECRET= +# TROGON_SOURCE_GITHUB_SUBJECT_PREFIX=github +# TROGON_SOURCE_GITHUB_STREAM_NAME=GITHUB +# TROGON_SOURCE_GITHUB_STREAM_MAX_AGE_SECS=604800 +# TROGON_SOURCE_GITHUB_NATS_ACK_TIMEOUT_SECS=10 # --- GitLab Source --- -# GITLAB_WEBHOOK_SECRET= -# GITLAB_WEBHOOK_PORT=8080 -# GITLAB_SUBJECT_PREFIX=gitlab -# GITLAB_STREAM_NAME=GITLAB -# GITLAB_STREAM_MAX_AGE_SECS=604800 -# GITLAB_NATS_ACK_TIMEOUT_MS=10000 +# TROGON_SOURCE_GITLAB_WEBHOOK_SECRET= +# TROGON_SOURCE_GITLAB_SUBJECT_PREFIX=gitlab +# TROGON_SOURCE_GITLAB_STREAM_NAME=GITLAB +# TROGON_SOURCE_GITLAB_STREAM_MAX_AGE_SECS=604800 +# TROGON_SOURCE_GITLAB_NATS_ACK_TIMEOUT_SECS=10 # --- Linear Source --- -# LINEAR_WEBHOOK_SECRET= -# LINEAR_WEBHOOK_PORT=8080 -# LINEAR_SUBJECT_PREFIX=linear -# LINEAR_STREAM_NAME=LINEAR -# LINEAR_STREAM_MAX_AGE_SECS=604800 -# LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS=60 -# LINEAR_NATS_ACK_TIMEOUT_MS=10000 +# TROGON_SOURCE_LINEAR_WEBHOOK_SECRET= +# TROGON_SOURCE_LINEAR_SUBJECT_PREFIX=linear +# TROGON_SOURCE_LINEAR_STREAM_NAME=LINEAR +# TROGON_SOURCE_LINEAR_STREAM_MAX_AGE_SECS=604800 +# TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS=10 +# TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS=60 # --- Discord Source --- -# DISCORD_MODE=gateway # "gateway" (WebSocket, all events) or "webhook" (HTTP, interactions only) -# DISCORD_BOT_TOKEN= # required when DISCORD_MODE=gateway -# DISCORD_GATEWAY_INTENTS=guilds,guild_members,guild_messages,guild_message_reactions,direct_messages,message_content,guild_voice_states -# DISCORD_PUBLIC_KEY= # required when DISCORD_MODE=webhook -# DISCORD_WEBHOOK_PORT=8080 -# DISCORD_SUBJECT_PREFIX=discord -# DISCORD_STREAM_NAME=DISCORD -# DISCORD_STREAM_MAX_AGE_SECS=604800 -# DISCORD_NATS_ACK_TIMEOUT_SECS=10 -# DISCORD_NATS_REQUEST_TIMEOUT_SECS=2 -# DISCORD_MAX_BODY_SIZE=4194304 +# TROGON_SOURCE_DISCORD_MODE=gateway # "gateway" (WebSocket, all events) or "webhook" (HTTP, interactions only) +# TROGON_SOURCE_DISCORD_BOT_TOKEN= # required when mode=gateway +# TROGON_SOURCE_DISCORD_GATEWAY_INTENTS=guilds,guild_members,guild_messages,guild_message_reactions,direct_messages,message_content,guild_voice_states +# TROGON_SOURCE_DISCORD_PUBLIC_KEY= # required when mode=webhook +# TROGON_SOURCE_DISCORD_SUBJECT_PREFIX=discord +# TROGON_SOURCE_DISCORD_STREAM_NAME=DISCORD +# TROGON_SOURCE_DISCORD_STREAM_MAX_AGE_SECS=604800 +# TROGON_SOURCE_DISCORD_NATS_ACK_TIMEOUT_SECS=10 +# TROGON_SOURCE_DISCORD_NATS_REQUEST_TIMEOUT_SECS=2 # --- Telegram Source --- -# TELEGRAM_WEBHOOK_SECRET= -# TELEGRAM_SOURCE_PORT=8080 -# TELEGRAM_SUBJECT_PREFIX=telegram -# TELEGRAM_STREAM_NAME=TELEGRAM -# TELEGRAM_STREAM_MAX_AGE_SECS=604800 -# TELEGRAM_NATS_ACK_TIMEOUT_SECS=10 -# TELEGRAM_MAX_BODY_SIZE=10485760 +# TROGON_SOURCE_TELEGRAM_WEBHOOK_SECRET= +# TROGON_SOURCE_TELEGRAM_SUBJECT_PREFIX=telegram +# TROGON_SOURCE_TELEGRAM_STREAM_NAME=TELEGRAM +# TROGON_SOURCE_TELEGRAM_STREAM_MAX_AGE_SECS=604800 +# TROGON_SOURCE_TELEGRAM_NATS_ACK_TIMEOUT_SECS=10 # --- Slack Source --- -# SLACK_SIGNING_SECRET= -# SLACK_WEBHOOK_PORT=3000 -# SLACK_SUBJECT_PREFIX=slack -# SLACK_STREAM_NAME=SLACK -# SLACK_STREAM_MAX_AGE_SECS=604800 -# SLACK_NATS_ACK_TIMEOUT_SECS=10 -# SLACK_TIMESTAMP_MAX_DRIFT_SECS=300 +# TROGON_SOURCE_SLACK_SIGNING_SECRET= +# TROGON_SOURCE_SLACK_SUBJECT_PREFIX=slack +# TROGON_SOURCE_SLACK_STREAM_NAME=SLACK +# TROGON_SOURCE_SLACK_STREAM_MAX_AGE_SECS=604800 +# TROGON_SOURCE_SLACK_NATS_ACK_TIMEOUT_SECS=10 +# TROGON_SOURCE_SLACK_TIMESTAMP_MAX_DRIFT_SECS=300 # --- Logging --- # RUST_LOG=info diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 1dcb25356..9e54b455d 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -17,156 +17,63 @@ services: start_period: 5s retries: 3 - trogon-source-github: + trogon-gateway: build: context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/trogon-source-github/Dockerfile + dockerfile: ../devops/docker/compose/services/trogon-gateway/Dockerfile environment: NATS_URL: "nats:4222" - GITHUB_WEBHOOK_SECRET: "${GITHUB_WEBHOOK_SECRET:-}" - GITHUB_WEBHOOK_PORT: "${GITHUB_WEBHOOK_PORT:-8080}" - GITHUB_SUBJECT_PREFIX: "${GITHUB_SUBJECT_PREFIX:-github}" - GITHUB_STREAM_NAME: "${GITHUB_STREAM_NAME:-GITHUB}" - GITHUB_STREAM_MAX_AGE_SECS: "${GITHUB_STREAM_MAX_AGE_SECS:-604800}" - GITHUB_NATS_ACK_TIMEOUT_SECS: "${GITHUB_NATS_ACK_TIMEOUT_SECS:-10}" RUST_LOG: "${RUST_LOG:-info}" - depends_on: - nats: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:${GITHUB_WEBHOOK_PORT:-8080}/health"] - interval: 10s - timeout: 3s - start_period: 10s - retries: 3 + TROGON_GATEWAY_PORT: "${TROGON_GATEWAY_PORT:-8080}" - trogon-source-linear: - build: - context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/trogon-source-linear/Dockerfile - environment: - NATS_URL: "nats:4222" - LINEAR_WEBHOOK_SECRET: "${LINEAR_WEBHOOK_SECRET}" - LINEAR_WEBHOOK_PORT: "${LINEAR_WEBHOOK_PORT:-8080}" - LINEAR_SUBJECT_PREFIX: "${LINEAR_SUBJECT_PREFIX:-linear}" - LINEAR_STREAM_NAME: "${LINEAR_STREAM_NAME:-LINEAR}" - LINEAR_STREAM_MAX_AGE_SECS: "${LINEAR_STREAM_MAX_AGE_SECS:-604800}" - LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS: "${LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS:-60}" - LINEAR_NATS_ACK_TIMEOUT_MS: "${LINEAR_NATS_ACK_TIMEOUT_MS:-10000}" - RUST_LOG: "${RUST_LOG:-info}" - depends_on: - nats: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:${LINEAR_WEBHOOK_PORT:-8080}/health"] - interval: 10s - timeout: 3s - start_period: 10s - retries: 3 + TROGON_SOURCE_GITHUB_WEBHOOK_SECRET: "${TROGON_SOURCE_GITHUB_WEBHOOK_SECRET:-}" + TROGON_SOURCE_GITHUB_SUBJECT_PREFIX: "${TROGON_SOURCE_GITHUB_SUBJECT_PREFIX:-github}" + TROGON_SOURCE_GITHUB_STREAM_NAME: "${TROGON_SOURCE_GITHUB_STREAM_NAME:-GITHUB}" + TROGON_SOURCE_GITHUB_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_GITHUB_STREAM_MAX_AGE_SECS:-604800}" + TROGON_SOURCE_GITHUB_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_GITHUB_NATS_ACK_TIMEOUT_SECS:-10}" - trogon-source-discord: - build: - context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/trogon-source-discord/Dockerfile - environment: - NATS_URL: "nats:4222" - DISCORD_MODE: "${DISCORD_MODE:-gateway}" - DISCORD_BOT_TOKEN: "${DISCORD_BOT_TOKEN:-}" - DISCORD_GATEWAY_INTENTS: "${DISCORD_GATEWAY_INTENTS:-}" - DISCORD_PUBLIC_KEY: "${DISCORD_PUBLIC_KEY:-}" - DISCORD_WEBHOOK_PORT: "${DISCORD_WEBHOOK_PORT:-8080}" - DISCORD_SUBJECT_PREFIX: "${DISCORD_SUBJECT_PREFIX:-discord}" - DISCORD_STREAM_NAME: "${DISCORD_STREAM_NAME:-DISCORD}" - DISCORD_STREAM_MAX_AGE_SECS: "${DISCORD_STREAM_MAX_AGE_SECS:-604800}" - DISCORD_NATS_ACK_TIMEOUT_SECS: "${DISCORD_NATS_ACK_TIMEOUT_SECS:-10}" - DISCORD_NATS_REQUEST_TIMEOUT_SECS: "${DISCORD_NATS_REQUEST_TIMEOUT_SECS:-2}" - DISCORD_MAX_BODY_SIZE: "${DISCORD_MAX_BODY_SIZE:-4194304}" - RUST_LOG: "${RUST_LOG:-info}" - depends_on: - nats: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:${DISCORD_WEBHOOK_PORT:-8080}/health"] - interval: 10s - timeout: 3s - start_period: 10s - retries: 3 - profiles: - - discord + TROGON_SOURCE_DISCORD_MODE: "${TROGON_SOURCE_DISCORD_MODE:-}" + TROGON_SOURCE_DISCORD_BOT_TOKEN: "${TROGON_SOURCE_DISCORD_BOT_TOKEN:-}" + TROGON_SOURCE_DISCORD_GATEWAY_INTENTS: "${TROGON_SOURCE_DISCORD_GATEWAY_INTENTS:-}" + TROGON_SOURCE_DISCORD_PUBLIC_KEY: "${TROGON_SOURCE_DISCORD_PUBLIC_KEY:-}" + TROGON_SOURCE_DISCORD_SUBJECT_PREFIX: "${TROGON_SOURCE_DISCORD_SUBJECT_PREFIX:-discord}" + TROGON_SOURCE_DISCORD_STREAM_NAME: "${TROGON_SOURCE_DISCORD_STREAM_NAME:-DISCORD}" + TROGON_SOURCE_DISCORD_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_DISCORD_STREAM_MAX_AGE_SECS:-604800}" + TROGON_SOURCE_DISCORD_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_DISCORD_NATS_ACK_TIMEOUT_SECS:-10}" + TROGON_SOURCE_DISCORD_NATS_REQUEST_TIMEOUT_SECS: "${TROGON_SOURCE_DISCORD_NATS_REQUEST_TIMEOUT_SECS:-2}" - trogon-source-gitlab: - build: - context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/trogon-source-gitlab/Dockerfile - environment: - NATS_URL: "nats:4222" - GITLAB_WEBHOOK_SECRET: "${GITLAB_WEBHOOK_SECRET:-}" - GITLAB_WEBHOOK_PORT: "${GITLAB_WEBHOOK_PORT:-8080}" - GITLAB_SUBJECT_PREFIX: "${GITLAB_SUBJECT_PREFIX:-gitlab}" - GITLAB_STREAM_NAME: "${GITLAB_STREAM_NAME:-GITLAB}" - GITLAB_STREAM_MAX_AGE_SECS: "${GITLAB_STREAM_MAX_AGE_SECS:-604800}" - GITLAB_NATS_ACK_TIMEOUT_MS: "${GITLAB_NATS_ACK_TIMEOUT_MS:-10000}" - RUST_LOG: "${RUST_LOG:-info}" - depends_on: - nats: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:${GITLAB_WEBHOOK_PORT:-8080}/health"] - interval: 10s - timeout: 3s - start_period: 10s - retries: 3 + TROGON_SOURCE_SLACK_SIGNING_SECRET: "${TROGON_SOURCE_SLACK_SIGNING_SECRET:-}" + TROGON_SOURCE_SLACK_SUBJECT_PREFIX: "${TROGON_SOURCE_SLACK_SUBJECT_PREFIX:-slack}" + TROGON_SOURCE_SLACK_STREAM_NAME: "${TROGON_SOURCE_SLACK_STREAM_NAME:-SLACK}" + TROGON_SOURCE_SLACK_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_SLACK_STREAM_MAX_AGE_SECS:-604800}" + TROGON_SOURCE_SLACK_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_SLACK_NATS_ACK_TIMEOUT_SECS:-10}" + TROGON_SOURCE_SLACK_TIMESTAMP_MAX_DRIFT_SECS: "${TROGON_SOURCE_SLACK_TIMESTAMP_MAX_DRIFT_SECS:-300}" - trogon-source-telegram: - build: - context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/trogon-source-telegram/Dockerfile - environment: - NATS_URL: "nats:4222" - TELEGRAM_WEBHOOK_SECRET: "${TELEGRAM_WEBHOOK_SECRET}" - TELEGRAM_SOURCE_PORT: "${TELEGRAM_SOURCE_PORT:-8080}" - TELEGRAM_SUBJECT_PREFIX: "${TELEGRAM_SUBJECT_PREFIX:-telegram}" - TELEGRAM_STREAM_NAME: "${TELEGRAM_STREAM_NAME:-TELEGRAM}" - TELEGRAM_STREAM_MAX_AGE_SECS: "${TELEGRAM_STREAM_MAX_AGE_SECS:-604800}" - TELEGRAM_NATS_ACK_TIMEOUT_SECS: "${TELEGRAM_NATS_ACK_TIMEOUT_SECS:-10}" - TELEGRAM_MAX_BODY_SIZE: "${TELEGRAM_MAX_BODY_SIZE:-10485760}" - RUST_LOG: "${RUST_LOG:-info}" - depends_on: - nats: - condition: service_healthy - restart: unless-stopped - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:${TELEGRAM_SOURCE_PORT:-8080}/health"] - interval: 10s - timeout: 3s - start_period: 10s - retries: 3 + TROGON_SOURCE_TELEGRAM_WEBHOOK_SECRET: "${TROGON_SOURCE_TELEGRAM_WEBHOOK_SECRET:-}" + TROGON_SOURCE_TELEGRAM_SUBJECT_PREFIX: "${TROGON_SOURCE_TELEGRAM_SUBJECT_PREFIX:-telegram}" + TROGON_SOURCE_TELEGRAM_STREAM_NAME: "${TROGON_SOURCE_TELEGRAM_STREAM_NAME:-TELEGRAM}" + TROGON_SOURCE_TELEGRAM_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_TELEGRAM_STREAM_MAX_AGE_SECS:-604800}" + TROGON_SOURCE_TELEGRAM_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_TELEGRAM_NATS_ACK_TIMEOUT_SECS:-10}" - trogon-source-slack: - build: - context: ../../../rsworkspace - dockerfile: ../devops/docker/compose/services/trogon-source-slack/Dockerfile - environment: - NATS_URL: "nats:4222" - SLACK_SIGNING_SECRET: "${SLACK_SIGNING_SECRET:-}" - SLACK_WEBHOOK_PORT: "${SLACK_WEBHOOK_PORT:-3000}" - SLACK_SUBJECT_PREFIX: "${SLACK_SUBJECT_PREFIX:-slack}" - SLACK_STREAM_NAME: "${SLACK_STREAM_NAME:-SLACK}" - SLACK_STREAM_MAX_AGE_SECS: "${SLACK_STREAM_MAX_AGE_SECS:-604800}" - SLACK_NATS_ACK_TIMEOUT_SECS: "${SLACK_NATS_ACK_TIMEOUT_SECS:-10}" - SLACK_TIMESTAMP_MAX_DRIFT_SECS: "${SLACK_TIMESTAMP_MAX_DRIFT_SECS:-300}" - RUST_LOG: "${RUST_LOG:-info}" + TROGON_SOURCE_GITLAB_WEBHOOK_SECRET: "${TROGON_SOURCE_GITLAB_WEBHOOK_SECRET:-}" + TROGON_SOURCE_GITLAB_SUBJECT_PREFIX: "${TROGON_SOURCE_GITLAB_SUBJECT_PREFIX:-gitlab}" + TROGON_SOURCE_GITLAB_STREAM_NAME: "${TROGON_SOURCE_GITLAB_STREAM_NAME:-GITLAB}" + TROGON_SOURCE_GITLAB_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_GITLAB_STREAM_MAX_AGE_SECS:-604800}" + TROGON_SOURCE_GITLAB_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_GITLAB_NATS_ACK_TIMEOUT_SECS:-10}" + + TROGON_SOURCE_LINEAR_WEBHOOK_SECRET: "${TROGON_SOURCE_LINEAR_WEBHOOK_SECRET:-}" + TROGON_SOURCE_LINEAR_SUBJECT_PREFIX: "${TROGON_SOURCE_LINEAR_SUBJECT_PREFIX:-linear}" + TROGON_SOURCE_LINEAR_STREAM_NAME: "${TROGON_SOURCE_LINEAR_STREAM_NAME:-LINEAR}" + TROGON_SOURCE_LINEAR_STREAM_MAX_AGE_SECS: "${TROGON_SOURCE_LINEAR_STREAM_MAX_AGE_SECS:-604800}" + TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS: "${TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS:-10}" + TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS: "${TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS:-60}" depends_on: nats: condition: service_healthy restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:${SLACK_WEBHOOK_PORT:-3000}/health"] - interval: 10s + test: ["CMD", "curl", "-sf", "http://localhost:${TROGON_GATEWAY_PORT:-8080}/-/liveness"] + interval: 5s timeout: 3s start_period: 10s retries: 3 @@ -175,12 +82,7 @@ services: image: ngrok/ngrok:alpine environment: NGROK_AUTHTOKEN: "${NGROK_AUTHTOKEN:-}" - DISCORD_ADDR: "trogon-source-discord:${DISCORD_WEBHOOK_PORT:-8080}" - GITHUB_ADDR: "trogon-source-github:${GITHUB_WEBHOOK_PORT:-8080}" - GITLAB_ADDR: "trogon-source-gitlab:${GITLAB_WEBHOOK_PORT:-8080}" - LINEAR_ADDR: "trogon-source-linear:${LINEAR_WEBHOOK_PORT:-8080}" - SLACK_ADDR: "trogon-source-slack:${SLACK_WEBHOOK_PORT:-3000}" - TELEGRAM_ADDR: "trogon-source-telegram:${TELEGRAM_SOURCE_PORT:-8080}" + GATEWAY_ADDR: "trogon-gateway:${TROGON_GATEWAY_PORT:-8080}" entrypoint: - /bin/sh - -c @@ -188,39 +90,13 @@ services: cat > /tmp/ngrok.yml < \ +docker compose up +``` + +### Webhook mode + +```bash +TROGON_SOURCE_DISCORD_MODE=webhook \ +TROGON_SOURCE_DISCORD_PUBLIC_KEY= \ +docker compose --profile dev up +``` + +Find the ngrok tunnel URL in `docker compose logs ngrok`, then set it as +your Discord application's Interactions Endpoint URL (append `/discord/webhook`). + +## Exposing webhooks with ngrok + +```bash +docker compose --profile dev up +``` + +This starts ngrok alongside the gateway. Check `docker compose logs ngrok` +for the public URL. Append the source prefix path when configuring each +platform's webhook settings (e.g. `https:///github/webhook`). + +## Verify + +Subscribe to NATS to see events flowing: + +```bash +nats sub -s nats://nats.trogonai.orb.local:4222 ">" +``` + +## Environment variables + +See `devops/docker/compose/.env.example` for the full list of configurable +env vars per source. All env vars use the `TROGON_SOURCE__` prefix. diff --git a/devops/docker/compose/services/trogon-source-github/README.md b/devops/docker/compose/services/trogon-source-github/README.md deleted file mode 100644 index 65ff46919..000000000 --- a/devops/docker/compose/services/trogon-source-github/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Receiving GitHub Webhooks Locally - -## Prerequisites - -- Docker Compose -- A GitHub App (org or personal) — the app's webhook delivers events from every - repo where it's installed, so you configure the URL once -- An [ngrok](https://ngrok.com) account (free tier works) - -## 1. Generate a webhook secret - -```bash -openssl rand -hex 32 -``` - -Save the output — you'll use it in both GitHub and the local stack. - -## 2. Start the stack - -```bash -docker compose --profile dev up -``` - -This starts NATS, the webhook receiver, and ngrok. Find the public tunnel URL -in the ngrok container logs: - -```bash -docker compose logs ngrok -``` - -Look for the `github` tunnel URL (e.g. `https://abc123.ngrok-free.app`). - -## 3. Configure your GitHub App - -1. Go to **Settings → Developer settings → GitHub Apps** -2. Select your app (or create a new one) -3. Under **Webhook**: - - **Webhook URL**: `https:///webhook` - - **Webhook secret**: the secret you generated in step 1 -4. Under **Permissions & events**, subscribe to the events you need -5. Install the app on the repositories or organization you want to receive - events from - -## 4. Verify - -Trigger an event in a repository where the app is installed (e.g. push a -commit). You should see: - -- The webhook receiver log the incoming event -- The event published to NATS on `github.{event}` - -You can inspect NATS with: - -```bash -nats sub -s nats://nats.trogonai.orb.local:4222 "github.>" -``` - -Without `--profile dev`, ngrok is excluded and only the core services start. - -## Environment variables - -| Variable | Required | Default | Description | -|---|---|---|---| -| `GITHUB_WEBHOOK_SECRET` | yes | — | HMAC-SHA256 secret (must match GitHub App) | -| `NGROK_AUTHTOKEN` | yes (dev profile) | — | ngrok auth token | -| `GITHUB_WEBHOOK_PORT` | no | `8080` | HTTP port for the webhook receiver | -| `GITHUB_SUBJECT_PREFIX` | no | `github` | NATS subject prefix | -| `GITHUB_STREAM_NAME` | no | `GITHUB` | JetStream stream name | -| `RUST_LOG` | no | `info` | Log level | diff --git a/devops/docker/compose/services/trogon-source-linear/Dockerfile b/devops/docker/compose/services/trogon-source-linear/Dockerfile deleted file mode 100644 index e1b52e52a..000000000 --- a/devops/docker/compose/services/trogon-source-linear/Dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -# ── Stage 1: chef — generate dependency recipe ────────────────────────────── -FROM rust:1.93-slim AS chef - -RUN cargo install cargo-chef --locked - -WORKDIR /build - -# ── Stage 2: planner — capture dependency graph ───────────────────────────── -FROM chef AS planner - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ - -RUN cargo chef prepare --recipe-path recipe.json - -# ── Stage 3: builder — cached dependency build + final compile ────────────── -FROM chef AS builder - -COPY --from=planner /build/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json -p trogon-source-linear - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ - -RUN cargo build --release -p trogon-source-linear && \ - strip target/release/trogon-source-linear - -# ── Stage 4: runtime ──────────────────────────────────────────────────────── -FROM debian:bookworm-20250317-slim AS runtime - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - -RUN useradd --no-create-home --shell /usr/sbin/nologin trogon - -COPY --from=builder /build/target/release/trogon-source-linear /usr/local/bin/trogon-source-linear - -USER trogon - -EXPOSE 8080 - -STOPSIGNAL SIGTERM - -ENTRYPOINT ["/usr/local/bin/trogon-source-linear"] diff --git a/devops/docker/compose/services/trogon-source-linear/README.md b/devops/docker/compose/services/trogon-source-linear/README.md deleted file mode 100644 index 83688877c..000000000 --- a/devops/docker/compose/services/trogon-source-linear/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Receiving Linear Webhooks Locally - -## Prerequisites - -- Docker Compose -- A [Linear](https://linear.app) workspace with admin access -- An [ngrok](https://ngrok.com) account (free tier works) - -## 1. Get your ngrok tunnel URL - -Start only ngrok and NATS to obtain a public URL before configuring Linear: - -```bash -docker compose --profile dev up ngrok nats -``` - -Find the tunnel URL in the ngrok container logs: - -```bash -docker compose logs ngrok -``` - -Look for the `linear` tunnel URL (e.g. `https://abc123.ngrok-free.app`). - -## 2. Configure your Linear webhook - -1. Go to **Settings → API → Webhooks** -2. Click **New webhook** -3. Set: - - **URL**: `https:///webhook` -4. Select the resource types you want to receive events for -5. Save -6. Copy the **signing secret** that Linear generates - -## 3. Start the webhook receiver - -Set `LINEAR_WEBHOOK_SECRET` in your `.env` to the signing secret from step 2, -then bring up the full stack: - -```bash -docker compose --profile dev up -``` - -## 4. Verify - -Trigger an event in Linear (e.g. create an issue). You should see: - -- The webhook receiver log the incoming event -- The event published to NATS on `linear.{type}.{action}` - -You can inspect NATS with: - -```bash -nats sub -s nats://nats.trogonai.orb.local:4222 "linear.>" -``` - -Without `--profile dev`, ngrok is excluded and only the core services start. - -## Environment variables - -| Variable | Required | Default | Description | -|---|---|---|---| -| `LINEAR_WEBHOOK_SECRET` | yes | — | Signing secret from Linear's webhook settings | -| `NGROK_AUTHTOKEN` | yes (dev profile) | — | ngrok auth token | -| `LINEAR_WEBHOOK_PORT` | no | `8080` | HTTP port for the webhook receiver | -| `LINEAR_SUBJECT_PREFIX` | no | `linear` | NATS subject prefix | -| `LINEAR_STREAM_NAME` | no | `LINEAR` | JetStream stream name | -| `LINEAR_STREAM_MAX_AGE_SECS` | no | `604800` | Max message age in seconds (7 days) | -| `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS` | no | `60` | Replay-attack window in seconds (0 to disable) | -| `LINEAR_NATS_ACK_TIMEOUT_MS` | no | `10000` | JetStream ACK timeout in milliseconds | -| `NATS_URL` | no | `localhost:4222` | NATS server URL(s) | -| `RUST_LOG` | no | `info` | Log level | diff --git a/devops/docker/compose/services/trogon-source-slack/Dockerfile b/devops/docker/compose/services/trogon-source-slack/Dockerfile deleted file mode 100644 index 3b1337ae9..000000000 --- a/devops/docker/compose/services/trogon-source-slack/Dockerfile +++ /dev/null @@ -1,45 +0,0 @@ -# ── Stage 1: chef — generate dependency recipe ────────────────────────────── -FROM rust:1.93-slim AS chef - -RUN cargo install cargo-chef --locked - -WORKDIR /build - -# ── Stage 2: planner — capture dependency graph ───────────────────────────── -FROM chef AS planner - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ - -RUN cargo chef prepare --recipe-path recipe.json - -# ── Stage 3: builder — cached dependency build + final compile ────────────── -FROM chef AS builder - -COPY --from=planner /build/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json -p trogon-source-slack - -COPY Cargo.toml Cargo.lock ./ -COPY crates/ crates/ - -RUN cargo build --release -p trogon-source-slack && \ - strip target/release/trogon-source-slack - -# ── Stage 4: runtime ──────────────────────────────────────────────────────── -FROM debian:bookworm-20250317-slim AS runtime - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* - -RUN useradd --no-create-home --shell /usr/sbin/nologin trogon - -COPY --from=builder /build/target/release/trogon-source-slack /usr/local/bin/trogon-source-slack - -USER trogon - -EXPOSE 3000 - -STOPSIGNAL SIGTERM - -ENTRYPOINT ["/usr/local/bin/trogon-source-slack"] diff --git a/devops/docker/compose/services/trogon-source-slack/README.md b/devops/docker/compose/services/trogon-source-slack/README.md deleted file mode 100644 index 3ff5d4bb7..000000000 --- a/devops/docker/compose/services/trogon-source-slack/README.md +++ /dev/null @@ -1,107 +0,0 @@ -# Receiving Slack Events Locally - -## Prerequisites - -- Docker Compose -- A Slack App with Event Subscriptions enabled -- An [ngrok](https://ngrok.com) account (free tier works) - -## 1. Create a Slack App - -1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** -2. Choose **From scratch**, pick a name and workspace -3. Under **Basic Information → App Credentials**, copy the **Signing Secret** - -## 2. Start the stack - -```bash -docker compose --profile dev up -``` - -This starts NATS, the Slack webhook receiver, and ngrok. Find the public tunnel -URL in the ngrok container logs: - -```bash -docker compose logs ngrok -``` - -Look for the `slack` tunnel URL (e.g. `https://def456.ngrok-free.app`). - -## 3. Enable Event Subscriptions - -1. In your Slack App settings, go to **Event Subscriptions** -2. Toggle **Enable Events** to On -3. Set the **Request URL** to `https:///webhook` -4. Slack will send a `url_verification` challenge — the server responds - automatically -5. Under **Subscribe to bot events**, add the events you need: - - `message.channels` — public channels - - `message.groups` — private channels - - `message.im` — direct messages - - `app_mention` — @mentions anywhere -6. Click **Save Changes** - -## 4. Enable Interactivity (optional) - -1. Go to **Interactivity & Shortcuts** -2. Toggle **Interactivity** to On -3. Set the **Request URL** to the same `https:///webhook` -4. Click **Save Changes** - -Block actions, modal submissions, and shortcuts will be published to NATS on -`slack.interaction.{type}` subjects (e.g. `slack.interaction.block_actions`). - -## 5. Register Slash Commands (optional) - -1. Go to **Slash Commands** and click **Create New Command** -2. Set the **Request URL** to the same `https:///webhook` -3. Fill in the command name, description, and usage hint -4. Click **Save** - -Slash commands will be published to NATS on `slack.command.{command_name}` -subjects (e.g. `slack.command.trogon`). - -## 6. Install the app to your workspace - -1. Go to **OAuth & Permissions** -2. Under **Scopes → Bot Token Scopes**, ensure you have the scopes required - by the events you subscribed to -3. Click **Install to Workspace** and authorize - -## 7. Verify - -Send a message in a channel where the app is installed. You should see: - -- The webhook receiver log the incoming event -- The event published to NATS on `slack.event.message` - -You can inspect NATS with: - -```bash -nats sub -s nats://nats.trogonai.orb.local:4222 "slack.>" -``` - -Without `--profile dev`, ngrok is excluded and only the core services start. - -## NATS subject mapping - -| Slack payload | NATS subject | `X-Slack-Payload-Kind` header | -|---|---|---| -| Events API (`event_callback`) | `slack.event.{event.type}` | `event` | -| Interactions (block actions, modals) | `slack.interaction.{type}` | `interaction` | -| Slash commands | `slack.command.{command_name}` | `command` | - -## Environment variables - -| Variable | Required | Default | Description | -|---|---|---|---| -| `SLACK_SIGNING_SECRET` | yes | — | Slack app signing secret | -| `NGROK_AUTHTOKEN` | yes (dev profile) | — | ngrok auth token | -| `SLACK_WEBHOOK_PORT` | no | `3000` | HTTP port for the webhook receiver | -| `SLACK_SUBJECT_PREFIX` | no | `slack` | NATS subject prefix | -| `SLACK_STREAM_NAME` | no | `SLACK` | JetStream stream name | -| `SLACK_STREAM_MAX_AGE_SECS` | no | `604800` | Max message age in seconds (default 7 days) | -| `SLACK_NATS_ACK_TIMEOUT_SECS` | no | `10` | NATS acknowledgement timeout in seconds | -| `SLACK_MAX_BODY_SIZE` | no | `1048576` | Max HTTP request body size in bytes (default 1 MB) | -| `SLACK_TIMESTAMP_MAX_DRIFT_SECS` | no | `300` | Max allowed clock drift in seconds (default 5 min) | -| `RUST_LOG` | no | `info` | Log level | diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index 6da2fcb0f..3df6e3aa1 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -537,6 +537,29 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "confique" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06b4f5ec222421e22bb0a8cbaa36b1d2b50fd45cdd30c915ded34108da78b29f" +dependencies = [ + "confique-macro", + "serde", + "toml", +] + +[[package]] +name = "confique-macro" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d1754680cd218e7bcb4c960cc9bae3444b5197d64563dccccfdf83cab9e1a7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "const-hex" version = "1.18.1" @@ -901,9 +924,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fiat-crypto" @@ -1600,6 +1623,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2341,6 +2370,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.22.4" @@ -2636,6 +2678,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2891,6 +2942,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "testcontainers" version = "0.20.1" @@ -3174,6 +3238,45 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.13.1", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.5" @@ -3344,6 +3447,29 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "trogon-gateway" +version = "0.1.0" +dependencies = [ + "acp-telemetry", + "async-nats", + "axum", + "clap", + "confique", + "serde", + "tempfile", + "tokio", + "tracing", + "trogon-nats", + "trogon-source-discord", + "trogon-source-github", + "trogon-source-gitlab", + "trogon-source-linear", + "trogon-source-slack", + "trogon-source-telegram", + "trogon-std", +] + [[package]] name = "trogon-nats" version = "0.1.0" @@ -4157,6 +4283,18 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index 0f73b0509..be43ab5a0 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -14,6 +14,12 @@ all = "deny" acp-nats = { path = "crates/acp-nats" } acp-telemetry = { path = "crates/acp-telemetry" } trogon-nats = { path = "crates/trogon-nats" } +trogon-source-discord = { path = "crates/trogon-source-discord" } +trogon-source-github = { path = "crates/trogon-source-github" } +trogon-source-gitlab = { path = "crates/trogon-source-gitlab" } +trogon-source-linear = { path = "crates/trogon-source-linear" } +trogon-source-slack = { path = "crates/trogon-source-slack" } +trogon-source-telegram = { path = "crates/trogon-source-telegram" } trogon-std = { path = "crates/trogon-std" } # ACP @@ -32,6 +38,7 @@ tokio-tungstenite = "=0.29.0" tower-http = { version = "=0.6.8", features = ["trace"] } # Serialization +confique = { version = "=0.4.0", features = ["toml"] } serde = { version = "=1.0.228", features = ["derive"] } serde_json = "=1.0.149" @@ -52,6 +59,7 @@ twilight-gateway = { version = "=0.17.1", default-features = false, features = [ twilight-model = "=0.17.1" # Misc +tempfile = "=3.19.1" uuid = { version = "=1.23.0", features = ["v4", "v7"] } [profile.release] diff --git a/rsworkspace/crates/acp-nats-stdio/src/main.rs b/rsworkspace/crates/acp-nats-stdio/src/main.rs index 9acfe854a..9587251bd 100644 --- a/rsworkspace/crates/acp-nats-stdio/src/main.rs +++ b/rsworkspace/crates/acp-nats-stdio/src/main.rs @@ -53,7 +53,9 @@ async fn main() -> Result<(), Box> { info!("ACP bridge stopped"); } - acp_telemetry::shutdown_otel(); + if let Err(e) = acp_telemetry::shutdown_otel() { + error!(error = %e, "OpenTelemetry shutdown failed"); + } result } diff --git a/rsworkspace/crates/acp-nats-ws/src/main.rs b/rsworkspace/crates/acp-nats-ws/src/main.rs index b04046e76..888d4cd25 100644 --- a/rsworkspace/crates/acp-nats-ws/src/main.rs +++ b/rsworkspace/crates/acp-nats-ws/src/main.rs @@ -78,7 +78,9 @@ async fn main() -> Result<(), Box> { error!("Connection thread panicked: {e:?}"); } - acp_telemetry::shutdown_otel(); + if let Err(e) = acp_telemetry::shutdown_otel() { + error!(error = %e, "OpenTelemetry shutdown failed"); + } result.map_err(|e| Box::new(e) as Box) } diff --git a/rsworkspace/crates/acp-telemetry/src/lib.rs b/rsworkspace/crates/acp-telemetry/src/lib.rs index ecfeb2210..86a98dabd 100644 --- a/rsworkspace/crates/acp-telemetry/src/lib.rs +++ b/rsworkspace/crates/acp-telemetry/src/lib.rs @@ -19,6 +19,23 @@ use tracing_subscriber::util::SubscriberInitExt; use trogon_std::env::ReadEnv; use trogon_std::fs::{CreateDirAll, OpenAppendFile}; +#[derive(Debug)] +pub struct TelemetryShutdownError { + errors: Vec, +} + +impl std::fmt::Display for TelemetryShutdownError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "failed to shutdown OpenTelemetry providers:")?; + for error in &self.errors { + writeln!(f, " - {error}")?; + } + Ok(()) + } +} + +impl std::error::Error for TelemetryShutdownError {} + fn try_open_log_file( service_name: ServiceName, env: &impl ReadEnv, @@ -145,16 +162,29 @@ fn try_init_otel( Ok((tracer_provider, meter_provider, logger_provider)) } -pub fn shutdown_otel() { +pub fn shutdown_otel() -> Result<(), TelemetryShutdownError> { tracing::info!("Shutting down OpenTelemetry providers"); trace::force_flush(); metric::force_flush(); log::force_flush(); - trace::shutdown(); - metric::shutdown(); - log::shutdown(); + let mut errors = Vec::new(); + if let Err(e) = trace::shutdown() { + errors.push(e); + } + if let Err(e) = metric::shutdown() { + errors.push(e); + } + if let Err(e) = log::shutdown() { + errors.push(e); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(TelemetryShutdownError { errors }) + } } pub fn meter(name: &'static str) -> opentelemetry::metrics::Meter { diff --git a/rsworkspace/crates/acp-telemetry/src/log.rs b/rsworkspace/crates/acp-telemetry/src/log.rs index 0be19d6c7..6292c2f11 100644 --- a/rsworkspace/crates/acp-telemetry/src/log.rs +++ b/rsworkspace/crates/acp-telemetry/src/log.rs @@ -6,7 +6,9 @@ use std::path::PathBuf; use std::sync::OnceLock; #[cfg(target_os = "macos")] use trogon_std::dirs::HomeDir; -use trogon_std::dirs::{StateDir, SystemDirs}; +#[cfg(not(target_os = "macos"))] +use trogon_std::dirs::StateDir; +use trogon_std::dirs::SystemDirs; use trogon_std::env::ReadEnv; use trogon_std::fs::CreateDirAll; @@ -33,12 +35,13 @@ pub(crate) fn force_flush() { } } -pub(crate) fn shutdown() { - if let Some(provider) = LOGGER_PROVIDER.get() - && let Err(e) = provider.shutdown() - { - eprintln!("Failed to shutdown logger provider: {e}"); +pub(crate) fn shutdown() -> Result<(), String> { + if let Some(provider) = LOGGER_PROVIDER.get() { + provider + .shutdown() + .map_err(|e| format!("failed to shutdown logger provider: {e}"))?; } + Ok(()) } pub(crate) fn ensure_log_dir( @@ -59,17 +62,13 @@ pub(crate) fn ensure_log_dir( #[cfg(target_os = "macos")] fn platform_log_dir(service_name: ServiceName) -> Result> { - if let Some(home) = SystemDirs.home_dir() { - Ok(home - .join("Library") - .join("Logs") - .join(service_name.as_str())) - } else { - Ok(SystemDirs - .state_dir() - .ok_or("could not determine home or state directory")? - .join(service_name.as_str())) - } + let home = SystemDirs + .home_dir() + .ok_or("could not determine home directory")?; + Ok(home + .join("Library") + .join("Logs") + .join(service_name.as_str())) } #[cfg(not(target_os = "macos"))] diff --git a/rsworkspace/crates/acp-telemetry/src/metric.rs b/rsworkspace/crates/acp-telemetry/src/metric.rs index 87f0cb4bd..c340b4aff 100644 --- a/rsworkspace/crates/acp-telemetry/src/metric.rs +++ b/rsworkspace/crates/acp-telemetry/src/metric.rs @@ -32,12 +32,13 @@ pub(crate) fn force_flush() { } } -pub(crate) fn shutdown() { +pub(crate) fn shutdown() -> Result<(), String> { if let Some(provider) = METER_PROVIDER.get() && let Err(e) = provider.shutdown() { - eprintln!("Failed to shutdown meter provider: {e}"); + return Err(format!("failed to shutdown meter provider: {e}")); } + Ok(()) } #[cfg(test)] diff --git a/rsworkspace/crates/acp-telemetry/src/service_name.rs b/rsworkspace/crates/acp-telemetry/src/service_name.rs index 0a6450b96..67789bbb0 100644 --- a/rsworkspace/crates/acp-telemetry/src/service_name.rs +++ b/rsworkspace/crates/acp-telemetry/src/service_name.rs @@ -5,6 +5,7 @@ pub enum ServiceName { AcpNatsStdio, AcpNatsWs, + TrogonGateway, TrogonSourceDiscord, TrogonSourceGithub, TrogonSourceGitlab, @@ -18,6 +19,7 @@ impl ServiceName { match self { Self::AcpNatsStdio => "acp-nats-stdio", Self::AcpNatsWs => "acp-nats-ws", + Self::TrogonGateway => "trogon-gateway", Self::TrogonSourceDiscord => "trogon-source-discord", Self::TrogonSourceGithub => "trogon-source-github", Self::TrogonSourceGitlab => "trogon-source-gitlab", @@ -42,6 +44,7 @@ mod tests { fn as_str_returns_expected_values() { assert_eq!(ServiceName::AcpNatsStdio.as_str(), "acp-nats-stdio"); assert_eq!(ServiceName::AcpNatsWs.as_str(), "acp-nats-ws"); + assert_eq!(ServiceName::TrogonGateway.as_str(), "trogon-gateway"); assert_eq!( ServiceName::TrogonSourceDiscord.as_str(), "trogon-source-discord" @@ -72,6 +75,7 @@ mod tests { fn display_delegates_to_as_str() { assert_eq!(format!("{}", ServiceName::AcpNatsStdio), "acp-nats-stdio"); assert_eq!(format!("{}", ServiceName::AcpNatsWs), "acp-nats-ws"); + assert_eq!(format!("{}", ServiceName::TrogonGateway), "trogon-gateway"); assert_eq!( format!("{}", ServiceName::TrogonSourceDiscord), "trogon-source-discord" diff --git a/rsworkspace/crates/acp-telemetry/src/trace.rs b/rsworkspace/crates/acp-telemetry/src/trace.rs index 475d8e248..cae60e4fe 100644 --- a/rsworkspace/crates/acp-telemetry/src/trace.rs +++ b/rsworkspace/crates/acp-telemetry/src/trace.rs @@ -25,12 +25,13 @@ pub(crate) fn force_flush() { } } -pub(crate) fn shutdown() { +pub(crate) fn shutdown() -> Result<(), String> { if let Some(provider) = TRACER_PROVIDER.get() && let Err(e) = provider.shutdown() { - eprintln!("Failed to shutdown tracer provider: {e}"); + return Err(format!("failed to shutdown tracer provider: {e}")); } + Ok(()) } #[cfg(test)] diff --git a/rsworkspace/crates/acp-telemetry/tests/lifecycle.rs b/rsworkspace/crates/acp-telemetry/tests/lifecycle.rs index 88fcc62ef..19d45c035 100644 --- a/rsworkspace/crates/acp-telemetry/tests/lifecycle.rs +++ b/rsworkspace/crates/acp-telemetry/tests/lifecycle.rs @@ -15,5 +15,5 @@ fn init_logger_creates_log_dir_and_shuts_down_cleanly() { "init_logger should create the configured log directory" ); - acp_telemetry::shutdown_otel(); + assert!(acp_telemetry::shutdown_otel().is_ok()); } diff --git a/rsworkspace/crates/trogon-gateway/Cargo.toml b/rsworkspace/crates/trogon-gateway/Cargo.toml new file mode 100644 index 000000000..e8ba48206 --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "trogon-gateway" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[[bin]] +name = "trogon-gateway" +path = "src/main.rs" + +[dependencies] +acp-telemetry = { workspace = true } +async-nats = { workspace = true, features = ["jetstream"] } +axum = { workspace = true } +clap = { workspace = true } +confique = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +trogon-nats = { workspace = true } +trogon-source-discord = { workspace = true } +trogon-source-github = { workspace = true } +trogon-source-gitlab = { workspace = true } +trogon-source-linear = { workspace = true } +trogon-source-slack = { workspace = true } +trogon-source-telegram = { workspace = true } +trogon-std = { workspace = true, features = ["clap"] } + +[dev-dependencies] +tempfile = { workspace = true } +trogon-nats = { workspace = true, features = ["test-support"] } diff --git a/rsworkspace/crates/trogon-gateway/src/cli.rs b/rsworkspace/crates/trogon-gateway/src/cli.rs new file mode 100644 index 000000000..f4e252d4e --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/src/cli.rs @@ -0,0 +1,16 @@ +use std::path::PathBuf; + +#[derive(clap::Parser, Clone)] +#[command(name = "trogon-gateway", about = "Unified gateway ingestion binary")] +pub struct Cli { + #[command(subcommand)] + pub command: Command, +} + +#[derive(clap::Subcommand, Clone)] +pub enum Command { + Serve { + #[arg(long, short)] + config: Option, + }, +} diff --git a/rsworkspace/crates/trogon-gateway/src/config.rs b/rsworkspace/crates/trogon-gateway/src/config.rs new file mode 100644 index 000000000..5493f400d --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/src/config.rs @@ -0,0 +1,1567 @@ +use std::fmt; +use std::path::Path; + +use confique::Config; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_nats::{NatsAuth, NatsToken}; +use trogon_source_discord::config::DiscordBotToken; +use trogon_source_github::config::GitHubWebhookSecret; +use trogon_source_gitlab::config::GitLabWebhookSecret; +use trogon_source_linear::config::LinearWebhookSecret; +use trogon_source_slack::config::SlackSigningSecret; +use trogon_source_telegram::config::TelegramWebhookSecret; +use trogon_std::NonZeroDuration; + +#[derive(Debug)] +pub enum ConfigError { + Load(confique::Error), + Validation(Vec), +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Load(e) => write!(f, "failed to load config: {e}"), + Self::Validation(errors) => { + writeln!(f, "config validation errors:")?; + for e in errors { + writeln!(f, " - {e}")?; + } + Ok(()) + } + } + } +} + +impl std::error::Error for ConfigError {} + +#[derive(Config)] +struct GatewayConfig { + #[config(nested)] + http_server: HttpServerConfig, + #[config(nested)] + nats: NatsConfig, + #[config(nested)] + sources: SourcesConfig, +} + +#[derive(Config)] +struct HttpServerConfig { + #[config(env = "TROGON_GATEWAY_PORT", default = 8080)] + port: u16, +} + +#[derive(Config)] +struct NatsConfig { + #[config(env = "NATS_URL", default = "localhost:4222")] + url: String, + #[config(env = "NATS_CREDS")] + creds: Option, + #[config(env = "NATS_NKEY")] + nkey: Option, + #[config(env = "NATS_USER")] + user: Option, + #[config(env = "NATS_PASSWORD")] + password: Option, + #[config(env = "NATS_TOKEN")] + token: Option, +} + +#[derive(Config)] +struct SourcesConfig { + #[config(nested)] + github: GithubConfig, + #[config(nested)] + discord: DiscordConfig, + #[config(nested)] + slack: SlackConfig, + #[config(nested)] + telegram: TelegramConfig, + #[config(nested)] + gitlab: GitlabConfig, + #[config(nested)] + linear: LinearConfig, +} + +#[derive(Config)] +struct GithubConfig { + #[config(env = "TROGON_SOURCE_GITHUB_WEBHOOK_SECRET")] + webhook_secret: Option, + #[config(env = "TROGON_SOURCE_GITHUB_SUBJECT_PREFIX", default = "github")] + subject_prefix: String, + #[config(env = "TROGON_SOURCE_GITHUB_STREAM_NAME", default = "GITHUB")] + stream_name: String, + #[config(env = "TROGON_SOURCE_GITHUB_STREAM_MAX_AGE_SECS", default = 604_800)] + stream_max_age_secs: u64, + #[config(env = "TROGON_SOURCE_GITHUB_NATS_ACK_TIMEOUT_SECS", default = 10)] + nats_ack_timeout_secs: u64, +} + +#[derive(Config)] +struct DiscordConfig { + #[config(env = "TROGON_SOURCE_DISCORD_MODE")] + mode: Option, + #[config(env = "TROGON_SOURCE_DISCORD_BOT_TOKEN")] + bot_token: Option, + #[config(env = "TROGON_SOURCE_DISCORD_GATEWAY_INTENTS")] + gateway_intents: Option, + #[config(env = "TROGON_SOURCE_DISCORD_PUBLIC_KEY")] + public_key: Option, + #[config(env = "TROGON_SOURCE_DISCORD_SUBJECT_PREFIX", default = "discord")] + subject_prefix: String, + #[config(env = "TROGON_SOURCE_DISCORD_STREAM_NAME", default = "DISCORD")] + stream_name: String, + #[config(env = "TROGON_SOURCE_DISCORD_STREAM_MAX_AGE_SECS", default = 604_800)] + stream_max_age_secs: u64, + #[config(env = "TROGON_SOURCE_DISCORD_NATS_ACK_TIMEOUT_SECS", default = 10)] + nats_ack_timeout_secs: u64, + #[config(env = "TROGON_SOURCE_DISCORD_NATS_REQUEST_TIMEOUT_SECS", default = 2)] + nats_request_timeout_secs: u64, +} + +#[derive(Config)] +struct SlackConfig { + #[config(env = "TROGON_SOURCE_SLACK_SIGNING_SECRET")] + signing_secret: Option, + #[config(env = "TROGON_SOURCE_SLACK_SUBJECT_PREFIX", default = "slack")] + subject_prefix: String, + #[config(env = "TROGON_SOURCE_SLACK_STREAM_NAME", default = "SLACK")] + stream_name: String, + #[config(env = "TROGON_SOURCE_SLACK_STREAM_MAX_AGE_SECS", default = 604_800)] + stream_max_age_secs: u64, + #[config(env = "TROGON_SOURCE_SLACK_NATS_ACK_TIMEOUT_SECS", default = 10)] + nats_ack_timeout_secs: u64, + #[config(env = "TROGON_SOURCE_SLACK_TIMESTAMP_MAX_DRIFT_SECS", default = 300)] + timestamp_max_drift_secs: u64, +} + +#[derive(Config)] +struct TelegramConfig { + #[config(env = "TROGON_SOURCE_TELEGRAM_WEBHOOK_SECRET")] + webhook_secret: Option, + #[config(env = "TROGON_SOURCE_TELEGRAM_SUBJECT_PREFIX", default = "telegram")] + subject_prefix: String, + #[config(env = "TROGON_SOURCE_TELEGRAM_STREAM_NAME", default = "TELEGRAM")] + stream_name: String, + #[config(env = "TROGON_SOURCE_TELEGRAM_STREAM_MAX_AGE_SECS", default = 604_800)] + stream_max_age_secs: u64, + #[config(env = "TROGON_SOURCE_TELEGRAM_NATS_ACK_TIMEOUT_SECS", default = 10)] + nats_ack_timeout_secs: u64, +} + +#[derive(Config)] +struct GitlabConfig { + #[config(env = "TROGON_SOURCE_GITLAB_WEBHOOK_SECRET")] + webhook_secret: Option, + #[config(env = "TROGON_SOURCE_GITLAB_SUBJECT_PREFIX", default = "gitlab")] + subject_prefix: String, + #[config(env = "TROGON_SOURCE_GITLAB_STREAM_NAME", default = "GITLAB")] + stream_name: String, + #[config(env = "TROGON_SOURCE_GITLAB_STREAM_MAX_AGE_SECS", default = 604_800)] + stream_max_age_secs: u64, + #[config(env = "TROGON_SOURCE_GITLAB_NATS_ACK_TIMEOUT_SECS", default = 10)] + nats_ack_timeout_secs: u64, +} + +#[derive(Config)] +struct LinearConfig { + #[config(env = "TROGON_SOURCE_LINEAR_WEBHOOK_SECRET")] + webhook_secret: Option, + #[config(env = "TROGON_SOURCE_LINEAR_SUBJECT_PREFIX", default = "linear")] + subject_prefix: String, + #[config(env = "TROGON_SOURCE_LINEAR_STREAM_NAME", default = "LINEAR")] + stream_name: String, + #[config(env = "TROGON_SOURCE_LINEAR_STREAM_MAX_AGE_SECS", default = 604_800)] + stream_max_age_secs: u64, + #[config(env = "TROGON_SOURCE_LINEAR_NATS_ACK_TIMEOUT_SECS", default = 10)] + nats_ack_timeout_secs: u64, + #[config(env = "TROGON_SOURCE_LINEAR_TIMESTAMP_TOLERANCE_SECS", default = 60)] + timestamp_tolerance_secs: u64, +} + +pub struct ResolvedHttpServerConfig { + pub port: u16, +} + +pub struct ResolvedConfig { + pub http_server: ResolvedHttpServerConfig, + pub nats: trogon_nats::NatsConfig, + pub github: Option, + pub discord: Option, + pub slack: Option, + pub telegram: Option, + pub gitlab: Option, + pub linear: Option, +} + +impl ResolvedConfig { + pub fn has_any_source(&self) -> bool { + self.github.is_some() + || self.discord.is_some() + || self.slack.is_some() + || self.telegram.is_some() + || self.gitlab.is_some() + || self.linear.is_some() + } +} + +pub fn load(config_path: Option<&Path>) -> Result { + let mut builder = GatewayConfig::builder(); + if let Some(path) = config_path { + builder = builder.file(path); + } + let cfg = builder.env().load().map_err(ConfigError::Load)?; + resolve(cfg) +} + +fn resolve(cfg: GatewayConfig) -> Result { + let nats = resolve_nats(&cfg.nats); + let mut errors = Vec::new(); + + let github = resolve_github(cfg.sources.github, &mut errors); + let discord = resolve_discord(cfg.sources.discord, &mut errors); + let slack = resolve_slack(cfg.sources.slack, &mut errors); + let telegram = resolve_telegram(cfg.sources.telegram, &mut errors); + let gitlab = resolve_gitlab(cfg.sources.gitlab, &mut errors); + let linear = resolve_linear(cfg.sources.linear, &mut errors); + + if !errors.is_empty() { + return Err(ConfigError::Validation(errors)); + } + + Ok(ResolvedConfig { + http_server: ResolvedHttpServerConfig { + port: cfg.http_server.port, + }, + nats, + github, + discord, + slack, + telegram, + gitlab, + linear, + }) +} + +fn non_empty(opt: &Option) -> Option<&String> { + opt.as_ref().filter(|s| !s.is_empty()) +} + +fn resolve_nats(section: &NatsConfig) -> trogon_nats::NatsConfig { + let auth = if let Some(creds) = non_empty(§ion.creds) { + NatsAuth::Credentials(creds.clone().into()) + } else if let Some(nkey) = non_empty(§ion.nkey) { + NatsAuth::NKey(nkey.clone()) + } else if let (Some(user), Some(password)) = + (non_empty(§ion.user), non_empty(§ion.password)) + { + NatsAuth::UserPassword { + user: user.clone(), + password: password.clone(), + } + } else if let Some(token) = non_empty(§ion.token) { + NatsAuth::Token(token.clone()) + } else { + NatsAuth::None + }; + + let servers: Vec = section + .url + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + trogon_nats::NatsConfig::new(servers, auth) +} + +fn resolve_github( + section: GithubConfig, + errors: &mut Vec, +) -> Option { + let secret_str = section.webhook_secret?; + let webhook_secret = match GitHubWebhookSecret::new(secret_str) { + Ok(s) => s, + Err(e) => { + errors.push(format!("github: invalid webhook_secret: {e}")); + return None; + } + }; + + let subject_prefix = match NatsToken::new(section.subject_prefix) { + Ok(t) => t, + Err(e) => { + errors.push(format!("github: invalid subject_prefix: {e:?}")); + return None; + } + }; + + let stream_name = match NatsToken::new(section.stream_name) { + Ok(t) => t, + Err(e) => { + errors.push(format!("github: invalid stream_name: {e:?}")); + return None; + } + }; + + let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("github: nats_ack_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) { + Ok(age) => age, + Err(_) => { + errors.push("github: stream_max_age_secs must not be zero".to_string()); + return None; + } + }; + + Some(trogon_source_github::GithubConfig { + webhook_secret, + subject_prefix, + stream_name, + stream_max_age, + nats_ack_timeout, + }) +} + +fn resolve_discord( + section: DiscordConfig, + errors: &mut Vec, +) -> Option { + let mode_str = section.mode.as_deref().filter(|s| !s.is_empty())?; + + let mode = resolve_discord_mode(§ion, mode_str, errors)?; + + let subject_prefix = match NatsToken::new(section.subject_prefix) { + Ok(t) => t, + Err(e) => { + errors.push(format!("discord: invalid subject_prefix: {e:?}")); + return None; + } + }; + + let stream_name = match NatsToken::new(section.stream_name) { + Ok(t) => t, + Err(e) => { + errors.push(format!("discord: invalid stream_name: {e:?}")); + return None; + } + }; + + let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("discord: nats_ack_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let nats_request_timeout = match NonZeroDuration::from_secs(section.nats_request_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("discord: nats_request_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) { + Ok(age) => age, + Err(_) => { + errors.push("discord: stream_max_age_secs must not be zero".to_string()); + return None; + } + }; + + Some(trogon_source_discord::DiscordConfig { + mode, + subject_prefix, + stream_name, + stream_max_age, + nats_ack_timeout, + nats_request_timeout, + }) +} + +fn resolve_discord_mode( + section: &DiscordConfig, + mode_str: &str, + errors: &mut Vec, +) -> Option { + match mode_str.to_ascii_lowercase().as_str() { + "gateway" => { + let Some(token_str) = section.bot_token.as_deref() else { + errors.push("discord: bot_token is required when mode=gateway".to_string()); + return None; + }; + let bot_token = match DiscordBotToken::new(token_str) { + Ok(s) => s, + Err(e) => { + errors.push(format!("discord: invalid bot_token: {e}")); + return None; + } + }; + + let intents = + if let Some(s) = section.gateway_intents.as_deref().filter(|s| !s.is_empty()) { + match trogon_source_discord::config::parse_gateway_intents(s) { + Ok(i) => i, + Err(e) => { + errors.push(format!("discord: invalid gateway_intents: {e}")); + return None; + } + } + } else { + trogon_source_discord::config::default_intents() + }; + + Some(trogon_source_discord::config::SourceMode::Gateway { bot_token, intents }) + } + "webhook" => { + let Some(public_key_hex) = section.public_key.as_deref().filter(|s| !s.is_empty()) + else { + errors.push("discord: public_key is required when mode=webhook".to_string()); + return None; + }; + + let public_key = + match trogon_source_discord::signature::parse_public_key(public_key_hex) { + Ok(pk) => pk, + Err(e) => { + errors.push(format!("discord: invalid public_key: {e}")); + return None; + } + }; + + Some(trogon_source_discord::config::SourceMode::Webhook { public_key }) + } + other => { + errors.push(format!( + "discord: mode must be 'gateway' or 'webhook', got '{other}'" + )); + None + } + } +} + +fn resolve_slack( + section: SlackConfig, + errors: &mut Vec, +) -> Option { + let secret_str = section.signing_secret?; + let signing_secret = match SlackSigningSecret::new(secret_str) { + Ok(s) => s, + Err(e) => { + errors.push(format!("slack: invalid signing_secret: {e}")); + return None; + } + }; + + let subject_prefix = match NatsToken::new(section.subject_prefix) { + Ok(t) => t, + Err(e) => { + errors.push(format!("slack: invalid subject_prefix: {e:?}")); + return None; + } + }; + + let stream_name = match NatsToken::new(section.stream_name) { + Ok(t) => t, + Err(e) => { + errors.push(format!("slack: invalid stream_name: {e:?}")); + return None; + } + }; + + let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("slack: nats_ack_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let timestamp_max_drift = match NonZeroDuration::from_secs(section.timestamp_max_drift_secs) { + Ok(d) => d, + Err(_) => { + errors.push("slack: timestamp_max_drift_secs must not be zero".to_string()); + return None; + } + }; + + let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) { + Ok(age) => age, + Err(_) => { + errors.push("slack: stream_max_age_secs must not be zero".to_string()); + return None; + } + }; + + Some(trogon_source_slack::SlackConfig { + signing_secret, + subject_prefix, + stream_name, + stream_max_age, + nats_ack_timeout, + timestamp_max_drift, + }) +} + +fn resolve_telegram( + section: TelegramConfig, + errors: &mut Vec, +) -> Option { + let secret_str = section.webhook_secret?; + let webhook_secret = match TelegramWebhookSecret::new(secret_str) { + Ok(s) => s, + Err(e) => { + errors.push(format!("telegram: invalid webhook_secret: {e}")); + return None; + } + }; + + let subject_prefix = match NatsToken::new(section.subject_prefix) { + Ok(t) => t, + Err(e) => { + errors.push(format!("telegram: invalid subject_prefix: {e:?}")); + return None; + } + }; + + let stream_name = match NatsToken::new(section.stream_name) { + Ok(t) => t, + Err(e) => { + errors.push(format!("telegram: invalid stream_name: {e:?}")); + return None; + } + }; + + let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("telegram: nats_ack_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) { + Ok(age) => age, + Err(_) => { + errors.push("telegram: stream_max_age_secs must not be zero".to_string()); + return None; + } + }; + + Some(trogon_source_telegram::TelegramSourceConfig { + webhook_secret, + subject_prefix, + stream_name, + stream_max_age, + nats_ack_timeout, + }) +} + +fn resolve_gitlab( + section: GitlabConfig, + errors: &mut Vec, +) -> Option { + let webhook_secret_str = section.webhook_secret?; + let webhook_secret = match GitLabWebhookSecret::new(webhook_secret_str) { + Ok(s) => s, + Err(e) => { + errors.push(format!("gitlab: invalid webhook_secret: {e}")); + return None; + } + }; + + let subject_prefix = match NatsToken::new(section.subject_prefix) { + Ok(t) => t, + Err(e) => { + errors.push(format!("gitlab: invalid subject_prefix: {e:?}")); + return None; + } + }; + + let stream_name = match NatsToken::new(section.stream_name) { + Ok(t) => t, + Err(e) => { + errors.push(format!("gitlab: invalid stream_name: {e:?}")); + return None; + } + }; + + let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("gitlab: nats_ack_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) { + Ok(age) => age, + Err(_) => { + errors.push("gitlab: stream_max_age_secs must not be zero".to_string()); + return None; + } + }; + + Some(trogon_source_gitlab::GitlabConfig { + webhook_secret, + subject_prefix, + stream_name, + stream_max_age, + nats_ack_timeout, + }) +} + +fn resolve_linear( + section: LinearConfig, + errors: &mut Vec, +) -> Option { + let secret_str = section.webhook_secret?; + let webhook_secret = match LinearWebhookSecret::new(secret_str) { + Ok(s) => s, + Err(e) => { + errors.push(format!("linear: invalid webhook_secret: {e}")); + return None; + } + }; + + let subject_prefix = match NatsToken::new(section.subject_prefix) { + Ok(t) => t, + Err(e) => { + errors.push(format!("linear: invalid subject_prefix: {e:?}")); + return None; + } + }; + + let stream_name = match NatsToken::new(section.stream_name) { + Ok(t) => t, + Err(e) => { + errors.push(format!("linear: invalid stream_name: {e:?}")); + return None; + } + }; + + let nats_ack_timeout = match NonZeroDuration::from_secs(section.nats_ack_timeout_secs) { + Ok(d) => d, + Err(_) => { + errors.push("linear: nats_ack_timeout_secs must not be zero".to_string()); + return None; + } + }; + + let stream_max_age = match StreamMaxAge::from_secs(section.stream_max_age_secs) { + Ok(age) => age, + Err(_) => { + errors.push("linear: stream_max_age_secs must not be zero".to_string()); + return None; + } + }; + + Some(trogon_source_linear::LinearConfig { + webhook_secret, + subject_prefix, + stream_name, + stream_max_age, + timestamp_tolerance: NonZeroDuration::from_secs(section.timestamp_tolerance_secs).ok(), + nats_ack_timeout, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + const VALID_ED25519_PUB_KEY: &str = + "236a4d1cb6b5d3b6e25664d96be99807095ea11930159bb832e53b87761648c3"; + + fn write_toml(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::Builder::new() + .suffix(".toml") + .tempfile() + .expect("failed to create temp file"); + f.write_all(content.as_bytes()) + .expect("failed to write toml"); + f.flush().expect("failed to flush"); + f + } + + fn minimal_toml() -> String { + String::new() + } + + fn github_toml(secret: &str) -> String { + format!( + r#" +[sources.github] +webhook_secret = "{secret}" +"# + ) + } + + fn discord_gateway_toml(bot_token: &str) -> String { + format!( + r#" +[sources.discord] +mode = "gateway" +bot_token = "{bot_token}" +"# + ) + } + + fn discord_webhook_toml(public_key: &str) -> String { + format!( + r#" +[sources.discord] +mode = "webhook" +public_key = "{public_key}" +"# + ) + } + + fn slack_toml(secret: &str) -> String { + format!( + r#" +[sources.slack] +signing_secret = "{secret}" +"# + ) + } + + fn telegram_toml(secret: &str) -> String { + format!( + r#" +[sources.telegram] +webhook_secret = "{secret}" +"# + ) + } + + fn gitlab_toml(secret: &str) -> String { + format!( + r#" +[sources.gitlab] +webhook_secret = "{secret}" +"# + ) + } + + fn linear_toml(secret: &str) -> String { + format!( + r#" +[sources.linear] +webhook_secret = "{secret}" +"# + ) + } + + fn nats_toml_with_creds(creds: &str) -> String { + format!( + r#" +[nats] +creds = "{creds}" +"# + ) + } + + fn nats_toml_with_nkey(nkey: &str) -> String { + format!( + r#" +[nats] +nkey = "{nkey}" +"# + ) + } + + fn nats_toml_with_user_password(user: &str, password: &str) -> String { + format!( + r#" +[nats] +user = "{user}" +password = "{password}" +"# + ) + } + + fn nats_toml_with_token(token: &str) -> String { + format!( + r#" +[nats] +token = "{token}" +"# + ) + } + + #[test] + fn has_any_source_returns_false_when_nothing_configured() { + let f = write_toml(&minimal_toml()); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(!cfg.has_any_source()); + } + + #[test] + fn github_resolves_with_valid_secret() { + let f = write_toml(&github_toml("my-gh-secret")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.github.is_some()); + assert!(cfg.has_any_source()); + } + + #[test] + fn discord_gateway_resolves_with_valid_token() { + let f = write_toml(&discord_gateway_toml("Bot my-bot-token")); + let cfg = load(Some(f.path())).expect("load failed"); + let discord = cfg.discord.as_ref().expect("discord should be Some"); + assert!(matches!( + discord.mode, + trogon_source_discord::config::SourceMode::Gateway { .. } + )); + } + + #[test] + fn discord_gateway_with_intents() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot my-bot-token" +gateway_intents = "guilds,guild_messages" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.discord.is_some()); + } + + #[test] + fn discord_gateway_with_invalid_intents() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot my-bot-token" +gateway_intents = "bogus_intent" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!(matches!(result, Err(ConfigError::Validation(_)))); + } + + #[test] + fn discord_webhook_resolves_with_valid_key() { + let f = write_toml(&discord_webhook_toml(VALID_ED25519_PUB_KEY)); + let cfg = load(Some(f.path())).expect("load failed"); + let discord = cfg.discord.as_ref().expect("discord should be Some"); + assert!(matches!( + discord.mode, + trogon_source_discord::config::SourceMode::Webhook { .. } + )); + } + + #[test] + fn discord_webhook_invalid_public_key() { + let f = write_toml(&discord_webhook_toml("not-valid-hex")); + let result = load(Some(f.path())); + assert!(matches!(result, Err(ConfigError::Validation(_)))); + } + + #[test] + fn discord_unknown_mode() { + let toml = r#" +[sources.discord] +mode = "unknown" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("must be 'gateway' or 'webhook'"))) + ); + } + + #[test] + fn discord_mode_empty_string_returns_none() { + let toml = r#" +[sources.discord] +mode = "" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.discord.is_none()); + } + + #[test] + fn discord_gateway_empty_bot_token() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("invalid bot_token"))) + ); + } + + #[test] + fn discord_gateway_missing_bot_token() { + let toml = r#" +[sources.discord] +mode = "gateway" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("bot_token is required"))) + ); + } + + #[test] + fn discord_webhook_empty_public_key() { + let toml = r#" +[sources.discord] +mode = "webhook" +public_key = "" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("public_key is required"))) + ); + } + + #[test] + fn slack_resolves_with_valid_secret() { + let f = write_toml(&slack_toml("slack-signing-secret")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.slack.is_some()); + } + + #[test] + fn telegram_resolves_with_valid_secret() { + let f = write_toml(&telegram_toml("telegram-webhook-secret")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.telegram.is_some()); + } + + #[test] + fn gitlab_resolves_with_valid_secret() { + let f = write_toml(&gitlab_toml("gitlab-webhook-secret")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.gitlab.is_some()); + } + + #[test] + fn linear_resolves_with_valid_secret() { + let f = write_toml(&linear_toml("linear-webhook-secret")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(cfg.linear.is_some()); + } + + #[test] + fn linear_with_zero_timestamp_tolerance() { + let toml = r#" +[sources.linear] +webhook_secret = "linear-secret" +timestamp_tolerance_secs = 0 +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + let linear = cfg.linear.as_ref().expect("linear should be Some"); + assert!(linear.timestamp_tolerance.is_none()); + } + + #[test] + fn github_zero_nats_ack_timeout_is_error() { + let toml = r#" +[sources.github] +webhook_secret = "gh-secret" +nats_ack_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_ack_timeout_secs must not be zero"))) + ); + } + + #[test] + fn github_zero_stream_max_age_is_error() { + let toml = r#" +[sources.github] +webhook_secret = "gh-secret" +stream_max_age_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_max_age_secs must not be zero"))) + ); + } + + #[test] + fn slack_zero_nats_ack_timeout_is_error() { + let toml = r#" +[sources.slack] +signing_secret = "slack-secret" +nats_ack_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_ack_timeout_secs must not be zero"))) + ); + } + + #[test] + fn slack_zero_timestamp_max_drift_is_error() { + let toml = r#" +[sources.slack] +signing_secret = "slack-secret" +timestamp_max_drift_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("timestamp_max_drift_secs must not be zero"))) + ); + } + + #[test] + fn slack_zero_stream_max_age_is_error() { + let toml = r#" +[sources.slack] +signing_secret = "slack-secret" +stream_max_age_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_max_age_secs must not be zero"))) + ); + } + + #[test] + fn telegram_zero_nats_ack_timeout_is_error() { + let toml = r#" +[sources.telegram] +webhook_secret = "tg-secret" +nats_ack_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_ack_timeout_secs must not be zero"))) + ); + } + + #[test] + fn telegram_zero_stream_max_age_is_error() { + let toml = r#" +[sources.telegram] +webhook_secret = "tg-secret" +stream_max_age_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_max_age_secs must not be zero"))) + ); + } + + #[test] + fn gitlab_zero_nats_ack_timeout_is_error() { + let toml = r#" +[sources.gitlab] +webhook_secret = "gl-secret" +nats_ack_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_ack_timeout_secs must not be zero"))) + ); + } + + #[test] + fn gitlab_zero_stream_max_age_is_error() { + let toml = r#" +[sources.gitlab] +webhook_secret = "gl-secret" +stream_max_age_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_max_age_secs must not be zero"))) + ); + } + + #[test] + fn linear_zero_nats_ack_timeout_is_error() { + let toml = r#" +[sources.linear] +webhook_secret = "lin-secret" +nats_ack_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_ack_timeout_secs must not be zero"))) + ); + } + + #[test] + fn linear_zero_stream_max_age_is_error() { + let toml = r#" +[sources.linear] +webhook_secret = "lin-secret" +stream_max_age_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_max_age_secs must not be zero"))) + ); + } + + #[test] + fn discord_zero_nats_ack_timeout_is_error() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot token" +nats_ack_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_ack_timeout_secs must not be zero"))) + ); + } + + #[test] + fn discord_zero_nats_request_timeout_is_error() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot token" +nats_request_timeout_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("nats_request_timeout_secs must not be zero"))) + ); + } + + #[test] + fn discord_zero_stream_max_age_is_error() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot token" +stream_max_age_secs = 0 +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_max_age_secs must not be zero"))) + ); + } + + #[test] + fn github_invalid_subject_prefix() { + let toml = r#" +[sources.github] +webhook_secret = "gh-secret" +subject_prefix = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("subject_prefix"))) + ); + } + + #[test] + fn github_invalid_stream_name() { + let toml = r#" +[sources.github] +webhook_secret = "gh-secret" +stream_name = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_name"))) + ); + } + + #[test] + fn nats_default_is_no_auth() { + let f = write_toml(&minimal_toml()); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::None)); + } + + #[test] + fn nats_credentials_auth() { + let f = write_toml(&nats_toml_with_creds("/path/to/creds")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::Credentials(_))); + } + + #[test] + fn nats_nkey_auth() { + let f = write_toml(&nats_toml_with_nkey("SUAIBDPBAUTW")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::NKey(_))); + } + + #[test] + fn nats_user_password_auth() { + let f = write_toml(&nats_toml_with_user_password("myuser", "mypass")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::UserPassword { .. })); + } + + #[test] + fn nats_token_auth() { + let f = write_toml(&nats_toml_with_token("mytoken")); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::Token(_))); + } + + #[test] + fn nats_creds_takes_priority_over_token() { + let toml = r#" +[nats] +creds = "/path/to/creds" +token = "mytoken" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::Credentials(_))); + } + + #[test] + fn nats_nkey_takes_priority_over_user_password() { + let toml = r#" +[nats] +nkey = "SUAIBDPBAUTW" +user = "myuser" +password = "mypass" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::NKey(_))); + } + + #[test] + fn nats_user_password_takes_priority_over_token() { + let toml = r#" +[nats] +user = "myuser" +password = "mypass" +token = "mytoken" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::UserPassword { .. })); + } + + #[test] + fn nats_empty_creds_falls_through() { + let toml = r#" +[nats] +creds = "" +token = "mytoken" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::Token(_))); + } + + #[test] + fn nats_url_comma_separated() { + let toml = r#" +[nats] +url = "host1:4222, host2:4222, host3:4222" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert_eq!(cfg.nats.servers.len(), 3); + } + + #[test] + fn nats_user_without_password_falls_through() { + let toml = r#" +[nats] +user = "myuser" +token = "mytoken" +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert!(matches!(cfg.nats.auth, NatsAuth::Token(_))); + } + + #[test] + fn non_empty_filters_none() { + let val: Option = None; + assert!(non_empty(&val).is_none()); + } + + #[test] + fn non_empty_filters_empty_string() { + let val = Some(String::new()); + assert!(non_empty(&val).is_none()); + } + + #[test] + fn non_empty_passes_through_nonempty() { + let val = Some("hello".to_string()); + assert_eq!(non_empty(&val), Some(&"hello".to_string())); + } + + #[test] + fn config_error_display_load() { + let f = write_toml("this is not { valid toml"); + let result = load(Some(f.path())); + assert!(matches!(result, Err(ConfigError::Load(_)))); + let err = result.err().unwrap(); + let display = format!("{err}"); + assert!(display.contains("failed to load config")); + } + + #[test] + fn config_error_display_validation() { + let err = ConfigError::Validation(vec!["error one".to_string(), "error two".to_string()]); + let display = format!("{err}"); + assert!(display.contains("config validation errors:")); + assert!(display.contains("error one")); + assert!(display.contains("error two")); + } + + #[test] + fn config_error_is_std_error() { + let err = ConfigError::Validation(vec!["test".to_string()]); + let _: &dyn std::error::Error = &err; + } + + #[test] + fn http_server_default_port() { + let f = write_toml(&minimal_toml()); + let cfg = load(Some(f.path())).expect("load failed"); + assert_eq!(cfg.http_server.port, 8080); + } + + #[test] + fn http_server_custom_port() { + let toml = r#" +[http_server] +port = 9090 +"#; + let f = write_toml(toml); + let cfg = load(Some(f.path())).expect("load failed"); + assert_eq!(cfg.http_server.port, 9090); + } + + #[test] + fn github_empty_secret_is_invalid() { + let f = write_toml(&github_toml("")); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("github: invalid webhook_secret"))) + ); + } + + #[test] + fn slack_empty_secret_is_invalid() { + let f = write_toml(&slack_toml("")); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("slack: invalid signing_secret"))) + ); + } + + #[test] + fn telegram_empty_secret_is_invalid() { + let f = write_toml(&telegram_toml("")); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("telegram: invalid webhook_secret"))) + ); + } + + #[test] + fn gitlab_empty_secret_is_invalid() { + let f = write_toml(&gitlab_toml("")); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("gitlab: invalid webhook_secret"))) + ); + } + + #[test] + fn linear_empty_secret_is_invalid() { + let f = write_toml(&linear_toml("")); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("linear: invalid webhook_secret"))) + ); + } + + #[test] + fn discord_invalid_subject_prefix() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot token" +subject_prefix = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("subject_prefix"))) + ); + } + + #[test] + fn discord_invalid_stream_name() { + let toml = r#" +[sources.discord] +mode = "gateway" +bot_token = "Bot token" +stream_name = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_name"))) + ); + } + + #[test] + fn slack_invalid_subject_prefix() { + let toml = r#" +[sources.slack] +signing_secret = "slack-secret" +subject_prefix = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("subject_prefix"))) + ); + } + + #[test] + fn telegram_invalid_subject_prefix() { + let toml = r#" +[sources.telegram] +webhook_secret = "tg-secret" +subject_prefix = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("subject_prefix"))) + ); + } + + #[test] + fn gitlab_invalid_subject_prefix() { + let toml = r#" +[sources.gitlab] +webhook_secret = "gl-secret" +subject_prefix = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("subject_prefix"))) + ); + } + + #[test] + fn linear_invalid_subject_prefix() { + let toml = r#" +[sources.linear] +webhook_secret = "lin-secret" +subject_prefix = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("subject_prefix"))) + ); + } + + #[test] + fn slack_invalid_stream_name() { + let toml = r#" +[sources.slack] +signing_secret = "slack-secret" +stream_name = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_name"))) + ); + } + + #[test] + fn telegram_invalid_stream_name() { + let toml = r#" +[sources.telegram] +webhook_secret = "tg-secret" +stream_name = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_name"))) + ); + } + + #[test] + fn gitlab_invalid_stream_name() { + let toml = r#" +[sources.gitlab] +webhook_secret = "gl-secret" +stream_name = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_name"))) + ); + } + + #[test] + fn linear_invalid_stream_name() { + let toml = r#" +[sources.linear] +webhook_secret = "lin-secret" +stream_name = "has.dots" +"#; + let f = write_toml(toml); + let result = load(Some(f.path())); + assert!( + matches!(result, Err(ConfigError::Validation(ref errs)) if errs.iter().any(|e| e.contains("stream_name"))) + ); + } + + #[test] + fn load_invalid_toml_returns_load_error() { + let f = write_toml("this is not { valid toml"); + let result = load(Some(f.path())); + assert!(matches!(result, Err(ConfigError::Load(_)))); + } +} diff --git a/rsworkspace/crates/trogon-gateway/src/http.rs b/rsworkspace/crates/trogon-gateway/src/http.rs new file mode 100644 index 000000000..60608bc3a --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/src/http.rs @@ -0,0 +1,159 @@ +use axum::Router; +use tracing::info; +use trogon_nats::jetstream::{ClaimCheckPublisher, JetStreamPublisher, ObjectStorePut}; + +use crate::config::ResolvedConfig; + +pub(crate) fn mount_sources( + config: ResolvedConfig, + publisher: ClaimCheckPublisher, + nats: R, +) -> Router +where + P: JetStreamPublisher, + S: ObjectStorePut, + R: trogon_nats::RequestClient, +{ + let mut app = Router::new() + .route( + "/-/liveness", + axum::routing::get(|| async { axum::http::StatusCode::OK }), + ) + .route( + "/-/readiness", + axum::routing::get(|| async { axum::http::StatusCode::OK }), + ); + + if let Some(ref cfg) = config.github { + app = app.nest( + "/github", + trogon_source_github::router(publisher.clone(), cfg), + ); + info!(source = "github", "mounted at /github"); + } + + if let Some(ref cfg) = config.discord + && let trogon_source_discord::config::SourceMode::Webhook { public_key } = cfg.mode + { + let sub = trogon_source_discord::router(publisher.clone(), nats.clone(), public_key, cfg); + + app = app.nest("/discord", sub); + info!(source = "discord", "mounted at /discord"); + } + + if let Some(ref cfg) = config.slack { + app = app.nest( + "/slack", + trogon_source_slack::router(publisher.clone(), cfg), + ); + info!(source = "slack", "mounted at /slack"); + } + + if let Some(ref cfg) = config.telegram { + app = app.nest( + "/telegram", + trogon_source_telegram::router(publisher.clone(), cfg), + ); + info!(source = "telegram", "mounted at /telegram"); + } + + if let Some(ref cfg) = config.gitlab { + app = app.nest( + "/gitlab", + trogon_source_gitlab::router(publisher.clone(), cfg), + ); + info!(source = "gitlab", "mounted at /gitlab"); + } + + if let Some(ref cfg) = config.linear { + app = app.nest( + "/linear", + trogon_source_linear::router(publisher.clone(), cfg), + ); + info!(source = "linear", "mounted at /linear"); + } + + app +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load; + use std::io::Write; + use trogon_nats::MockNatsClient; + use trogon_nats::jetstream::{ + ClaimCheckPublisher, MaxPayload, MockJetStreamPublisher, MockObjectStore, + }; + + const VALID_ED25519_PUB_KEY: &str = + "236a4d1cb6b5d3b6e25664d96be99807095ea11930159bb832e53b87761648c3"; + + fn wrap_publisher( + publisher: MockJetStreamPublisher, + ) -> ClaimCheckPublisher { + ClaimCheckPublisher::new( + publisher, + MockObjectStore::new(), + "test-bucket".to_string(), + MaxPayload::from_server_limit(usize::MAX), + ) + } + + fn write_toml(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::Builder::new() + .suffix(".toml") + .tempfile() + .expect("failed to create temp file"); + f.write_all(content.as_bytes()) + .expect("failed to write toml"); + f.flush().expect("failed to flush"); + f + } + + fn all_sources_toml() -> String { + format!( + r#" +[sources.github] +webhook_secret = "gh-secret" + +[sources.discord] +mode = "webhook" +public_key = "{VALID_ED25519_PUB_KEY}" + +[sources.slack] +signing_secret = "slack-secret" + +[sources.telegram] +webhook_secret = "tg-secret" + +[sources.gitlab] +webhook_secret = "gl-secret" + +[sources.linear] +webhook_secret = "linear-secret" +"# + ) + } + + #[test] + fn mount_sources_with_no_sources_builds_router() { + let cfg = load(None).expect("load failed"); + let _app = mount_sources( + cfg, + wrap_publisher(MockJetStreamPublisher::new()), + MockNatsClient::new(), + ); + } + + #[test] + fn mount_sources_with_all_sources_builds_router() { + let f = write_toml(&all_sources_toml()); + let cfg = load(Some(f.path())).expect("load failed"); + let _app = mount_sources( + cfg, + wrap_publisher(MockJetStreamPublisher::new()), + MockNatsClient::new(), + ); + } +} diff --git a/rsworkspace/crates/trogon-gateway/src/main.rs b/rsworkspace/crates/trogon-gateway/src/main.rs new file mode 100644 index 000000000..496d2cb11 --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/src/main.rs @@ -0,0 +1,166 @@ +#[cfg(not(coverage))] +mod cli; +#[cfg_attr(coverage, allow(dead_code))] +mod config; +#[cfg_attr(coverage, allow(dead_code))] +mod http; +#[cfg_attr(coverage, allow(dead_code))] +mod streams; + +#[cfg(not(coverage))] +use std::net::SocketAddr; +#[cfg(not(coverage))] +use std::time::Duration; + +#[cfg(not(coverage))] +use tokio::task::JoinSet; +#[cfg(not(coverage))] +use tracing::{error, info}; +#[cfg(not(coverage))] +use trogon_nats::connect; +#[cfg(not(coverage))] +use trogon_nats::jetstream::{ + ClaimCheckPublisher, MaxPayload, NatsJetStreamClient, NatsObjectStore, +}; +#[cfg(not(coverage))] +use trogon_std::args::{CliArgs, ParseArgs}; +#[cfg(not(coverage))] +use trogon_std::env::SystemEnv; +#[cfg(not(coverage))] +use trogon_std::fs::SystemFs; + +#[cfg(not(coverage))] +const NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +#[cfg(not(coverage))] +const CLAIM_CHECK_BUCKET: &str = "trogon-claims"; + +#[cfg(not(coverage))] +type SourceResult = (&'static str, Result<(), String>); + +#[cfg(not(coverage))] +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = CliArgs::::new().parse_args(); + + let config_path = match cli.command { + cli::Command::Serve { ref config } => config.as_deref(), + }; + + let resolved = config::load(config_path)?; + + if !resolved.has_any_source() { + return Err("no sources configured — provide a config file or set source env vars".into()); + } + + acp_telemetry::init_logger( + acp_telemetry::ServiceName::TrogonGateway, + "gateway", + &SystemEnv, + &SystemFs, + ); + + info!("trogon-gateway starting"); + + let nats = connect(&resolved.nats, NATS_CONNECT_TIMEOUT).await?; + let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); + let js_context = async_nats::jetstream::new(nats.clone()); + let object_store = NatsObjectStore::provision( + &js_context, + async_nats::jetstream::object_store::Config { + bucket: CLAIM_CHECK_BUCKET.to_string(), + max_age: Duration::from_secs(8 * 24 * 60 * 60), + ..Default::default() + }, + ) + .await?; + let client = NatsJetStreamClient::new(js_context); + + streams::provision(&client, &resolved).await?; + + let port = resolved.http_server.port; + let mut join_set: JoinSet = JoinSet::new(); + + let publisher = ClaimCheckPublisher::new( + client.clone(), + object_store.clone(), + CLAIM_CHECK_BUCKET.to_string(), + max_payload, + ); + + { + if let Some(ref cfg) = resolved.discord + && let trogon_source_discord::config::SourceMode::Gateway { + ref bot_token, + intents, + } = cfg.mode + { + let p = publisher.clone(); + let discord_cfg = cfg.clone(); + let token = bot_token.clone(); + join_set.spawn(async move { + trogon_source_discord::gateway_runner::run( + p, + &discord_cfg, + token.as_str(), + intents, + ) + .await; + ("discord-gateway", Ok(())) + }); + info!(source = "discord", "gateway mode spawned"); + } + } + + let app = http::mount_sources(resolved, publisher, nats); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = tokio::net::TcpListener::bind(addr).await?; + info!(addr = %addr, "listening"); + + join_set.spawn(async move { + let result = axum::serve(listener, app) + .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) + .await; + ("http", result.map_err(|e| format!("http server: {e}"))) + }); + + let task_count = join_set.len(); + info!(count = task_count, "tasks spawned"); + + let mut failed: usize = 0; + while let Some(result) = join_set.join_next().await { + match result { + Ok((name, Ok(()))) => info!(source = name, "task stopped"), + Ok((name, Err(e))) => { + error!(source = name, error = %e, "task failed"); + failed += 1; + } + Err(e) => { + error!(error = %e, "task panicked"); + failed += 1; + } + } + } + + info!("all tasks stopped, shutting down"); + if let Err(e) = acp_telemetry::shutdown_otel() { + error!(error = %e, "OpenTelemetry shutdown failed"); + } + + if failed == task_count { + return Err(format!("all {task_count} task(s) failed").into()); + } + + Ok(()) +} + +#[cfg(coverage)] +fn main() {} + +#[cfg(all(coverage, test))] +mod tests { + #[test] + fn coverage_stub() { + super::main(); + } +} diff --git a/rsworkspace/crates/trogon-gateway/src/streams.rs b/rsworkspace/crates/trogon-gateway/src/streams.rs new file mode 100644 index 000000000..73bb7a5eb --- /dev/null +++ b/rsworkspace/crates/trogon-gateway/src/streams.rs @@ -0,0 +1,107 @@ +use tracing::info; +use trogon_nats::jetstream::JetStreamContext; + +use crate::config::ResolvedConfig; + +pub(crate) async fn provision( + client: &C, + config: &ResolvedConfig, +) -> Result<(), C::Error> { + if let Some(ref cfg) = config.github { + trogon_source_github::provision(client, cfg).await?; + info!(source = "github", "stream provisioned"); + } + if let Some(ref cfg) = config.discord { + trogon_source_discord::provision(client, cfg).await?; + info!(source = "discord", "stream provisioned"); + } + if let Some(ref cfg) = config.slack { + trogon_source_slack::provision(client, cfg).await?; + info!(source = "slack", "stream provisioned"); + } + if let Some(ref cfg) = config.telegram { + trogon_source_telegram::provision(client, cfg).await?; + info!(source = "telegram", "stream provisioned"); + } + if let Some(ref cfg) = config.gitlab { + trogon_source_gitlab::provision(client, cfg).await?; + info!(source = "gitlab", "stream provisioned"); + } + if let Some(ref cfg) = config.linear { + trogon_source_linear::provision(client, cfg).await?; + info!(source = "linear", "stream provisioned"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load; + use std::io::Write; + use trogon_nats::jetstream::MockJetStreamContext; + + const VALID_ED25519_PUB_KEY: &str = + "236a4d1cb6b5d3b6e25664d96be99807095ea11930159bb832e53b87761648c3"; + + fn write_toml(content: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::Builder::new() + .suffix(".toml") + .tempfile() + .expect("failed to create temp file"); + f.write_all(content.as_bytes()) + .expect("failed to write toml"); + f.flush().expect("failed to flush"); + f + } + + fn all_sources_toml() -> String { + format!( + r#" +[sources.github] +webhook_secret = "gh-secret" + +[sources.discord] +mode = "webhook" +public_key = "{VALID_ED25519_PUB_KEY}" + +[sources.slack] +signing_secret = "slack-secret" + +[sources.telegram] +webhook_secret = "tg-secret" + +[sources.gitlab] +webhook_secret = "gl-secret" + +[sources.linear] +webhook_secret = "linear-secret" +"# + ) + } + + #[tokio::test] + async fn provision_no_sources_is_noop() { + let cfg = load(None).expect("load failed"); + let js = MockJetStreamContext::new(); + + provision(&js, &cfg) + .await + .expect("provision should succeed"); + + assert!(js.created_streams().is_empty()); + } + + #[tokio::test] + async fn provision_all_sources_creates_all_streams() { + let f = write_toml(&all_sources_toml()); + let cfg = load(Some(f.path())).expect("load failed"); + let js = MockJetStreamContext::new(); + + provision(&js, &cfg) + .await + .expect("provision should succeed"); + + assert_eq!(js.created_streams().len(), 6); + } +} diff --git a/rsworkspace/crates/trogon-nats/src/jetstream/claim_check.rs b/rsworkspace/crates/trogon-nats/src/jetstream/claim_check.rs index 37008d17f..a9332408f 100644 --- a/rsworkspace/crates/trogon-nats/src/jetstream/claim_check.rs +++ b/rsworkspace/crates/trogon-nats/src/jetstream/claim_check.rs @@ -82,9 +82,14 @@ impl fmt::Display for ClaimResolveError { } } -impl std::error::Error - for ClaimResolveError -{ +impl std::error::Error for ClaimResolveError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::MissingKey => None, + Self::StoreFailed(e) => Some(e), + Self::ReadFailed(e) => Some(e), + } + } } #[derive(Clone, Debug)] @@ -264,6 +269,29 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("pipe broke")); } + + #[test] + fn claim_resolve_error_source_missing_key() { + use std::error::Error; + let err: ClaimResolveError = ClaimResolveError::MissingKey; + assert!(err.source().is_none()); + } + + #[test] + fn claim_resolve_error_source_store_failed() { + use std::error::Error; + let inner = std::io::Error::other("boom"); + let err: ClaimResolveError = ClaimResolveError::StoreFailed(inner); + assert!(err.source().is_some()); + } + + #[test] + fn claim_resolve_error_source_read_failed() { + use std::error::Error; + let inner = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broke"); + let err: ClaimResolveError = ClaimResolveError::ReadFailed(inner); + assert!(err.source().is_some()); + } } #[cfg(all(test, feature = "test-support"))] diff --git a/rsworkspace/crates/trogon-nats/src/jetstream/mod.rs b/rsworkspace/crates/trogon-nats/src/jetstream/mod.rs index 61270fd4f..05c8232c3 100644 --- a/rsworkspace/crates/trogon-nats/src/jetstream/mod.rs +++ b/rsworkspace/crates/trogon-nats/src/jetstream/mod.rs @@ -4,6 +4,7 @@ pub mod client; pub mod message; pub mod object_store; pub mod publish; +pub mod stream_max_age; pub mod traits; #[cfg(feature = "test-support")] @@ -26,6 +27,7 @@ pub use message::{ pub use object_store::NatsObjectStore; pub use object_store::{ObjectStoreGet, ObjectStorePut}; pub use publish::{PublishOutcome, publish_event}; +pub use stream_max_age::StreamMaxAge; pub use traits::{ JetStreamConsumer, JetStreamContext, JetStreamCreateConsumer, JetStreamGetStream, JetStreamPublisher, JsMessageOf, diff --git a/rsworkspace/crates/trogon-nats/src/jetstream/stream_max_age.rs b/rsworkspace/crates/trogon-nats/src/jetstream/stream_max_age.rs new file mode 100644 index 000000000..dd44f6bb5 --- /dev/null +++ b/rsworkspace/crates/trogon-nats/src/jetstream/stream_max_age.rs @@ -0,0 +1,45 @@ +use std::time::Duration; + +use trogon_std::{NonZeroDuration, ZeroDuration}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamMaxAge { + NoExpiry, + ExpireAfter(NonZeroDuration), +} + +impl StreamMaxAge { + pub fn from_secs(secs: u64) -> Result { + Ok(Self::ExpireAfter(NonZeroDuration::from_secs(secs)?)) + } +} + +impl From for Duration { + fn from(age: StreamMaxAge) -> Self { + match age { + StreamMaxAge::NoExpiry => Duration::ZERO, + StreamMaxAge::ExpireAfter(d) => d.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_expiry_is_zero() { + assert_eq!(Duration::from(StreamMaxAge::NoExpiry), Duration::ZERO); + } + + #[test] + fn expire_after() { + let age = StreamMaxAge::from_secs(3600).unwrap(); + assert_eq!(Duration::from(age), Duration::from_secs(3600)); + } + + #[test] + fn zero_rejected() { + assert!(matches!(StreamMaxAge::from_secs(0), Err(ZeroDuration))); + } +} diff --git a/rsworkspace/crates/trogon-source-discord/Cargo.toml b/rsworkspace/crates/trogon-source-discord/Cargo.toml index 9fb1906db..63f35d851 100644 --- a/rsworkspace/crates/trogon-source-discord/Cargo.toml +++ b/rsworkspace/crates/trogon-source-discord/Cargo.toml @@ -6,10 +6,6 @@ edition = "2024" [lints] workspace = true -[[bin]] -name = "trogon-source-discord" -path = "src/main.rs" - [dependencies] acp-telemetry = { workspace = true } async-nats = { workspace = true, features = ["jetstream"] } diff --git a/rsworkspace/crates/trogon-source-discord/src/config.rs b/rsworkspace/crates/trogon-source-discord/src/config.rs index 9d4e4cf6f..ef5a6f68e 100644 --- a/rsworkspace/crates/trogon-source-discord/src/config.rs +++ b/rsworkspace/crates/trogon-source-discord/src/config.rs @@ -1,110 +1,49 @@ -use std::time::Duration; +use std::fmt; use ed25519_dalek::VerifyingKey; -use trogon_nats::{NatsConfig, NatsToken}; -use trogon_std::env::ReadEnv; -use twilight_model::gateway::Intents; +use trogon_nats::NatsToken; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_std::{EmptySecret, NonZeroDuration, SecretString}; -use std::fmt; +#[derive(Clone)] +pub struct DiscordBotToken(SecretString); + +impl DiscordBotToken { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} -use crate::constants::{ - DEFAULT_NATS_ACK_TIMEOUT, DEFAULT_NATS_REQUEST_TIMEOUT, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE, - DEFAULT_STREAM_NAME, DEFAULT_SUBJECT_PREFIX, -}; -use crate::signature; +impl fmt::Debug for DiscordBotToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DiscordBotToken(****)") + } +} +use twilight_model::gateway::Intents; +#[derive(Clone)] pub enum SourceMode { - Gateway { bot_token: String, intents: Intents }, - Webhook { public_key: VerifyingKey }, + Gateway { + bot_token: DiscordBotToken, + intents: Intents, + }, + Webhook { + public_key: VerifyingKey, + }, } +#[derive(Clone)] pub struct DiscordConfig { pub mode: SourceMode, - pub port: u16, pub subject_prefix: NatsToken, pub stream_name: NatsToken, - pub stream_max_age: Duration, - pub nats_ack_timeout: Duration, - pub nats_request_timeout: Duration, - pub nats: NatsConfig, -} - -impl DiscordConfig { - pub fn from_env(env: &E) -> Self { - let mode_str = env - .var("DISCORD_MODE") - .expect("DISCORD_MODE is required (gateway or webhook)"); - - let mode = match mode_str.to_ascii_lowercase().as_str() { - "gateway" => { - let bot_token = env - .var("DISCORD_BOT_TOKEN") - .ok() - .filter(|s| !s.is_empty()) - .expect("DISCORD_BOT_TOKEN is required when DISCORD_MODE=gateway"); - - let intents = env - .var("DISCORD_GATEWAY_INTENTS") - .ok() - .filter(|s| !s.is_empty()) - .map(|s| parse_gateway_intents(&s).expect("DISCORD_GATEWAY_INTENTS is invalid")) - .unwrap_or_else(default_intents); - - SourceMode::Gateway { bot_token, intents } - } - "webhook" => { - let public_key_hex = env - .var("DISCORD_PUBLIC_KEY") - .ok() - .filter(|s| !s.is_empty()) - .expect("DISCORD_PUBLIC_KEY is required when DISCORD_MODE=webhook"); - - let public_key = signature::parse_public_key(&public_key_hex) - .expect("DISCORD_PUBLIC_KEY must be a valid hex-encoded Ed25519 public key"); - - SourceMode::Webhook { public_key } - } - other => panic!("DISCORD_MODE must be 'gateway' or 'webhook', got '{other}'"), - }; - - Self { - mode, - port: env - .var("DISCORD_WEBHOOK_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT), - subject_prefix: NatsToken::new( - env.var("DISCORD_SUBJECT_PREFIX") - .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), - ) - .expect("DISCORD_SUBJECT_PREFIX is not a valid NATS token"), - stream_name: NatsToken::new( - env.var("DISCORD_STREAM_NAME") - .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), - ) - .expect("DISCORD_STREAM_NAME is not a valid NATS token"), - stream_max_age: env - .var("DISCORD_STREAM_MAX_AGE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_STREAM_MAX_AGE), - nats_ack_timeout: env - .var("DISCORD_NATS_ACK_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_NATS_ACK_TIMEOUT), - nats_request_timeout: env - .var("DISCORD_NATS_REQUEST_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_NATS_REQUEST_TIMEOUT), - nats: NatsConfig::from_env(env), - } - } + pub stream_max_age: StreamMaxAge, + pub nats_ack_timeout: NonZeroDuration, + pub nats_request_timeout: NonZeroDuration, } const PRIVILEGED_INTENTS: Intents = Intents::from_bits_truncate( @@ -113,7 +52,7 @@ const PRIVILEGED_INTENTS: Intents = Intents::from_bits_truncate( | Intents::MESSAGE_CONTENT.bits(), ); -fn default_intents() -> Intents { +pub fn default_intents() -> Intents { Intents::all().difference(PRIVILEGED_INTENTS) } @@ -130,7 +69,7 @@ impl fmt::Display for UnknownIntentError { impl std::error::Error for UnknownIntentError {} -fn parse_gateway_intents(s: &str) -> Result { +pub fn parse_gateway_intents(s: &str) -> Result { let mut intents = Intents::empty(); for part in s.split(',') { let part = part.trim(); @@ -171,156 +110,6 @@ fn parse_gateway_intents(s: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use ed25519_dalek::SigningKey; - use trogon_std::env::InMemoryEnv; - - fn valid_public_key_hex() -> String { - let sk = SigningKey::from_bytes(&[1u8; 32]); - hex::encode(sk.verifying_key().as_bytes()) - } - - fn env_webhook() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "webhook"); - env.set("DISCORD_PUBLIC_KEY", valid_public_key_hex()); - env - } - - fn env_gateway() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "gateway"); - env.set("DISCORD_BOT_TOKEN", "test-token"); - env - } - - #[test] - fn webhook_mode_defaults() { - let env = env_webhook(); - let config = DiscordConfig::from_env(&env); - - assert!(matches!(config.mode, SourceMode::Webhook { .. })); - assert_eq!(config.port, 8080); - assert_eq!(config.subject_prefix.as_str(), "discord"); - assert_eq!(config.stream_name.as_str(), "DISCORD"); - assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(10)); - assert_eq!(config.nats_request_timeout, Duration::from_secs(2)); - } - - #[test] - fn gateway_mode_defaults() { - let env = env_gateway(); - let config = DiscordConfig::from_env(&env); - - match &config.mode { - SourceMode::Gateway { bot_token, intents } => { - assert_eq!(bot_token, "test-token"); - assert_eq!(*intents, default_intents()); - assert!(intents.contains(Intents::GUILDS)); - assert!(intents.contains(Intents::GUILD_MESSAGES)); - assert!(!intents.contains(Intents::MESSAGE_CONTENT)); - assert!(!intents.contains(Intents::GUILD_MEMBERS)); - assert!(!intents.contains(Intents::GUILD_PRESENCES)); - } - SourceMode::Webhook { .. } => panic!("expected gateway mode"), - } - } - - #[test] - #[should_panic(expected = "DISCORD_MODE is required")] - fn missing_mode_panics() { - let env = InMemoryEnv::new(); - DiscordConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "DISCORD_MODE must be 'gateway' or 'webhook'")] - fn invalid_mode_panics() { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "bogus"); - DiscordConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "DISCORD_BOT_TOKEN is required when DISCORD_MODE=gateway")] - fn gateway_missing_token_panics() { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "gateway"); - DiscordConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "DISCORD_PUBLIC_KEY is required when DISCORD_MODE=webhook")] - fn webhook_missing_key_panics() { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "webhook"); - DiscordConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "DISCORD_PUBLIC_KEY must be a valid")] - fn webhook_invalid_key_panics() { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "webhook"); - env.set("DISCORD_PUBLIC_KEY", "deadbeef"); - DiscordConfig::from_env(&env); - } - - #[test] - fn reads_all_env_vars() { - let env = env_webhook(); - env.set("DISCORD_WEBHOOK_PORT", "9090"); - env.set("DISCORD_SUBJECT_PREFIX", "dc"); - env.set("DISCORD_STREAM_NAME", "DC_EVENTS"); - env.set("DISCORD_STREAM_MAX_AGE_SECS", "3600"); - env.set("DISCORD_NATS_ACK_TIMEOUT_SECS", "30"); - env.set("DISCORD_NATS_REQUEST_TIMEOUT_SECS", "5"); - - let config = DiscordConfig::from_env(&env); - - assert_eq!(config.port, 9090); - assert_eq!(config.subject_prefix.as_str(), "dc"); - assert_eq!(config.stream_name.as_str(), "DC_EVENTS"); - assert_eq!(config.stream_max_age, Duration::from_secs(3600)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(30)); - assert_eq!(config.nats_request_timeout, Duration::from_secs(5)); - } - - #[test] - fn invalid_port_falls_back_to_default() { - let env = env_webhook(); - env.set("DISCORD_WEBHOOK_PORT", "not-a-number"); - - let config = DiscordConfig::from_env(&env); - assert_eq!(config.port, 8080); - } - - #[test] - fn invalid_max_age_falls_back_to_default() { - let env = env_webhook(); - env.set("DISCORD_STREAM_MAX_AGE_SECS", "not-a-number"); - - let config = DiscordConfig::from_env(&env); - assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE); - } - - #[test] - fn invalid_nats_ack_timeout_falls_back_to_default() { - let env = env_webhook(); - env.set("DISCORD_NATS_ACK_TIMEOUT_SECS", "not-a-number"); - - let config = DiscordConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, DEFAULT_NATS_ACK_TIMEOUT); - } - - #[test] - fn invalid_nats_request_timeout_falls_back_to_default() { - let env = env_webhook(); - env.set("DISCORD_NATS_REQUEST_TIMEOUT_SECS", "not-a-number"); - - let config = DiscordConfig::from_env(&env); - assert_eq!(config.nats_request_timeout, DEFAULT_NATS_REQUEST_TIMEOUT); - } #[test] fn parse_intents_csv() { @@ -351,44 +140,6 @@ mod tests { assert!(std::error::Error::source(&err).is_none()); } - #[test] - #[should_panic(expected = "DISCORD_GATEWAY_INTENTS is invalid")] - fn gateway_invalid_intents_panics() { - let env = env_gateway(); - env.set("DISCORD_GATEWAY_INTENTS", "guilds,not_real"); - DiscordConfig::from_env(&env); - } - - #[test] - fn gateway_intents_from_env() { - let env = env_gateway(); - env.set("DISCORD_GATEWAY_INTENTS", "guilds,guild_presences"); - let config = DiscordConfig::from_env(&env); - match &config.mode { - SourceMode::Gateway { intents, .. } => { - assert!(intents.contains(Intents::GUILDS)); - assert!(intents.contains(Intents::GUILD_PRESENCES)); - assert!(!intents.contains(Intents::MESSAGE_CONTENT)); - } - _ => panic!("expected gateway mode"), - } - } - - #[test] - fn mode_is_case_insensitive() { - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "Gateway"); - env.set("DISCORD_BOT_TOKEN", "tok"); - let config = DiscordConfig::from_env(&env); - assert!(matches!(config.mode, SourceMode::Gateway { .. })); - - let env = InMemoryEnv::new(); - env.set("DISCORD_MODE", "WEBHOOK"); - env.set("DISCORD_PUBLIC_KEY", valid_public_key_hex()); - let config = DiscordConfig::from_env(&env); - assert!(matches!(config.mode, SourceMode::Webhook { .. })); - } - #[test] fn default_intents_excludes_privileged() { let intents = default_intents(); @@ -408,34 +159,14 @@ mod tests { } #[test] - #[should_panic(expected = "DISCORD_SUBJECT_PREFIX is not a valid NATS token")] - fn invalid_subject_prefix_panics() { - let env = env_webhook(); - env.set("DISCORD_SUBJECT_PREFIX", "has spaces"); - DiscordConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "DISCORD_STREAM_NAME is not a valid NATS token")] - fn invalid_stream_name_panics() { - let env = env_webhook(); - env.set("DISCORD_STREAM_NAME", "has.dots"); - DiscordConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "DISCORD_SUBJECT_PREFIX is not a valid NATS token")] - fn empty_subject_prefix_panics() { - let env = env_webhook(); - env.set("DISCORD_SUBJECT_PREFIX", ""); - DiscordConfig::from_env(&env); + fn discord_bot_token_roundtrips() { + let token = DiscordBotToken::new("Bot token").unwrap(); + assert_eq!(token.as_str(), "Bot token"); } #[test] - #[should_panic(expected = "DISCORD_STREAM_NAME is not a valid NATS token")] - fn wildcard_stream_name_panics() { - let env = env_webhook(); - env.set("DISCORD_STREAM_NAME", "DISCORD>"); - DiscordConfig::from_env(&env); + fn discord_bot_token_debug_redacts() { + let token = DiscordBotToken::new("Bot token").unwrap(); + assert_eq!(format!("{token:?}"), "DiscordBotToken(****)"); } } diff --git a/rsworkspace/crates/trogon-source-discord/src/constants.rs b/rsworkspace/crates/trogon-source-discord/src/constants.rs index 9ce873485..ef480bc9d 100644 --- a/rsworkspace/crates/trogon-source-discord/src/constants.rs +++ b/rsworkspace/crates/trogon-source-discord/src/constants.rs @@ -1,15 +1,5 @@ -use std::time::Duration; - use trogon_std::{ByteSize, HttpBodySizeMax}; -pub const DEFAULT_PORT: u16 = 8080; -pub const DEFAULT_SUBJECT_PREFIX: &str = "discord"; -pub const DEFAULT_STREAM_NAME: &str = "DISCORD"; -pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); -pub const DEFAULT_NATS_ACK_TIMEOUT: Duration = Duration::from_secs(10); -pub const DEFAULT_NATS_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); -pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - pub const HTTP_BODY_SIZE_MAX: HttpBodySizeMax = HttpBodySizeMax::new(ByteSize::mib(4)).unwrap(); pub const HEADER_SIGNATURE: &str = "x-signature-ed25519"; diff --git a/rsworkspace/crates/trogon-source-discord/src/gateway_runner.rs b/rsworkspace/crates/trogon-source-discord/src/gateway_runner.rs new file mode 100644 index 000000000..17e580b2e --- /dev/null +++ b/rsworkspace/crates/trogon-source-discord/src/gateway_runner.rs @@ -0,0 +1,46 @@ +use std::future::poll_fn; +use std::pin::Pin; + +use futures_core::Stream; +use tracing::{info, warn}; +use twilight_gateway::{Message, Shard, ShardId}; + +use crate::gateway::GatewayBridge; + +pub async fn run< + P: trogon_nats::jetstream::JetStreamPublisher, + S: trogon_nats::jetstream::ObjectStorePut, +>( + publisher: trogon_nats::jetstream::ClaimCheckPublisher, + config: &crate::config::DiscordConfig, + bot_token: &str, + intents: twilight_model::gateway::Intents, +) { + info!("mode: gateway"); + + let bridge = GatewayBridge::new( + publisher, + config.subject_prefix.clone(), + config.nats_ack_timeout.into(), + ); + + let mut shard = Shard::new(ShardId::ONE, bot_token.to_owned(), intents); + + info!("starting Discord gateway connection"); + + loop { + let msg = poll_fn(|cx| Stream::poll_next(Pin::new(&mut shard), cx)).await; + match msg { + Some(Ok(Message::Text(text))) => bridge.dispatch(&text).await, + Some(Ok(Message::Close(_))) => { + info!("gateway connection closed"); + break; + } + Some(Err(source)) => { + warn!(?source, "error receiving gateway message"); + continue; + } + None => break, + } + } +} diff --git a/rsworkspace/crates/trogon-source-discord/src/lib.rs b/rsworkspace/crates/trogon-source-discord/src/lib.rs index 26b1b8b10..a3f5d9626 100644 --- a/rsworkspace/crates/trogon-source-discord/src/lib.rs +++ b/rsworkspace/crates/trogon-source-discord/src/lib.rs @@ -27,11 +27,11 @@ pub mod config; pub mod constants; pub mod gateway; +#[cfg(not(coverage))] +pub mod gateway_runner; pub mod server; pub mod signature; pub use config::{DiscordConfig, SourceMode}; -#[cfg(not(coverage))] -pub use server::{ServeError, serve}; pub use server::{provision, router}; pub use signature::SignatureError; diff --git a/rsworkspace/crates/trogon-source-discord/src/main.rs b/rsworkspace/crates/trogon-source-discord/src/main.rs deleted file mode 100644 index e9e17f7e8..000000000 --- a/rsworkspace/crates/trogon-source-discord/src/main.rs +++ /dev/null @@ -1,157 +0,0 @@ -#[cfg(not(coverage))] -use { - acp_telemetry::ServiceName, - futures_core::Stream, - std::future::poll_fn, - std::pin::Pin, - tracing::info, - tracing::warn, - trogon_nats::connect, - trogon_nats::jetstream::ClaimCheckPublisher, - trogon_nats::jetstream::MaxPayload, - trogon_nats::jetstream::NatsJetStreamClient, - trogon_nats::jetstream::NatsObjectStore, - trogon_source_discord::DiscordConfig, - trogon_source_discord::config::SourceMode, - trogon_source_discord::constants::DEFAULT_NATS_CONNECT_TIMEOUT, - trogon_source_discord::gateway::GatewayBridge, - trogon_std::env::SystemEnv, - trogon_std::fs::SystemFs, - twilight_gateway::{Message, Shard, ShardId}, -}; - -#[cfg(not(coverage))] -async fn run_gateway( - publisher: ClaimCheckPublisher, - config: &DiscordConfig, - bot_token: &str, - intents: twilight_model::gateway::Intents, -) { - info!("mode: gateway"); - - let bridge = GatewayBridge::new( - publisher, - config.subject_prefix.clone(), - config.nats_ack_timeout, - ); - - let health_addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - let health_app = axum::Router::new().route( - "/health", - axum::routing::get(|| async { axum::http::StatusCode::OK }), - ); - tokio::spawn(async move { - let listener = tokio::net::TcpListener::bind(health_addr) - .await - .expect("failed to bind health port"); - info!(addr = %health_addr, "health endpoint listening"); - let _ = axum::serve(listener, health_app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await; - }); - - let mut shard = Shard::new(ShardId::ONE, bot_token.to_owned(), intents); - - info!("starting Discord gateway connection"); - - loop { - let msg = poll_fn(|cx| Pin::new(&mut shard).poll_next(cx)).await; - match msg { - Some(Ok(Message::Text(text))) => bridge.dispatch(&text).await, - Some(Ok(Message::Close(_))) => { - info!("gateway connection closed"); - break; - } - Some(Err(source)) => { - warn!(?source, "error receiving gateway message"); - continue; - } - None => break, - } - } -} - -#[cfg(not(coverage))] -async fn run_webhook( - publisher: ClaimCheckPublisher, - nats: async_nats::Client, - public_key: ed25519_dalek::VerifyingKey, - config: &DiscordConfig, -) -> Result<(), Box> { - info!("mode: webhook"); - - let app = trogon_source_discord::router(publisher, nats, public_key, config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "starting HTTP interactions endpoint"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - Ok(()) -} - -#[cfg(not(coverage))] -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = DiscordConfig::from_env(&SystemEnv); - - acp_telemetry::init_logger( - ServiceName::TrogonSourceDiscord, - &config.subject_prefix, - &SystemEnv, - &SystemFs, - ); - - info!("Discord source starting"); - - let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; - let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); - let js_context = async_nats::jetstream::new(nats.clone()); - let object_store = NatsObjectStore::provision( - &js_context, - async_nats::jetstream::object_store::Config { - bucket: "trogon-claims".to_string(), - ..Default::default() - }, - ) - .await?; - let client = NatsJetStreamClient::new(js_context); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store, - "trogon-claims".to_string(), - max_payload, - ); - - trogon_source_discord::provision(&client, &config) - .await - .map_err(|e| format!("stream provisioning failed: {e}"))?; - - match config.mode { - SourceMode::Gateway { - ref bot_token, - intents, - } => run_gateway(publisher, &config, bot_token, intents).await, - SourceMode::Webhook { public_key } => { - run_webhook(publisher, nats, public_key, &config).await? - } - } - - info!("Discord source stopped"); - acp_telemetry::shutdown_otel(); - - Ok(()) -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(coverage, test))] -mod tests { - #[test] - fn coverage_stub() { - super::main(); - } -} diff --git a/rsworkspace/crates/trogon-source-discord/src/server.rs b/rsworkspace/crates/trogon-source-discord/src/server.rs index 42e87dac4..1c947b901 100644 --- a/rsworkspace/crates/trogon-source-discord/src/server.rs +++ b/rsworkspace/crates/trogon-source-discord/src/server.rs @@ -7,11 +7,9 @@ use crate::constants::{ NATS_HEADER_INTERACTION_TYPE, NATS_HEADER_PAYLOAD_KIND, NATS_HEADER_REJECT_REASON, }; use crate::signature; -#[cfg(not(coverage))] -use async_nats::jetstream::context::CreateStreamError; use axum::{ Json, Router, body::Bytes, extract::DefaultBodyLimit, extract::State, http::HeaderMap, - http::StatusCode, response::IntoResponse, response::Response, routing::get, routing::post, + http::StatusCode, response::IntoResponse, response::Response, routing::post, }; use ed25519_dalek::VerifyingKey; use std::future::Future; @@ -21,41 +19,7 @@ use trogon_nats::jetstream::{ ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut, PublishOutcome, }; use trogon_nats::{NatsToken, RequestClient}; - -#[cfg(not(coverage))] -#[derive(Debug)] -#[non_exhaustive] -pub enum ServeError { - Provision(CreateStreamError), - Io(std::io::Error), -} - -#[cfg(not(coverage))] -impl fmt::Display for ServeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServeError::Provision(e) => write!(f, "stream provisioning failed: {e}"), - ServeError::Io(e) => write!(f, "server IO error: {e}"), - } - } -} - -#[cfg(not(coverage))] -impl std::error::Error for ServeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServeError::Provision(e) => Some(e), - ServeError::Io(e) => Some(e), - } - } -} - -#[cfg(not(coverage))] -impl From for ServeError { - fn from(e: std::io::Error) -> Self { - ServeError::Io(e) - } -} +use trogon_std::NonZeroDuration; fn outcome_to_status(outcome: PublishOutcome) -> StatusCode { if outcome.is_ok() { @@ -72,7 +36,7 @@ async fn publish_unroutable( subject_prefix: &str, reason: &str, body: Bytes, - ack_timeout: Duration, + ack_timeout: NonZeroDuration, ) { let subject = format!("{subject_prefix}.unroutable"); let mut headers = async_nats::HeaderMap::new(); @@ -80,7 +44,7 @@ async fn publish_unroutable( headers.insert(NATS_HEADER_PAYLOAD_KIND, "unroutable"); let outcome = publisher - .publish_event(subject, headers, body, ack_timeout) + .publish_event(subject, headers, body, ack_timeout.into()) .await; outcome.log_on_error("discord.unroutable"); } @@ -101,8 +65,8 @@ struct AppState { nats: R, public_key: VerifyingKey, subject_prefix: NatsToken, - nats_ack_timeout: Duration, - nats_request_timeout: Duration, + nats_ack_timeout: NonZeroDuration, + nats_request_timeout: NonZeroDuration, } pub async fn provision( @@ -112,12 +76,12 @@ pub async fn provision( js.get_or_create_stream(async_nats::jetstream::stream::Config { name: config.stream_name.to_string(), subjects: vec![format!("{}.>", config.subject_prefix)], - max_age: config.stream_max_age, + max_age: config.stream_max_age.into(), ..Default::default() }) .await?; - let max_age_secs = config.stream_max_age.as_secs(); + let max_age_secs = Duration::from(config.stream_max_age).as_secs(); info!( stream = %config.stream_name, max_age_secs, "JetStream stream ready" @@ -142,46 +106,10 @@ pub fn router( Router::new() .route("/webhook", post(handle_webhook::)) - .route("/health", get(handle_health)) .layer(DefaultBodyLimit::max(HTTP_BODY_SIZE_MAX.as_usize())) .with_state(state) } -#[cfg(not(coverage))] -pub async fn serve( - context: C, - publisher: ClaimCheckPublisher, - nats: R, - public_key: VerifyingKey, - config: DiscordConfig, -) -> Result<(), ServeError> -where - C: JetStreamContext, - P: JetStreamPublisher, - S: ObjectStorePut, - R: RequestClient, -{ - provision(&context, &config) - .await - .map_err(ServeError::Provision)?; - - let app = router(publisher, nats, public_key, &config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "Discord webhook server listening"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - info!("Discord webhook server shut down"); - Ok(()) -} - -async fn handle_health() -> StatusCode { - StatusCode::OK -} - fn handle_webhook( State(state): State>, headers: HeaderMap, @@ -287,14 +215,14 @@ async fn handle_webhook_inner Response { #[cfg(test)] mod tests { use super::*; + use axum::body::Body; use axum::http::Request; use ed25519_dalek::{Signer, SigningKey}; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; use trogon_nats::AdvancedMockNatsClient; + use trogon_nats::jetstream::StreamMaxAge; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, @@ -374,13 +304,11 @@ mod tests { fn test_config(vk: VerifyingKey) -> DiscordConfig { DiscordConfig { mode: crate::config::SourceMode::Webhook { public_key: vk }, - port: 0, subject_prefix: NatsToken::new("discord").unwrap(), stream_name: NatsToken::new("DISCORD").unwrap(), - stream_max_age: Duration::from_secs(3600), - nats_ack_timeout: Duration::from_secs(10), - nats_request_timeout: Duration::from_secs(2), - nats: trogon_nats::NatsConfig::from_env(&trogon_std::env::InMemoryEnv::new()), + stream_max_age: StreamMaxAge::from_secs(3600).unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + nats_request_timeout: NonZeroDuration::from_secs(2).unwrap(), } } @@ -431,28 +359,6 @@ mod tests { String::from_utf8(body_bytes.to_vec()).unwrap() } - #[cfg(not(coverage))] - #[test] - fn serve_error_display_and_source() { - use async_nats::jetstream::context::{CreateStreamError, CreateStreamErrorKind}; - - let io_err = ServeError::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "port taken", - )); - assert_eq!(io_err.to_string(), "server IO error: port taken"); - assert!(std::error::Error::source(&io_err).is_some()); - - let prov_err = ServeError::Provision(CreateStreamError::new( - CreateStreamErrorKind::EmptyStreamName, - )); - assert!(prov_err.to_string().contains("stream provisioning failed")); - assert!(std::error::Error::source(&prov_err).is_some()); - - let io_err: ServeError = std::io::Error::other("boom").into(); - assert!(matches!(io_err, ServeError::Io(_))); - } - #[tokio::test] async fn provision_creates_stream() { let _guard = tracing_guard(); @@ -615,7 +521,7 @@ mod tests { nats.hang_next_request(); let mut config = test_config(vk); - config.nats_request_timeout = Duration::from_millis(10); + config.nats_request_timeout = NonZeroDuration::from_millis(10).unwrap(); let app = router(wrap_publisher(publisher.clone()), nats, vk, &config); let body = br#"{"type":4,"id":"auto-1","data":{"name":"cmd"}}"#; @@ -838,8 +744,8 @@ mod tests { nats: AdvancedMockNatsClient::new(), public_key: vk, subject_prefix: NatsToken::new("custom").unwrap(), - nats_ack_timeout: Duration::from_secs(10), - nats_request_timeout: Duration::from_secs(2), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + nats_request_timeout: NonZeroDuration::from_secs(2).unwrap(), }; let app = @@ -871,22 +777,6 @@ mod tests { ); } - #[tokio::test] - async fn health_endpoint_returns_200() { - let _guard = tracing_guard(); - let (_, vk) = test_keypair(); - let app = mock_app(MockJetStreamPublisher::new(), vk); - - let req = Request::builder() - .method("GET") - .uri("/health") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } - #[tokio::test] async fn missing_interaction_id_defaults_to_unknown() { let _guard = tracing_guard(); @@ -924,8 +814,8 @@ mod tests { nats: AdvancedMockNatsClient::new(), public_key: vk, subject_prefix: NatsToken::new("discord").unwrap(), - nats_ack_timeout: Duration::from_secs(10), - nats_request_timeout: Duration::from_secs(2), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + nats_request_timeout: NonZeroDuration::from_secs(2).unwrap(), }; let app = @@ -1043,8 +933,8 @@ mod tests { nats: AdvancedMockNatsClient::new(), public_key: vk, subject_prefix: NatsToken::new("discord").unwrap(), - nats_ack_timeout: Duration::from_secs(10), - nats_request_timeout: Duration::from_secs(2), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + nats_request_timeout: NonZeroDuration::from_secs(2).unwrap(), }; Router::new() @@ -1069,8 +959,8 @@ mod tests { nats: AdvancedMockNatsClient::new(), public_key: vk, subject_prefix: NatsToken::new("discord").unwrap(), - nats_ack_timeout: Duration::from_millis(10), - nats_request_timeout: Duration::from_secs(2), + nats_ack_timeout: NonZeroDuration::from_millis(10).unwrap(), + nats_request_timeout: NonZeroDuration::from_secs(2).unwrap(), }; Router::new() diff --git a/rsworkspace/crates/trogon-source-github/Cargo.toml b/rsworkspace/crates/trogon-source-github/Cargo.toml index ffeed0045..462c7e93c 100644 --- a/rsworkspace/crates/trogon-source-github/Cargo.toml +++ b/rsworkspace/crates/trogon-source-github/Cargo.toml @@ -6,10 +6,6 @@ edition = "2024" [lints] workspace = true -[[bin]] -name = "trogon-source-github" -path = "src/main.rs" - [dependencies] acp-telemetry = { workspace = true } async-nats = { workspace = true, features = ["jetstream"] } diff --git a/rsworkspace/crates/trogon-source-github/src/config.rs b/rsworkspace/crates/trogon-source-github/src/config.rs index f7e53fed6..b4f0c7733 100644 --- a/rsworkspace/crates/trogon-source-github/src/config.rs +++ b/rsworkspace/crates/trogon-source-github/src/config.rs @@ -1,155 +1,49 @@ -use std::time::Duration; +use std::fmt; -use trogon_nats::NatsConfig; -use trogon_std::env::ReadEnv; +use trogon_nats::NatsToken; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_std::{EmptySecret, NonZeroDuration, SecretString}; -use crate::constants::{ - DEFAULT_NATS_ACK_TIMEOUT, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE, DEFAULT_STREAM_NAME, - DEFAULT_SUBJECT_PREFIX, -}; +#[derive(Clone)] +pub struct GitHubWebhookSecret(SecretString); -/// Configuration for the GitHub webhook server. -/// -/// Resolved from environment variables: -/// - `GITHUB_WEBHOOK_SECRET`: HMAC-SHA256 secret configured in GitHub (**required**) -/// - `GITHUB_WEBHOOK_PORT`: HTTP listening port (default: 8080) -/// - `GITHUB_SUBJECT_PREFIX`: NATS subject prefix (default: `github`) -/// - `GITHUB_STREAM_NAME`: JetStream stream name (default: `GITHUB`) -/// - `GITHUB_STREAM_MAX_AGE_SECS`: max age of messages in the JetStream stream in seconds (default: 604800 / 7 days) -/// - `GITHUB_NATS_ACK_TIMEOUT_SECS`: NATS ack timeout in seconds (default: 10) -/// - Standard `NATS_*` variables for NATS connection (see `trogon-nats`) -pub struct GithubConfig { - pub webhook_secret: String, - pub port: u16, - pub subject_prefix: String, - pub stream_name: String, - pub stream_max_age: Duration, - pub nats_ack_timeout: Duration, - pub nats: NatsConfig, +impl GitHubWebhookSecret { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } } -impl GithubConfig { - pub fn from_env(env: &E) -> Self { - Self { - webhook_secret: env - .var("GITHUB_WEBHOOK_SECRET") - .ok() - .filter(|s| !s.is_empty()) - .expect("GITHUB_WEBHOOK_SECRET is required"), - port: env - .var("GITHUB_WEBHOOK_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT), - subject_prefix: env - .var("GITHUB_SUBJECT_PREFIX") - .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), - stream_name: env - .var("GITHUB_STREAM_NAME") - .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), - stream_max_age: env - .var("GITHUB_STREAM_MAX_AGE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_STREAM_MAX_AGE), - nats_ack_timeout: env - .var("GITHUB_NATS_ACK_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_NATS_ACK_TIMEOUT), - nats: NatsConfig::from_env(env), - } +impl fmt::Debug for GitHubWebhookSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("GitHubWebhookSecret(****)") } } +pub struct GithubConfig { + pub webhook_secret: GitHubWebhookSecret, + pub subject_prefix: NatsToken, + pub stream_name: NatsToken, + pub stream_max_age: StreamMaxAge, + pub nats_ack_timeout: NonZeroDuration, +} + #[cfg(test)] mod tests { use super::*; - use trogon_std::env::InMemoryEnv; - - fn env_with_secret() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("GITHUB_WEBHOOK_SECRET", "test-secret"); - env - } #[test] - fn defaults_with_required_secret() { - let env = env_with_secret(); - let config = GithubConfig::from_env(&env); - - assert_eq!(config.webhook_secret, "test-secret"); - assert_eq!(config.port, 8080); - assert_eq!(config.subject_prefix, "github"); - assert_eq!(config.stream_name, "GITHUB"); - assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(10)); - } - - #[test] - fn reads_all_env_vars() { - let env = InMemoryEnv::new(); - env.set("GITHUB_WEBHOOK_SECRET", "my-secret"); - env.set("GITHUB_WEBHOOK_PORT", "9090"); - env.set("GITHUB_SUBJECT_PREFIX", "gh"); - env.set("GITHUB_STREAM_NAME", "GH_EVENTS"); - env.set("GITHUB_STREAM_MAX_AGE_SECS", "3600"); - env.set("GITHUB_NATS_ACK_TIMEOUT_SECS", "30"); - - let config = GithubConfig::from_env(&env); - - assert_eq!(config.webhook_secret, "my-secret"); - assert_eq!(config.port, 9090); - assert_eq!(config.subject_prefix, "gh"); - assert_eq!(config.stream_name, "GH_EVENTS"); - assert_eq!(config.stream_max_age, Duration::from_secs(3600)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(30)); + fn github_webhook_secret_roundtrips() { + let secret = GitHubWebhookSecret::new("super-secret").unwrap(); + assert_eq!(secret.as_str(), "super-secret"); } #[test] - #[should_panic(expected = "GITHUB_WEBHOOK_SECRET is required")] - fn missing_webhook_secret_panics() { - let env = InMemoryEnv::new(); - GithubConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "GITHUB_WEBHOOK_SECRET is required")] - fn empty_webhook_secret_panics() { - let env = InMemoryEnv::new(); - env.set("GITHUB_WEBHOOK_SECRET", ""); - GithubConfig::from_env(&env); - } - - #[test] - fn invalid_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("GITHUB_WEBHOOK_PORT", "not-a-number"); - - let config = GithubConfig::from_env(&env); - - assert_eq!(config.port, 8080); - } - - #[test] - fn invalid_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("GITHUB_STREAM_MAX_AGE_SECS", "not-a-number"); - - let config = GithubConfig::from_env(&env); - - assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE); - } - - #[test] - fn invalid_nats_ack_timeout_falls_back_to_default() { - let env = env_with_secret(); - env.set("GITHUB_NATS_ACK_TIMEOUT_SECS", "not-a-number"); - - let config = GithubConfig::from_env(&env); - - assert_eq!(config.nats_ack_timeout, DEFAULT_NATS_ACK_TIMEOUT); + fn github_webhook_secret_debug_redacts() { + let secret = GitHubWebhookSecret::new("super-secret").unwrap(); + assert_eq!(format!("{secret:?}"), "GitHubWebhookSecret(****)"); } } diff --git a/rsworkspace/crates/trogon-source-github/src/constants.rs b/rsworkspace/crates/trogon-source-github/src/constants.rs index 03cb04a7f..3842580c5 100644 --- a/rsworkspace/crates/trogon-source-github/src/constants.rs +++ b/rsworkspace/crates/trogon-source-github/src/constants.rs @@ -1,14 +1,5 @@ -use std::time::Duration; - use trogon_std::{ByteSize, HttpBodySizeMax}; -pub const DEFAULT_PORT: u16 = 8080; -pub const DEFAULT_SUBJECT_PREFIX: &str = "github"; -pub const DEFAULT_STREAM_NAME: &str = "GITHUB"; -pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days -pub const DEFAULT_NATS_ACK_TIMEOUT: Duration = Duration::from_secs(10); -pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - pub const HTTP_BODY_SIZE_MAX: HttpBodySizeMax = HttpBodySizeMax::new(ByteSize::mib(25)).unwrap(); pub const HEADER_SIGNATURE: &str = "x-hub-signature-256"; diff --git a/rsworkspace/crates/trogon-source-github/src/lib.rs b/rsworkspace/crates/trogon-source-github/src/lib.rs index 1e2577908..4eafa8384 100644 --- a/rsworkspace/crates/trogon-source-github/src/lib.rs +++ b/rsworkspace/crates/trogon-source-github/src/lib.rs @@ -37,7 +37,5 @@ pub mod server; pub mod signature; pub use config::GithubConfig; -#[cfg(not(coverage))] -pub use server::{ServeError, serve}; pub use server::{provision, router}; pub use signature::SignatureError; diff --git a/rsworkspace/crates/trogon-source-github/src/main.rs b/rsworkspace/crates/trogon-source-github/src/main.rs deleted file mode 100644 index f5f22fa37..000000000 --- a/rsworkspace/crates/trogon-source-github/src/main.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(not(coverage))] -use { - acp_telemetry::ServiceName, tracing::error, tracing::info, trogon_nats::connect, - trogon_nats::jetstream::ClaimCheckPublisher, trogon_nats::jetstream::MaxPayload, - trogon_nats::jetstream::NatsJetStreamClient, trogon_nats::jetstream::NatsObjectStore, - trogon_source_github::GithubConfig, - trogon_source_github::constants::DEFAULT_NATS_CONNECT_TIMEOUT, trogon_std::env::SystemEnv, - trogon_std::fs::SystemFs, -}; - -#[cfg(not(coverage))] -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = GithubConfig::from_env(&SystemEnv); - - acp_telemetry::init_logger( - ServiceName::TrogonSourceGithub, - &config.subject_prefix, - &SystemEnv, - &SystemFs, - ); - - info!("GitHub webhook server starting"); - - let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; - let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); - let js_context = async_nats::jetstream::new(nats); - let object_store = NatsObjectStore::provision( - &js_context, - async_nats::jetstream::object_store::Config { - bucket: "trogon-claims".to_string(), - ..Default::default() - }, - ) - .await?; - let client = NatsJetStreamClient::new(js_context); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store, - "trogon-claims".to_string(), - max_payload, - ); - let result = trogon_source_github::serve(client, publisher, config).await; - - if let Err(ref e) = result { - error!(error = %e, "GitHub webhook server stopped with error"); - } else { - info!("GitHub webhook server stopped"); - } - - acp_telemetry::shutdown_otel(); - - result.map_err(|e| Box::new(e) as Box) -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(coverage, test))] -mod tests { - #[test] - fn coverage_stub() { - super::main(); - } -} diff --git a/rsworkspace/crates/trogon-source-github/src/server.rs b/rsworkspace/crates/trogon-source-github/src/server.rs index 61527fe8f..ccda08988 100644 --- a/rsworkspace/crates/trogon-source-github/src/server.rs +++ b/rsworkspace/crates/trogon-source-github/src/server.rs @@ -1,59 +1,25 @@ use std::fmt; use std::time::Duration; +use crate::config::GitHubWebhookSecret; use crate::config::GithubConfig; use crate::constants::{ HEADER_DELIVERY, HEADER_EVENT, HEADER_SIGNATURE, HTTP_BODY_SIZE_MAX, NATS_HEADER_DELIVERY, NATS_HEADER_EVENT, }; use crate::signature; -#[cfg(not(coverage))] -use async_nats::jetstream::context::CreateStreamError; use axum::{ Router, body::Bytes, extract::DefaultBodyLimit, extract::State, http::HeaderMap, - http::StatusCode, routing::get, routing::post, + http::StatusCode, routing::post, }; use std::future::Future; use std::pin::Pin; use tracing::{info, instrument, warn}; +use trogon_nats::NatsToken; use trogon_nats::jetstream::{ ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut, PublishOutcome, }; - -#[cfg(not(coverage))] -#[derive(Debug)] -#[non_exhaustive] -pub enum ServeError { - Provision(CreateStreamError), - Io(std::io::Error), -} - -#[cfg(not(coverage))] -impl fmt::Display for ServeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServeError::Provision(e) => write!(f, "stream provisioning failed: {e}"), - ServeError::Io(e) => write!(f, "server IO error: {e}"), - } - } -} - -#[cfg(not(coverage))] -impl std::error::Error for ServeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServeError::Provision(e) => Some(e), - ServeError::Io(e) => Some(e), - } - } -} - -#[cfg(not(coverage))] -impl From for ServeError { - fn from(e: std::io::Error) -> Self { - ServeError::Io(e) - } -} +use trogon_std::NonZeroDuration; fn outcome_to_status(outcome: PublishOutcome) -> StatusCode { if outcome.is_ok() { @@ -68,25 +34,23 @@ fn outcome_to_status(outcome: PublishOutcome) -> StatusCode #[derive(Clone)] struct AppState { publisher: ClaimCheckPublisher, - webhook_secret: String, - subject_prefix: String, - nats_ack_timeout: Duration, + webhook_secret: GitHubWebhookSecret, + subject_prefix: NatsToken, + nats_ack_timeout: NonZeroDuration, } pub async fn provision(js: &C, config: &GithubConfig) -> Result<(), C::Error> { js.get_or_create_stream(async_nats::jetstream::stream::Config { - name: config.stream_name.clone(), + name: config.stream_name.as_str().to_owned(), subjects: vec![format!("{}.>", config.subject_prefix)], - max_age: config.stream_max_age, + max_age: config.stream_max_age.into(), ..Default::default() }) .await?; - let max_age_secs = config.stream_max_age.as_secs(); - info!( - stream = config.stream_name, - max_age_secs, "JetStream stream ready" - ); + let stream = config.stream_name.as_str(); + let max_age_secs = Duration::from(config.stream_max_age).as_secs(); + info!(stream, max_age_secs, "JetStream stream ready"); Ok(()) } @@ -103,43 +67,10 @@ pub fn router( Router::new() .route("/webhook", post(handle_webhook::)) - .route("/health", get(handle_health)) .layer(DefaultBodyLimit::max(HTTP_BODY_SIZE_MAX.as_usize())) .with_state(state) } -#[cfg(not(coverage))] -pub async fn serve( - context: C, - publisher: ClaimCheckPublisher, - config: GithubConfig, -) -> Result<(), ServeError> -where - C: JetStreamContext, - P: JetStreamPublisher, - S: ObjectStorePut, -{ - provision(&context, &config) - .await - .map_err(ServeError::Provision)?; - - let app = router(publisher, &config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "GitHub webhook server listening"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - info!("GitHub webhook server shut down"); - Ok(()) -} - -async fn handle_health() -> StatusCode { - StatusCode::OK -} - fn handle_webhook( State(state): State>, headers: HeaderMap, @@ -170,7 +101,7 @@ async fn handle_webhook_inner( match sig { Some(sig) => { - if let Err(e) = signature::verify(&state.webhook_secret, &body, sig) { + if let Err(e) = signature::verify(state.webhook_secret.as_str(), &body, sig) { warn!(reason = %e, "GitHub webhook signature validation failed"); return StatusCode::UNAUTHORIZED; } @@ -210,7 +141,7 @@ async fn handle_webhook_inner( let outcome = state .publisher - .publish_event(subject, nats_headers, body, state.nats_ack_timeout) + .publish_event(subject, nats_headers, body, state.nats_ack_timeout.into()) .await; outcome_to_status(outcome) @@ -223,12 +154,15 @@ mod tests { use axum::http::Request; use hmac::{Hmac, Mac}; use sha2::Sha256; + use std::time::Duration; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; + use trogon_nats::jetstream::StreamMaxAge; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; + use trogon_std::NonZeroDuration; type HmacSha256 = Hmac; @@ -253,13 +187,11 @@ mod tests { fn test_config() -> GithubConfig { GithubConfig { - webhook_secret: TEST_SECRET.to_string(), - port: 0, - subject_prefix: "github".to_string(), - stream_name: "GITHUB".to_string(), - stream_max_age: Duration::from_secs(3600), - nats_ack_timeout: Duration::from_secs(10), - nats: trogon_nats::NatsConfig::from_env(&trogon_std::env::InMemoryEnv::new()), + webhook_secret: GitHubWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("github").unwrap(), + stream_name: NatsToken::new("GITHUB").unwrap(), + stream_max_age: StreamMaxAge::from_secs(3600).unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), } } @@ -290,28 +222,6 @@ mod tests { builder.body(Body::from(body.to_vec())).unwrap() } - #[cfg(not(coverage))] - #[test] - fn serve_error_display_and_source() { - use async_nats::jetstream::context::{CreateStreamError, CreateStreamErrorKind}; - - let io_err = ServeError::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "port taken", - )); - assert_eq!(io_err.to_string(), "server IO error: port taken"); - assert!(std::error::Error::source(&io_err).is_some()); - - let prov_err = ServeError::Provision(CreateStreamError::new( - CreateStreamErrorKind::EmptyStreamName, - )); - assert!(prov_err.to_string().contains("stream provisioning failed")); - assert!(std::error::Error::source(&prov_err).is_some()); - - let io_err: ServeError = std::io::Error::other("boom").into(); - assert!(matches!(io_err, ServeError::Io(_))); - } - #[tokio::test] async fn provision_creates_stream() { let _guard = tracing_guard(); @@ -470,9 +380,9 @@ mod tests { let state = AppState { publisher: wrap_publisher(publisher.clone()), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "custom".to_string(), - nats_ack_timeout: Duration::from_secs(10), + webhook_secret: GitHubWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("custom").unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -494,21 +404,6 @@ mod tests { assert_eq!(publisher.published_subjects(), vec!["custom.issues"]); } - #[tokio::test] - async fn health_endpoint_returns_200() { - let _guard = tracing_guard(); - let app = mock_app(MockJetStreamPublisher::new()); - - let req = Request::builder() - .method("GET") - .uri("/health") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } - #[tokio::test] async fn empty_body_publishes_successfully() { let _guard = tracing_guard(); @@ -641,9 +536,9 @@ mod tests { "test-bucket".to_string(), MaxPayload::from_server_limit(usize::MAX), ), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "github".to_string(), - nats_ack_timeout: Duration::from_secs(10), + webhook_secret: GitHubWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("github").unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -676,9 +571,9 @@ mod tests { "test-bucket".to_string(), MaxPayload::from_server_limit(usize::MAX), ), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "github".to_string(), - nats_ack_timeout: Duration::from_millis(10), + webhook_secret: GitHubWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("github").unwrap(), + nats_ack_timeout: NonZeroDuration::from_millis(10).unwrap(), }; let app = Router::new() diff --git a/rsworkspace/crates/trogon-source-gitlab/Cargo.toml b/rsworkspace/crates/trogon-source-gitlab/Cargo.toml index 04b44097e..ca1d9d80a 100644 --- a/rsworkspace/crates/trogon-source-gitlab/Cargo.toml +++ b/rsworkspace/crates/trogon-source-gitlab/Cargo.toml @@ -6,10 +6,6 @@ edition = "2024" [lints] workspace = true -[[bin]] -name = "trogon-source-gitlab" -path = "src/main.rs" - [dependencies] acp-telemetry = { workspace = true } async-nats = { workspace = true, features = ["jetstream"] } diff --git a/rsworkspace/crates/trogon-source-gitlab/src/config.rs b/rsworkspace/crates/trogon-source-gitlab/src/config.rs index 29e5fd135..71e74f8ed 100644 --- a/rsworkspace/crates/trogon-source-gitlab/src/config.rs +++ b/rsworkspace/crates/trogon-source-gitlab/src/config.rs @@ -1,169 +1,49 @@ -use std::time::Duration; +use std::fmt; -use trogon_nats::NatsConfig; use trogon_nats::NatsToken; -use trogon_std::env::ReadEnv; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_std::{EmptySecret, NonZeroDuration, SecretString}; -use crate::constants::{ - DEFAULT_NATS_ACK_TIMEOUT_MS, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE, DEFAULT_STREAM_NAME, - DEFAULT_SUBJECT_PREFIX, -}; -use crate::webhook_secret::WebhookSecret; +#[derive(Clone)] +pub struct GitLabWebhookSecret(SecretString); -pub struct GitlabConfig { - pub webhook_secret: WebhookSecret, - pub port: u16, - pub subject_prefix: NatsToken, - pub stream_name: NatsToken, - pub stream_max_age: Duration, - pub nats_ack_timeout: Duration, - pub nats: NatsConfig, +impl GitLabWebhookSecret { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } } -impl GitlabConfig { - pub fn from_env(env: &E) -> Self { - Self { - webhook_secret: WebhookSecret::new( - env.var("GITLAB_WEBHOOK_SECRET").unwrap_or_default(), - ) - .expect("GITLAB_WEBHOOK_SECRET is required"), - port: env - .var("GITLAB_WEBHOOK_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT), - subject_prefix: NatsToken::new( - env.var("GITLAB_SUBJECT_PREFIX") - .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), - ) - .expect("GITLAB_SUBJECT_PREFIX is not a valid NATS token"), - stream_name: NatsToken::new( - env.var("GITLAB_STREAM_NAME") - .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), - ) - .expect("GITLAB_STREAM_NAME is not a valid NATS token"), - stream_max_age: env - .var("GITLAB_STREAM_MAX_AGE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_STREAM_MAX_AGE), - nats_ack_timeout: env - .var("GITLAB_NATS_ACK_TIMEOUT_MS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_millis) - .unwrap_or(Duration::from_millis(DEFAULT_NATS_ACK_TIMEOUT_MS)), - nats: NatsConfig::from_env(env), - } +impl fmt::Debug for GitLabWebhookSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("GitLabWebhookSecret(****)") } } +pub struct GitlabConfig { + pub webhook_secret: GitLabWebhookSecret, + pub subject_prefix: NatsToken, + pub stream_name: NatsToken, + pub stream_max_age: StreamMaxAge, + pub nats_ack_timeout: NonZeroDuration, +} + #[cfg(test)] mod tests { use super::*; - use trogon_std::env::InMemoryEnv; - - fn env_with_secret() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("GITLAB_WEBHOOK_SECRET", "test-secret"); - env - } - - #[test] - fn defaults_with_required_secret() { - let env = env_with_secret(); - let config = GitlabConfig::from_env(&env); - - assert_eq!(config.webhook_secret.as_str(), "test-secret"); - assert_eq!(config.port, 8080); - assert_eq!(config.subject_prefix.as_str(), "gitlab"); - assert_eq!(config.stream_name.as_str(), "GITLAB"); - assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); - assert_eq!(config.nats_ack_timeout, Duration::from_millis(10_000)); - } - - #[test] - fn reads_all_env_vars() { - let env = InMemoryEnv::new(); - env.set("GITLAB_WEBHOOK_SECRET", "my-secret"); - env.set("GITLAB_WEBHOOK_PORT", "9090"); - env.set("GITLAB_SUBJECT_PREFIX", "gl"); - env.set("GITLAB_STREAM_NAME", "GL_EVENTS"); - env.set("GITLAB_STREAM_MAX_AGE_SECS", "3600"); - env.set("GITLAB_NATS_ACK_TIMEOUT_MS", "5000"); - - let config = GitlabConfig::from_env(&env); - - assert_eq!(config.webhook_secret.as_str(), "my-secret"); - assert_eq!(config.port, 9090); - assert_eq!(config.subject_prefix.as_str(), "gl"); - assert_eq!(config.stream_name.as_str(), "GL_EVENTS"); - assert_eq!(config.stream_max_age, Duration::from_secs(3600)); - assert_eq!(config.nats_ack_timeout, Duration::from_millis(5000)); - } - - #[test] - #[should_panic(expected = "GITLAB_WEBHOOK_SECRET is required")] - fn missing_webhook_secret_panics() { - let env = InMemoryEnv::new(); - GitlabConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "GITLAB_WEBHOOK_SECRET is required")] - fn empty_webhook_secret_panics() { - let env = InMemoryEnv::new(); - env.set("GITLAB_WEBHOOK_SECRET", ""); - GitlabConfig::from_env(&env); - } - - #[test] - fn invalid_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("GITLAB_WEBHOOK_PORT", "not-a-number"); - - let config = GitlabConfig::from_env(&env); - - assert_eq!(config.port, 8080); - } - - #[test] - fn invalid_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("GITLAB_STREAM_MAX_AGE_SECS", "not-a-number"); - - let config = GitlabConfig::from_env(&env); - - assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE); - } - - #[test] - fn invalid_nats_ack_timeout_falls_back_to_default() { - let env = env_with_secret(); - env.set("GITLAB_NATS_ACK_TIMEOUT_MS", "not-a-number"); - - let config = GitlabConfig::from_env(&env); - - assert_eq!( - config.nats_ack_timeout, - Duration::from_millis(DEFAULT_NATS_ACK_TIMEOUT_MS) - ); - } #[test] - #[should_panic(expected = "GITLAB_SUBJECT_PREFIX is not a valid NATS token")] - fn invalid_subject_prefix_panics() { - let env = env_with_secret(); - env.set("GITLAB_SUBJECT_PREFIX", "has.dot"); - GitlabConfig::from_env(&env); + fn gitlab_webhook_secret_roundtrips() { + let secret = GitLabWebhookSecret::new("super-secret").unwrap(); + assert_eq!(secret.as_str(), "super-secret"); } #[test] - #[should_panic(expected = "GITLAB_STREAM_NAME is not a valid NATS token")] - fn invalid_stream_name_panics() { - let env = env_with_secret(); - env.set("GITLAB_STREAM_NAME", "has space"); - GitlabConfig::from_env(&env); + fn gitlab_webhook_secret_debug_redacts() { + let secret = GitLabWebhookSecret::new("super-secret").unwrap(); + assert_eq!(format!("{secret:?}"), "GitLabWebhookSecret(****)"); } } diff --git a/rsworkspace/crates/trogon-source-gitlab/src/constants.rs b/rsworkspace/crates/trogon-source-gitlab/src/constants.rs index 1af8f075b..06b274db3 100644 --- a/rsworkspace/crates/trogon-source-gitlab/src/constants.rs +++ b/rsworkspace/crates/trogon-source-gitlab/src/constants.rs @@ -1,14 +1,5 @@ -use std::time::Duration; - use trogon_std::{ByteSize, HttpBodySizeMax}; -pub const DEFAULT_PORT: u16 = 8080; -pub const DEFAULT_SUBJECT_PREFIX: &str = "gitlab"; -pub const DEFAULT_STREAM_NAME: &str = "GITLAB"; -pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days -pub const DEFAULT_NATS_ACK_TIMEOUT_MS: u64 = 10_000; -pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - pub const HTTP_BODY_SIZE_MAX: HttpBodySizeMax = HttpBodySizeMax::new(ByteSize::mib(25)).unwrap(); pub const HEADER_TOKEN: &str = "x-gitlab-token"; diff --git a/rsworkspace/crates/trogon-source-gitlab/src/lib.rs b/rsworkspace/crates/trogon-source-gitlab/src/lib.rs index 7d8bc805f..d8521bde5 100644 --- a/rsworkspace/crates/trogon-source-gitlab/src/lib.rs +++ b/rsworkspace/crates/trogon-source-gitlab/src/lib.rs @@ -39,11 +39,6 @@ pub mod config; pub mod constants; pub mod server; pub mod signature; -pub mod webhook_secret; - pub use config::GitlabConfig; -#[cfg(not(coverage))] -pub use server::{ServeError, serve}; pub use server::{provision, router}; pub use signature::SignatureError; -pub use webhook_secret::WebhookSecret; diff --git a/rsworkspace/crates/trogon-source-gitlab/src/main.rs b/rsworkspace/crates/trogon-source-gitlab/src/main.rs deleted file mode 100644 index f008f2c48..000000000 --- a/rsworkspace/crates/trogon-source-gitlab/src/main.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(not(coverage))] -use { - acp_telemetry::ServiceName, tracing::error, tracing::info, trogon_nats::connect, - trogon_nats::jetstream::ClaimCheckPublisher, trogon_nats::jetstream::MaxPayload, - trogon_nats::jetstream::NatsJetStreamClient, trogon_nats::jetstream::NatsObjectStore, - trogon_source_gitlab::GitlabConfig, - trogon_source_gitlab::constants::DEFAULT_NATS_CONNECT_TIMEOUT, trogon_std::env::SystemEnv, - trogon_std::fs::SystemFs, -}; - -#[cfg(not(coverage))] -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = GitlabConfig::from_env(&SystemEnv); - - acp_telemetry::init_logger( - ServiceName::TrogonSourceGitlab, - &config.subject_prefix, - &SystemEnv, - &SystemFs, - ); - - info!("GitLab webhook server starting"); - - let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; - let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); - let js_context = async_nats::jetstream::new(nats); - let object_store = NatsObjectStore::provision( - &js_context, - async_nats::jetstream::object_store::Config { - bucket: "trogon-claims".to_string(), - ..Default::default() - }, - ) - .await?; - let client = NatsJetStreamClient::new(js_context); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store, - "trogon-claims".to_string(), - max_payload, - ); - let result = trogon_source_gitlab::serve(client, publisher, config).await; - - if let Err(ref e) = result { - error!(error = %e, "GitLab webhook server stopped with error"); - } else { - info!("GitLab webhook server stopped"); - } - - acp_telemetry::shutdown_otel(); - - result.map_err(|e| Box::new(e) as Box) -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(coverage, test))] -mod tests { - #[test] - fn coverage_stub() { - super::main(); - } -} diff --git a/rsworkspace/crates/trogon-source-gitlab/src/server.rs b/rsworkspace/crates/trogon-source-gitlab/src/server.rs index db160d5b2..87ea601e5 100644 --- a/rsworkspace/crates/trogon-source-gitlab/src/server.rs +++ b/rsworkspace/crates/trogon-source-gitlab/src/server.rs @@ -1,6 +1,7 @@ use std::fmt; use std::time::Duration; +use crate::config::GitLabWebhookSecret; use crate::config::GitlabConfig; use crate::constants::{ HEADER_EVENT, HEADER_EVENT_UUID, HEADER_IDEMPOTENCY_KEY, HEADER_INSTANCE, HEADER_TOKEN, @@ -8,12 +9,9 @@ use crate::constants::{ NATS_HEADER_INSTANCE, NATS_HEADER_REJECT_REASON, NATS_HEADER_WEBHOOK_UUID, }; use crate::signature; -use crate::webhook_secret::WebhookSecret; -#[cfg(not(coverage))] -use async_nats::jetstream::context::CreateStreamError; use axum::{ Router, body::Bytes, extract::DefaultBodyLimit, extract::State, http::HeaderMap, - http::StatusCode, routing::get, routing::post, + http::StatusCode, routing::post, }; use std::future::Future; use std::pin::Pin; @@ -22,41 +20,7 @@ use trogon_nats::NatsToken; use trogon_nats::jetstream::{ ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut, PublishOutcome, }; - -#[cfg(not(coverage))] -#[derive(Debug)] -#[non_exhaustive] -pub enum ServeError { - Provision(CreateStreamError), - Io(std::io::Error), -} - -#[cfg(not(coverage))] -impl fmt::Display for ServeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServeError::Provision(e) => write!(f, "stream provisioning failed: {e}"), - ServeError::Io(e) => write!(f, "server IO error: {e}"), - } - } -} - -#[cfg(not(coverage))] -impl std::error::Error for ServeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServeError::Provision(e) => Some(e), - ServeError::Io(e) => Some(e), - } - } -} - -#[cfg(not(coverage))] -impl From for ServeError { - fn from(e: std::io::Error) -> Self { - ServeError::Io(e) - } -} +use trogon_std::NonZeroDuration; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RejectReason { @@ -78,14 +42,14 @@ async fn publish_unroutable( subject_prefix: &NatsToken, reason: RejectReason, body: Bytes, - ack_timeout: Duration, + ack_timeout: NonZeroDuration, ) -> StatusCode { let subject = format!("{subject_prefix}.unroutable"); let mut headers = async_nats::HeaderMap::new(); headers.insert(NATS_HEADER_REJECT_REASON, reason.as_str()); let outcome = publisher - .publish_event(subject, headers, body, ack_timeout) + .publish_event(subject, headers, body, ack_timeout.into()) .await; if outcome.is_ok() { // Return 200 so GitLab doesn't count this as a failure and @@ -110,22 +74,22 @@ fn outcome_to_status(outcome: PublishOutcome) -> StatusCode #[derive(Clone)] struct AppState { publisher: ClaimCheckPublisher, - webhook_secret: WebhookSecret, + webhook_secret: GitLabWebhookSecret, subject_prefix: NatsToken, - nats_ack_timeout: Duration, + nats_ack_timeout: NonZeroDuration, } pub async fn provision(js: &C, config: &GitlabConfig) -> Result<(), C::Error> { js.get_or_create_stream(async_nats::jetstream::stream::Config { name: config.stream_name.as_str().to_owned(), subjects: vec![format!("{}.>", config.subject_prefix)], - max_age: config.stream_max_age, + max_age: config.stream_max_age.into(), ..Default::default() }) .await?; let stream = config.stream_name.as_str(); - let max_age_secs = config.stream_max_age.as_secs(); + let max_age_secs = Duration::from(config.stream_max_age).as_secs(); info!(stream, max_age_secs, "JetStream stream ready"); Ok(()) } @@ -143,43 +107,10 @@ pub fn router( Router::new() .route("/webhook", post(handle_webhook::)) - .route("/health", get(handle_health)) .layer(DefaultBodyLimit::max(HTTP_BODY_SIZE_MAX.as_usize())) .with_state(state) } -#[cfg(not(coverage))] -pub async fn serve( - context: C, - publisher: ClaimCheckPublisher, - config: GitlabConfig, -) -> Result<(), ServeError> -where - C: JetStreamContext, - P: JetStreamPublisher, - S: ObjectStorePut, -{ - provision(&context, &config) - .await - .map_err(ServeError::Provision)?; - - let app = router(publisher, &config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "GitLab webhook server listening"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - info!("GitLab webhook server shut down"); - Ok(()) -} - -async fn handle_health() -> StatusCode { - StatusCode::OK -} - fn handle_webhook( State(state): State>, headers: HeaderMap, @@ -280,7 +211,7 @@ async fn handle_webhook_inner( let outcome = state .publisher - .publish_event(subject, nats_headers, body, state.nats_ack_timeout) + .publish_event(subject, nats_headers, body, state.nats_ack_timeout.into()) .await; outcome_to_status(outcome) @@ -289,10 +220,13 @@ async fn handle_webhook_inner( #[cfg(test)] mod tests { use super::*; + use std::time::Duration; + use axum::body::Body; use axum::http::Request; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; + use trogon_nats::jetstream::StreamMaxAge; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, @@ -313,13 +247,11 @@ mod tests { fn test_config() -> GitlabConfig { GitlabConfig { - webhook_secret: WebhookSecret::new(TEST_SECRET).unwrap(), - port: 0, + webhook_secret: GitLabWebhookSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("gitlab").unwrap(), stream_name: NatsToken::new("GITLAB").unwrap(), - stream_max_age: Duration::from_secs(3600), - nats_ack_timeout: Duration::from_secs(10), - nats: trogon_nats::NatsConfig::from_env(&trogon_std::env::InMemoryEnv::new()), + stream_max_age: StreamMaxAge::from_secs(3600).unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), } } @@ -348,28 +280,6 @@ mod tests { builder.body(Body::from(body.to_vec())).unwrap() } - #[cfg(not(coverage))] - #[test] - fn serve_error_display_and_source() { - use async_nats::jetstream::context::{CreateStreamError, CreateStreamErrorKind}; - - let io_err = ServeError::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "port taken", - )); - assert_eq!(io_err.to_string(), "server IO error: port taken"); - assert!(std::error::Error::source(&io_err).is_some()); - - let prov_err = ServeError::Provision(CreateStreamError::new( - CreateStreamErrorKind::EmptyStreamName, - )); - assert!(prov_err.to_string().contains("stream provisioning failed")); - assert!(std::error::Error::source(&prov_err).is_some()); - - let io_err: ServeError = std::io::Error::other("boom").into(); - assert!(matches!(io_err, ServeError::Io(_))); - } - #[test] fn reject_reason_as_str() { assert_eq!( @@ -558,9 +468,9 @@ mod tests { let state = AppState { publisher: wrap_publisher(publisher.clone()), - webhook_secret: WebhookSecret::new(TEST_SECRET).unwrap(), + webhook_secret: GitLabWebhookSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("custom").unwrap(), - nats_ack_timeout: Duration::from_secs(10), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -580,21 +490,6 @@ mod tests { assert_eq!(publisher.published_subjects(), vec!["custom.issues"]); } - #[tokio::test] - async fn health_endpoint_returns_200() { - let _guard = tracing_guard(); - let app = mock_app(MockJetStreamPublisher::new()); - - let req = Request::builder() - .method("GET") - .uri("/health") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } - #[tokio::test] async fn empty_body_publishes_successfully() { let _guard = tracing_guard(); @@ -617,9 +512,9 @@ mod tests { let state = AppState { publisher: wrap_publisher(publisher.clone()), - webhook_secret: WebhookSecret::new(TEST_SECRET).unwrap(), + webhook_secret: GitLabWebhookSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("gitlab").unwrap(), - nats_ack_timeout: Duration::from_secs(10), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -777,9 +672,9 @@ mod tests { "test-bucket".to_string(), MaxPayload::from_server_limit(usize::MAX), ), - webhook_secret: WebhookSecret::new(TEST_SECRET).unwrap(), + webhook_secret: GitLabWebhookSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("gitlab").unwrap(), - nats_ack_timeout: Duration::from_secs(10), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -810,9 +705,9 @@ mod tests { "test-bucket".to_string(), MaxPayload::from_server_limit(usize::MAX), ), - webhook_secret: WebhookSecret::new(TEST_SECRET).unwrap(), + webhook_secret: GitLabWebhookSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("gitlab").unwrap(), - nats_ack_timeout: Duration::from_millis(10), + nats_ack_timeout: NonZeroDuration::from_millis(10).unwrap(), }; let app = Router::new() diff --git a/rsworkspace/crates/trogon-source-gitlab/src/webhook_secret.rs b/rsworkspace/crates/trogon-source-gitlab/src/webhook_secret.rs deleted file mode 100644 index 9e2ef371a..000000000 --- a/rsworkspace/crates/trogon-source-gitlab/src/webhook_secret.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::fmt; -use std::sync::Arc; - -#[derive(Clone)] -pub struct WebhookSecret(Arc); - -#[derive(Debug, PartialEq, Eq)] -pub struct EmptyWebhookSecret; - -impl fmt::Display for EmptyWebhookSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("webhook secret must not be empty") - } -} - -impl std::error::Error for EmptyWebhookSecret {} - -impl WebhookSecret { - pub fn new(s: impl AsRef) -> Result { - let s = s.as_ref(); - if s.is_empty() { - return Err(EmptyWebhookSecret); - } - Ok(Self(Arc::from(s))) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for WebhookSecret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("WebhookSecret(***)") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_secret() { - let secret = WebhookSecret::new("my-secret").unwrap(); - assert_eq!(secret.as_str(), "my-secret"); - } - - #[test] - fn empty_secret_rejected() { - assert!(matches!(WebhookSecret::new(""), Err(EmptyWebhookSecret))); - } - - #[test] - fn debug_redacts_value() { - let secret = WebhookSecret::new("super-secret").unwrap(); - assert_eq!(format!("{secret:?}"), "WebhookSecret(***)"); - } - - #[test] - fn clone_shares_arc() { - let a = WebhookSecret::new("secret").unwrap(); - let b = a.clone(); - assert_eq!(a.as_str(), b.as_str()); - } - - #[test] - fn error_display() { - assert_eq!( - EmptyWebhookSecret.to_string(), - "webhook secret must not be empty" - ); - } -} diff --git a/rsworkspace/crates/trogon-source-linear/Cargo.toml b/rsworkspace/crates/trogon-source-linear/Cargo.toml index 50ee2c4b2..9161cffbf 100644 --- a/rsworkspace/crates/trogon-source-linear/Cargo.toml +++ b/rsworkspace/crates/trogon-source-linear/Cargo.toml @@ -6,10 +6,6 @@ edition = "2024" [lints] workspace = true -[[bin]] -name = "trogon-source-linear" -path = "src/main.rs" - [dependencies] acp-telemetry = { workspace = true } async-nats = { workspace = true, features = ["ring", "jetstream"] } diff --git a/rsworkspace/crates/trogon-source-linear/src/config.rs b/rsworkspace/crates/trogon-source-linear/src/config.rs index 541cd4662..1d84bb641 100644 --- a/rsworkspace/crates/trogon-source-linear/src/config.rs +++ b/rsworkspace/crates/trogon-source-linear/src/config.rs @@ -1,362 +1,50 @@ -use std::time::Duration; +use std::fmt; -use trogon_nats::{NatsConfig, NatsToken}; -use trogon_std::env::ReadEnv; +use trogon_nats::NatsToken; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_std::{EmptySecret, NonZeroDuration, SecretString}; -use crate::constants::{ - DEFAULT_NATS_ACK_TIMEOUT_MS, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE_SECS, DEFAULT_STREAM_NAME, - DEFAULT_SUBJECT_PREFIX, DEFAULT_TIMESTAMP_TOLERANCE_SECS, -}; +#[derive(Clone)] +pub struct LinearWebhookSecret(SecretString); -/// Configuration for the Linear webhook server. -/// -/// Resolved from environment variables: -/// - `LINEAR_WEBHOOK_SECRET`: signing secret from Linear's webhook settings (required) -/// - `LINEAR_WEBHOOK_PORT`: HTTP listening port (default: 8080) -/// - `LINEAR_SUBJECT_PREFIX`: NATS subject prefix (default: `linear`) -/// - `LINEAR_STREAM_NAME`: JetStream stream name (default: `LINEAR`) -/// - `LINEAR_STREAM_MAX_AGE_SECS`: max age of messages in the JetStream stream in seconds (default: 604800 / 7 days) -/// - `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS`: replay-attack window in seconds (default: 60, set to 0 to disable) -/// - `LINEAR_NATS_ACK_TIMEOUT_MS`: how long to wait for a JetStream ACK in milliseconds (default: 10000) -/// - Standard `NATS_*` variables for NATS connection (see `trogon-nats`) -pub struct LinearConfig { - pub webhook_secret: String, - pub port: u16, - pub subject_prefix: NatsToken, - pub stream_name: NatsToken, - pub stream_max_age: Duration, - /// How far in the past a `webhookTimestamp` may be before the request is - /// rejected as a potential replay. `None` disables the check entirely - /// (set `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS=0`). - pub timestamp_tolerance: Option, - /// How long to wait for a JetStream ACK before declaring it timed out. - pub nats_ack_timeout: Duration, - pub nats: NatsConfig, -} +impl LinearWebhookSecret { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } -impl LinearConfig { - pub fn from_env(env: &E) -> Self { - let tolerance_secs: u64 = env - .var("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_TIMESTAMP_TOLERANCE_SECS); + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} - Self { - webhook_secret: env - .var("LINEAR_WEBHOOK_SECRET") - .ok() - .filter(|s| !s.is_empty()) - .expect("LINEAR_WEBHOOK_SECRET is required"), - port: env - .var("LINEAR_WEBHOOK_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT), - subject_prefix: NatsToken::new( - env.var("LINEAR_SUBJECT_PREFIX") - .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), - ) - .expect("LINEAR_SUBJECT_PREFIX is not a valid NATS token"), - stream_name: NatsToken::new( - env.var("LINEAR_STREAM_NAME") - .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), - ) - .expect("LINEAR_STREAM_NAME is not a valid NATS token"), - stream_max_age: Duration::from_secs( - env.var("LINEAR_STREAM_MAX_AGE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_STREAM_MAX_AGE_SECS), - ), - timestamp_tolerance: (tolerance_secs > 0).then(|| Duration::from_secs(tolerance_secs)), - nats_ack_timeout: Duration::from_millis( - env.var("LINEAR_NATS_ACK_TIMEOUT_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(DEFAULT_NATS_ACK_TIMEOUT_MS), - ), - nats: NatsConfig::from_env(env), - } +impl fmt::Debug for LinearWebhookSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("LinearWebhookSecret(****)") } } +pub struct LinearConfig { + pub webhook_secret: LinearWebhookSecret, + pub subject_prefix: NatsToken, + pub stream_name: NatsToken, + pub stream_max_age: StreamMaxAge, + pub timestamp_tolerance: Option, + pub nats_ack_timeout: NonZeroDuration, +} + #[cfg(test)] mod tests { use super::*; - use trogon_std::env::InMemoryEnv; - - fn env_with_secret() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("LINEAR_WEBHOOK_SECRET", "test-secret"); - env - } - - #[test] - #[should_panic(expected = "LINEAR_WEBHOOK_SECRET is required")] - fn panics_when_webhook_secret_missing() { - let env = InMemoryEnv::new(); - let _config = LinearConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "LINEAR_WEBHOOK_SECRET is required")] - fn panics_when_webhook_secret_empty() { - let env = InMemoryEnv::new(); - env.set("LINEAR_WEBHOOK_SECRET", ""); - let _config = LinearConfig::from_env(&env); - } - - #[test] - fn defaults_when_only_secret_set() { - let env = env_with_secret(); - let config = LinearConfig::from_env(&env); - - assert_eq!(config.webhook_secret, "test-secret"); - assert_eq!(config.port, 8080); - assert_eq!(config.subject_prefix.as_str(), "linear"); - assert_eq!(config.stream_name.as_str(), "LINEAR"); - assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); - assert_eq!(config.timestamp_tolerance, Some(Duration::from_secs(60))); - assert_eq!( - config.nats_ack_timeout, - Duration::from_millis(DEFAULT_NATS_ACK_TIMEOUT_MS) - ); - } - - #[test] - fn reads_all_env_vars() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_SECRET", "my-secret"); - env.set("LINEAR_WEBHOOK_PORT", "9090"); - env.set("LINEAR_SUBJECT_PREFIX", "lin"); - env.set("LINEAR_STREAM_NAME", "LIN_EVENTS"); - env.set("LINEAR_STREAM_MAX_AGE_SECS", "3600"); - env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "120"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.webhook_secret, "my-secret"); - assert_eq!(config.port, 9090); - assert_eq!(config.subject_prefix.as_str(), "lin"); - assert_eq!(config.stream_name.as_str(), "LIN_EVENTS"); - assert_eq!(config.stream_max_age, Duration::from_secs(3600)); - assert_eq!(config.timestamp_tolerance, Some(Duration::from_secs(120))); - } - - #[test] - #[should_panic(expected = "LINEAR_SUBJECT_PREFIX is not a valid NATS token")] - fn empty_subject_prefix_panics() { - let env = env_with_secret(); - env.set("LINEAR_SUBJECT_PREFIX", ""); - let _config = LinearConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "LINEAR_STREAM_NAME is not a valid NATS token")] - fn empty_stream_name_panics() { - let env = env_with_secret(); - env.set("LINEAR_STREAM_NAME", ""); - let _config = LinearConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "LINEAR_SUBJECT_PREFIX is not a valid NATS token")] - fn subject_prefix_with_dots_panics() { - let env = env_with_secret(); - env.set("LINEAR_SUBJECT_PREFIX", "linear.events"); - let _config = LinearConfig::from_env(&env); - } - - #[test] - fn timestamp_tolerance_zero_disables_check() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "0"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.timestamp_tolerance, None); - } - - #[test] - fn invalid_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_PORT", "not-a-number"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.port, 8080); - } - - #[test] - fn invalid_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_STREAM_MAX_AGE_SECS", "not-a-number"); - - let config = LinearConfig::from_env(&env); - - assert_eq!( - config.stream_max_age, - Duration::from_secs(DEFAULT_STREAM_MAX_AGE_SECS) - ); - } - - #[test] - fn invalid_tolerance_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "not-a-number"); - - let config = LinearConfig::from_env(&env); - - assert_eq!( - config.timestamp_tolerance, - Some(Duration::from_secs(DEFAULT_TIMESTAMP_TOLERANCE_SECS)) - ); - } - - #[test] - fn port_overflow_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_PORT", "65536"); // u16::MAX is 65535 - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.port, DEFAULT_PORT); - } - - #[test] - fn stream_max_age_zero_produces_zero_duration() { - let env = env_with_secret(); - env.set("LINEAR_STREAM_MAX_AGE_SECS", "0"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.stream_max_age, Duration::from_secs(0)); - } - - #[test] - fn negative_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_PORT", "-1"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.port, DEFAULT_PORT); - } - - #[test] - fn float_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_PORT", "8080.5"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.port, DEFAULT_PORT); - } - - #[test] - fn port_with_trailing_chars_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_PORT", "8080abc"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.port, DEFAULT_PORT); - } - - #[test] - fn negative_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_STREAM_MAX_AGE_SECS", "-1"); - - let config = LinearConfig::from_env(&env); - - assert_eq!( - config.stream_max_age, - Duration::from_secs(DEFAULT_STREAM_MAX_AGE_SECS) - ); - } - - #[test] - fn float_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_STREAM_MAX_AGE_SECS", "3600.5"); - - let config = LinearConfig::from_env(&env); - - assert_eq!( - config.stream_max_age, - Duration::from_secs(DEFAULT_STREAM_MAX_AGE_SECS) - ); - } - - #[test] - fn tolerance_secs_one_is_minimum_non_zero() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "1"); - - let config = LinearConfig::from_env(&env); - - assert_eq!(config.timestamp_tolerance, Some(Duration::from_secs(1))); - } - - #[test] - fn negative_tolerance_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "-1"); - - let config = LinearConfig::from_env(&env); - - assert_eq!( - config.timestamp_tolerance, - Some(Duration::from_secs(DEFAULT_TIMESTAMP_TOLERANCE_SECS)) - ); - } - - #[test] - fn float_tolerance_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "60.5"); - - let config = LinearConfig::from_env(&env); - - assert_eq!( - config.timestamp_tolerance, - Some(Duration::from_secs(DEFAULT_TIMESTAMP_TOLERANCE_SECS)) - ); - } - - #[test] - fn defaults_nats_ack_timeout_to_10_seconds() { - let env = env_with_secret(); - let config = LinearConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(10)); - } - - #[test] - fn reads_nats_ack_timeout_from_env() { - let env = env_with_secret(); - env.set("LINEAR_NATS_ACK_TIMEOUT_MS", "500"); - let config = LinearConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, Duration::from_millis(500)); - } #[test] - fn invalid_nats_ack_timeout_falls_back_to_default() { - let env = env_with_secret(); - env.set("LINEAR_NATS_ACK_TIMEOUT_MS", "not-a-number"); - let config = LinearConfig::from_env(&env); - assert_eq!( - config.nats_ack_timeout, - Duration::from_millis(DEFAULT_NATS_ACK_TIMEOUT_MS) - ); + fn linear_webhook_secret_roundtrips() { + let secret = LinearWebhookSecret::new("super-secret").unwrap(); + assert_eq!(secret.as_str(), "super-secret"); } #[test] - fn zero_nats_ack_timeout_is_valid() { - let env = env_with_secret(); - env.set("LINEAR_NATS_ACK_TIMEOUT_MS", "0"); - let config = LinearConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, Duration::ZERO); + fn linear_webhook_secret_debug_redacts() { + let secret = LinearWebhookSecret::new("super-secret").unwrap(); + assert_eq!(format!("{secret:?}"), "LinearWebhookSecret(****)"); } } diff --git a/rsworkspace/crates/trogon-source-linear/src/constants.rs b/rsworkspace/crates/trogon-source-linear/src/constants.rs index 2ac8995c9..be28b982c 100644 --- a/rsworkspace/crates/trogon-source-linear/src/constants.rs +++ b/rsworkspace/crates/trogon-source-linear/src/constants.rs @@ -1,17 +1,5 @@ -use std::time::Duration; - use trogon_std::{ByteSize, HttpBodySizeMax}; -pub const DEFAULT_PORT: u16 = 8080; -pub const DEFAULT_SUBJECT_PREFIX: &str = "linear"; -pub const DEFAULT_STREAM_NAME: &str = "LINEAR"; -pub const DEFAULT_STREAM_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60; // 7 days -/// Default replay-attack tolerance: 60 seconds (as recommended by Linear). -pub const DEFAULT_TIMESTAMP_TOLERANCE_SECS: u64 = 60; -/// Default JetStream ACK timeout: 10 seconds. -pub const DEFAULT_NATS_ACK_TIMEOUT_MS: u64 = 10_000; -pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - pub const HTTP_BODY_SIZE_MAX: HttpBodySizeMax = HttpBodySizeMax::new(ByteSize::mib(2)).unwrap(); pub const NATS_HEADER_REJECT_REASON: &str = "X-Linear-Reject-Reason"; diff --git a/rsworkspace/crates/trogon-source-linear/src/lib.rs b/rsworkspace/crates/trogon-source-linear/src/lib.rs index 9f8c46cd4..1258600f6 100644 --- a/rsworkspace/crates/trogon-source-linear/src/lib.rs +++ b/rsworkspace/crates/trogon-source-linear/src/lib.rs @@ -40,5 +40,4 @@ pub mod server; pub mod signature; pub use config::LinearConfig; -#[cfg(not(coverage))] -pub use server::serve; +pub use server::{provision, router}; diff --git a/rsworkspace/crates/trogon-source-linear/src/main.rs b/rsworkspace/crates/trogon-source-linear/src/main.rs deleted file mode 100644 index d773fd74c..000000000 --- a/rsworkspace/crates/trogon-source-linear/src/main.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(not(coverage))] -use { - acp_telemetry::ServiceName, tracing::error, tracing::info, trogon_nats::connect, - trogon_nats::jetstream::ClaimCheckPublisher, trogon_nats::jetstream::MaxPayload, - trogon_nats::jetstream::NatsJetStreamClient, trogon_nats::jetstream::NatsObjectStore, - trogon_source_linear::LinearConfig, - trogon_source_linear::constants::DEFAULT_NATS_CONNECT_TIMEOUT, trogon_std::env::SystemEnv, - trogon_std::fs::SystemFs, -}; - -#[cfg(not(coverage))] -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = LinearConfig::from_env(&SystemEnv); - - acp_telemetry::init_logger( - ServiceName::TrogonSourceLinear, - &config.subject_prefix, - &SystemEnv, - &SystemFs, - ); - - info!("Linear webhook server starting"); - - let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; - let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); - let js_context = async_nats::jetstream::new(nats); - let object_store = NatsObjectStore::provision( - &js_context, - async_nats::jetstream::object_store::Config { - bucket: "trogon-claims".to_string(), - ..Default::default() - }, - ) - .await?; - let client = NatsJetStreamClient::new(js_context); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store, - "trogon-claims".to_string(), - max_payload, - ); - let result = trogon_source_linear::serve(client, publisher, config).await; - - if let Err(ref e) = result { - error!(error = %e, "Linear webhook server stopped with error"); - } else { - info!("Linear webhook server stopped"); - } - - acp_telemetry::shutdown_otel(); - - result.map_err(|e| Box::new(e) as Box) -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(coverage, test))] -mod tests { - #[test] - fn coverage_stub() { - super::main(); - } -} diff --git a/rsworkspace/crates/trogon-source-linear/src/server.rs b/rsworkspace/crates/trogon-source-linear/src/server.rs index 843df5d0e..86b68996c 100644 --- a/rsworkspace/crates/trogon-source-linear/src/server.rs +++ b/rsworkspace/crates/trogon-source-linear/src/server.rs @@ -4,56 +4,19 @@ use std::pin::Pin; use std::time::Duration; use crate::config::LinearConfig; +use crate::config::LinearWebhookSecret; use crate::constants::{HTTP_BODY_SIZE_MAX, NATS_HEADER_REJECT_REASON}; use crate::signature; -#[cfg(not(coverage))] -use async_nats::jetstream::context::CreateStreamError; use axum::{ Router, body::Bytes, extract::DefaultBodyLimit, extract::State, http::HeaderMap, - http::StatusCode, routing::get, routing::post, + http::StatusCode, routing::post, }; use tracing::{info, instrument, warn}; use trogon_nats::NatsToken; -#[cfg(not(coverage))] -use trogon_nats::jetstream::JetStreamContext; use trogon_nats::jetstream::{ - ClaimCheckPublisher, JetStreamPublisher, ObjectStorePut, PublishOutcome, + ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut, PublishOutcome, }; - -#[cfg(not(coverage))] -#[derive(Debug)] -#[non_exhaustive] -pub enum ServeError { - Provision(CreateStreamError), - Io(std::io::Error), -} - -#[cfg(not(coverage))] -impl fmt::Display for ServeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Provision(e) => write!(f, "stream provisioning failed: {e}"), - Self::Io(e) => write!(f, "IO error: {e}"), - } - } -} - -#[cfg(not(coverage))] -impl std::error::Error for ServeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Provision(e) => Some(e), - Self::Io(e) => Some(e), - } - } -} - -#[cfg(not(coverage))] -impl From for ServeError { - fn from(e: std::io::Error) -> Self { - ServeError::Io(e) - } -} +use trogon_std::NonZeroDuration; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RejectReason { @@ -95,14 +58,14 @@ async fn publish_unroutable( subject_prefix: &NatsToken, reason: RejectReason, body: Bytes, - ack_timeout: Duration, + ack_timeout: NonZeroDuration, ) -> StatusCode { let subject = format!("{subject_prefix}.unroutable"); let mut headers = async_nats::HeaderMap::new(); headers.insert(NATS_HEADER_REJECT_REASON, reason.as_str()); let outcome = publisher - .publish_event(subject, headers, body, ack_timeout) + .publish_event(subject, headers, body, ack_timeout.into()) .await; if outcome.is_ok() { StatusCode::BAD_REQUEST @@ -115,27 +78,24 @@ async fn publish_unroutable( #[derive(Clone)] struct AppState { publisher: ClaimCheckPublisher, - webhook_secret: String, + webhook_secret: LinearWebhookSecret, subject_prefix: NatsToken, - timestamp_tolerance: Option, - nats_ack_timeout: Duration, + timestamp_tolerance: Option, + nats_ack_timeout: NonZeroDuration, } -#[cfg(not(coverage))] pub async fn provision(js: &C, config: &LinearConfig) -> Result<(), C::Error> { js.get_or_create_stream(async_nats::jetstream::stream::Config { name: config.stream_name.to_string(), subjects: vec![format!("{}.>", config.subject_prefix)], - max_age: config.stream_max_age, + max_age: config.stream_max_age.into(), ..Default::default() }) .await?; - let max_age_secs = config.stream_max_age.as_secs(); - info!( - stream = config.stream_name.as_str(), - max_age_secs, "JetStream stream ready" - ); + let max_age_secs = Duration::from(config.stream_max_age).as_secs(); + let stream_name = config.stream_name.as_str(); + info!(stream = stream_name, max_age_secs, "JetStream stream ready"); Ok(()) } @@ -153,43 +113,10 @@ pub fn router( Router::new() .route("/webhook", post(handle_webhook::)) - .route("/health", get(handle_health)) .layer(DefaultBodyLimit::max(HTTP_BODY_SIZE_MAX.as_usize())) .with_state(state) } -#[cfg(not(coverage))] -pub async fn serve( - context: C, - publisher: ClaimCheckPublisher, - config: LinearConfig, -) -> Result<(), ServeError> -where - C: JetStreamContext, - P: JetStreamPublisher, - S: ObjectStorePut, -{ - provision(&context, &config) - .await - .map_err(ServeError::Provision)?; - - let app = router(publisher, &config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "Linear webhook server listening"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - info!("Linear webhook server shut down"); - Ok(()) -} - -async fn handle_health() -> StatusCode { - StatusCode::OK -} - fn handle_webhook( State(state): State>, headers: HeaderMap, @@ -218,7 +145,7 @@ async fn handle_webhook_inner( .and_then(|v| v.to_str().ok()); match sig { - Some(sig) if signature::verify(&state.webhook_secret, &body, sig) => {} + Some(sig) if signature::verify(state.webhook_secret.as_str(), &body, sig) => {} Some(_) => { warn!("Invalid Linear webhook signature"); return StatusCode::UNAUTHORIZED; @@ -261,10 +188,10 @@ async fn handle_webhook_inner( .unwrap_or_default() .as_millis() as u64; let age_ms = now_ms.saturating_sub(ts_ms); - if age_ms > tolerance.as_millis() as u64 { + if age_ms > Duration::from(tolerance).as_millis() as u64 { warn!( age_ms, - tolerance_ms = tolerance.as_millis() as u64, + tolerance_ms = Duration::from(tolerance).as_millis() as u64, "Stale webhookTimestamp — potential replay attack" ); return publish_unroutable( @@ -343,7 +270,7 @@ async fn handle_webhook_inner( let outcome = state .publisher - .publish_event(subject, nats_headers, body, state.nats_ack_timeout) + .publish_event(subject, nats_headers, body, state.nats_ack_timeout.into()) .await; outcome_to_status(outcome) @@ -358,8 +285,10 @@ mod tests { use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; + use trogon_nats::jetstream::StreamMaxAge; use trogon_nats::jetstream::{ - ClaimCheckPublisher, MaxPayload, MockJetStreamPublisher, MockObjectStore, + ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, + MockObjectStore, }; type HmacSha256 = Hmac; @@ -385,14 +314,12 @@ mod tests { fn test_config() -> LinearConfig { LinearConfig { - webhook_secret: TEST_SECRET.to_string(), - port: 0, + webhook_secret: LinearWebhookSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("linear").expect("valid token"), stream_name: NatsToken::new("LINEAR").expect("valid token"), - stream_max_age: Duration::from_secs(3600), - timestamp_tolerance: Some(Duration::from_secs(60)), - nats_ack_timeout: Duration::from_secs(10), - nats: trogon_nats::NatsConfig::from_env(&trogon_std::env::InMemoryEnv::new()), + stream_max_age: StreamMaxAge::from_secs(3600).unwrap(), + timestamp_tolerance: NonZeroDuration::from_secs(60).ok(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), } } @@ -471,6 +398,32 @@ mod tests { assert_eq!(messages[0].subject, "linear.Issue.create"); } + #[tokio::test] + async fn provision_creates_stream() { + let _guard = tracing_guard(); + let js = MockJetStreamContext::new(); + let config = test_config(); + + provision(&js, &config).await.unwrap(); + + let streams = js.created_streams(); + assert_eq!(streams.len(), 1); + assert_eq!(streams[0].name, "LINEAR"); + assert_eq!(streams[0].subjects, vec!["linear.>"]); + assert_eq!(streams[0].max_age, Duration::from_secs(3600)); + } + + #[tokio::test] + async fn provision_propagates_error() { + let _guard = tracing_guard(); + let js = MockJetStreamContext::new(); + js.fail_next(); + let config = test_config(); + + let result = provision(&js, &config).await; + assert!(result.is_err()); + } + #[tokio::test] async fn missing_signature_returns_401() { let _guard = tracing_guard(); @@ -682,37 +635,6 @@ mod tests { assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } - #[cfg(not(coverage))] - #[test] - fn serve_error_display_and_source() { - use async_nats::jetstream::context::{CreateStreamError, CreateStreamErrorKind}; - - let io_err = ServeError::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "port taken", - )); - assert_eq!( - io_err.to_string(), - "stream provisioning failed: port taken" - .replace("stream provisioning failed", "IO error") - ); - assert!(std::error::Error::source(&io_err).is_some()); - - let prov_err = - ServeError::Provision(CreateStreamError::new(CreateStreamErrorKind::TimedOut)); - let display = prov_err.to_string(); - assert!(display.contains("stream provisioning failed"), "{display}"); - assert!(std::error::Error::source(&prov_err).is_some()); - } - - #[cfg(not(coverage))] - #[test] - fn serve_error_from_io() { - let io = std::io::Error::other("boom"); - let err: ServeError = io.into(); - assert!(matches!(err, ServeError::Io(_))); - } - #[tokio::test] async fn dlq_publish_failure_returns_500() { let _guard = tracing_guard(); diff --git a/rsworkspace/crates/trogon-source-slack/Cargo.toml b/rsworkspace/crates/trogon-source-slack/Cargo.toml index 61382b505..a3aa14152 100644 --- a/rsworkspace/crates/trogon-source-slack/Cargo.toml +++ b/rsworkspace/crates/trogon-source-slack/Cargo.toml @@ -6,10 +6,6 @@ edition = "2024" [lints] workspace = true -[[bin]] -name = "trogon-source-slack" -path = "src/main.rs" - [dependencies] acp-telemetry = { workspace = true } async-nats = { workspace = true, features = ["jetstream"] } diff --git a/rsworkspace/crates/trogon-source-slack/src/config.rs b/rsworkspace/crates/trogon-source-slack/src/config.rs index 4abc59a19..8826c04e6 100644 --- a/rsworkspace/crates/trogon-source-slack/src/config.rs +++ b/rsworkspace/crates/trogon-source-slack/src/config.rs @@ -1,226 +1,50 @@ -use std::time::Duration; +use std::fmt; -use trogon_nats::{NatsConfig, NatsToken}; -use trogon_std::env::ReadEnv; +use trogon_nats::NatsToken; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_std::{EmptySecret, NonZeroDuration, SecretString}; -use crate::constants::{ - DEFAULT_NATS_ACK_TIMEOUT, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE, DEFAULT_STREAM_NAME, - DEFAULT_SUBJECT_PREFIX, DEFAULT_TIMESTAMP_MAX_DRIFT_SECS, -}; +#[derive(Clone)] +pub struct SlackSigningSecret(SecretString); -/// Configuration for the Slack Events API webhook server. -/// -/// Resolved from environment variables: -/// - `SLACK_SIGNING_SECRET`: Slack app signing secret (**required**) -/// - `SLACK_WEBHOOK_PORT`: HTTP listening port (default: 3000) -/// - `SLACK_SUBJECT_PREFIX`: NATS subject prefix (default: `slack`) -/// - `SLACK_STREAM_NAME`: JetStream stream name (default: `SLACK`) -/// - `SLACK_STREAM_MAX_AGE_SECS`: max age of messages in the JetStream stream in seconds (default: 604800 / 7 days) -/// - `SLACK_NATS_ACK_TIMEOUT_SECS`: NATS ack timeout in seconds (default: 10) -/// - `SLACK_MAX_BODY_SIZE`: maximum webhook body size in bytes (default: 1048576 / 1 MB) -/// - `SLACK_TIMESTAMP_MAX_DRIFT_SECS`: max allowed clock drift for request timestamps in seconds (default: 300 / 5 min) -/// - Standard `NATS_*` variables for NATS connection (see `trogon-nats`) -pub struct SlackConfig { - pub signing_secret: String, - pub port: u16, - pub subject_prefix: NatsToken, - pub stream_name: NatsToken, - pub stream_max_age: Duration, - pub nats_ack_timeout: Duration, - pub timestamp_max_drift: Duration, - pub nats: NatsConfig, +impl SlackSigningSecret { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } } -impl SlackConfig { - pub fn from_env(env: &E) -> Self { - Self { - signing_secret: env - .var("SLACK_SIGNING_SECRET") - .ok() - .filter(|s| !s.is_empty()) - .expect("SLACK_SIGNING_SECRET is required"), - port: env - .var("SLACK_WEBHOOK_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT), - subject_prefix: NatsToken::new( - env.var("SLACK_SUBJECT_PREFIX") - .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), - ) - .expect("SLACK_SUBJECT_PREFIX is not a valid NATS token"), - stream_name: NatsToken::new( - env.var("SLACK_STREAM_NAME") - .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), - ) - .expect("SLACK_STREAM_NAME is not a valid NATS token"), - stream_max_age: env - .var("SLACK_STREAM_MAX_AGE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_STREAM_MAX_AGE), - nats_ack_timeout: env - .var("SLACK_NATS_ACK_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_NATS_ACK_TIMEOUT), - timestamp_max_drift: env - .var("SLACK_TIMESTAMP_MAX_DRIFT_SECS") - .ok() - .and_then(|v| v.parse::().ok()) - .filter(|&v| v > 0) - .map(Duration::from_secs) - .unwrap_or(Duration::from_secs(DEFAULT_TIMESTAMP_MAX_DRIFT_SECS)), - nats: NatsConfig::from_env(env), - } +impl fmt::Debug for SlackSigningSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SlackSigningSecret(****)") } } +pub struct SlackConfig { + pub signing_secret: SlackSigningSecret, + pub subject_prefix: NatsToken, + pub stream_name: NatsToken, + pub stream_max_age: StreamMaxAge, + pub nats_ack_timeout: NonZeroDuration, + pub timestamp_max_drift: NonZeroDuration, +} + #[cfg(test)] mod tests { use super::*; - use trogon_std::env::InMemoryEnv; - - fn env_with_secret() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("SLACK_SIGNING_SECRET", "test-secret"); - env - } - - #[test] - fn defaults_with_required_secret() { - let env = env_with_secret(); - let config = SlackConfig::from_env(&env); - - assert_eq!(config.signing_secret, "test-secret"); - assert_eq!(config.port, 3000); - assert_eq!(config.subject_prefix.as_str(), "slack"); - assert_eq!(config.stream_name.as_str(), "SLACK"); - assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(10)); - assert_eq!(config.timestamp_max_drift, Duration::from_secs(300)); - } - - #[test] - fn reads_all_env_vars() { - let env = InMemoryEnv::new(); - env.set("SLACK_SIGNING_SECRET", "my-secret"); - env.set("SLACK_WEBHOOK_PORT", "9090"); - env.set("SLACK_SUBJECT_PREFIX", "slk"); - env.set("SLACK_STREAM_NAME", "SLK_EVENTS"); - env.set("SLACK_STREAM_MAX_AGE_SECS", "3600"); - env.set("SLACK_NATS_ACK_TIMEOUT_SECS", "30"); - env.set("SLACK_TIMESTAMP_MAX_DRIFT_SECS", "60"); - - let config = SlackConfig::from_env(&env); - - assert_eq!(config.signing_secret, "my-secret"); - assert_eq!(config.port, 9090); - assert_eq!(config.subject_prefix.as_str(), "slk"); - assert_eq!(config.stream_name.as_str(), "SLK_EVENTS"); - assert_eq!(config.stream_max_age, Duration::from_secs(3600)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(30)); - assert_eq!(config.timestamp_max_drift, Duration::from_secs(60)); - } - - #[test] - #[should_panic(expected = "SLACK_SIGNING_SECRET is required")] - fn missing_signing_secret_panics() { - let env = InMemoryEnv::new(); - SlackConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "SLACK_SIGNING_SECRET is required")] - fn empty_signing_secret_panics() { - let env = InMemoryEnv::new(); - env.set("SLACK_SIGNING_SECRET", ""); - SlackConfig::from_env(&env); - } - - #[test] - fn invalid_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("SLACK_WEBHOOK_PORT", "not-a-number"); - let config = SlackConfig::from_env(&env); - assert_eq!(config.port, 3000); - } - - #[test] - fn invalid_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("SLACK_STREAM_MAX_AGE_SECS", "not-a-number"); - let config = SlackConfig::from_env(&env); - assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE); - } - - #[test] - fn invalid_nats_ack_timeout_falls_back_to_default() { - let env = env_with_secret(); - env.set("SLACK_NATS_ACK_TIMEOUT_SECS", "not-a-number"); - let config = SlackConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, DEFAULT_NATS_ACK_TIMEOUT); - } - - #[test] - fn invalid_timestamp_drift_falls_back_to_default() { - let env = env_with_secret(); - env.set("SLACK_TIMESTAMP_MAX_DRIFT_SECS", "not-a-number"); - let config = SlackConfig::from_env(&env); - assert_eq!(config.timestamp_max_drift, Duration::from_secs(300)); - } - - #[test] - #[should_panic(expected = "SLACK_SUBJECT_PREFIX is not a valid NATS token")] - fn empty_subject_prefix_panics() { - let env = env_with_secret(); - env.set("SLACK_SUBJECT_PREFIX", ""); - SlackConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "SLACK_SUBJECT_PREFIX is not a valid NATS token")] - fn wildcard_subject_prefix_panics() { - let env = env_with_secret(); - env.set("SLACK_SUBJECT_PREFIX", "slack.>"); - SlackConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "SLACK_STREAM_NAME is not a valid NATS token")] - fn empty_stream_name_panics() { - let env = env_with_secret(); - env.set("SLACK_STREAM_NAME", ""); - SlackConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "SLACK_STREAM_NAME is not a valid NATS token")] - fn whitespace_stream_name_panics() { - let env = env_with_secret(); - env.set("SLACK_STREAM_NAME", "SLACK EVENTS"); - SlackConfig::from_env(&env); - } #[test] - fn zero_nats_ack_timeout_falls_back_to_default() { - let env = env_with_secret(); - env.set("SLACK_NATS_ACK_TIMEOUT_SECS", "0"); - let config = SlackConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, DEFAULT_NATS_ACK_TIMEOUT); + fn slack_signing_secret_roundtrips() { + let secret = SlackSigningSecret::new("super-secret").unwrap(); + assert_eq!(secret.as_str(), "super-secret"); } #[test] - fn zero_timestamp_drift_falls_back_to_default() { - let env = env_with_secret(); - env.set("SLACK_TIMESTAMP_MAX_DRIFT_SECS", "0"); - let config = SlackConfig::from_env(&env); - assert_eq!( - config.timestamp_max_drift, - Duration::from_secs(DEFAULT_TIMESTAMP_MAX_DRIFT_SECS) - ); + fn slack_signing_secret_debug_redacts() { + let secret = SlackSigningSecret::new("super-secret").unwrap(); + assert_eq!(format!("{secret:?}"), "SlackSigningSecret(****)"); } } diff --git a/rsworkspace/crates/trogon-source-slack/src/constants.rs b/rsworkspace/crates/trogon-source-slack/src/constants.rs index e4b1725d5..d08bb807c 100644 --- a/rsworkspace/crates/trogon-source-slack/src/constants.rs +++ b/rsworkspace/crates/trogon-source-slack/src/constants.rs @@ -1,16 +1,6 @@ -use std::time::Duration; - use trogon_std::{ByteSize, HttpBodySizeMax}; -pub const DEFAULT_PORT: u16 = 3000; -pub const DEFAULT_SUBJECT_PREFIX: &str = "slack"; -pub const DEFAULT_STREAM_NAME: &str = "SLACK"; -pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days -pub const DEFAULT_NATS_ACK_TIMEOUT: Duration = Duration::from_secs(10); -pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - pub const HTTP_BODY_SIZE_MAX: HttpBodySizeMax = HttpBodySizeMax::new(ByteSize::mib(1)).unwrap(); -pub const DEFAULT_TIMESTAMP_MAX_DRIFT_SECS: u64 = 300; // 5 minutes pub const HEADER_SIGNATURE: &str = "x-slack-signature"; pub const HEADER_TIMESTAMP: &str = "x-slack-request-timestamp"; diff --git a/rsworkspace/crates/trogon-source-slack/src/lib.rs b/rsworkspace/crates/trogon-source-slack/src/lib.rs index 7fa8c957e..911fa50de 100644 --- a/rsworkspace/crates/trogon-source-slack/src/lib.rs +++ b/rsworkspace/crates/trogon-source-slack/src/lib.rs @@ -39,7 +39,5 @@ pub mod server; pub mod signature; pub use config::SlackConfig; -pub use server::router; -#[cfg(not(coverage))] -pub use server::{ServeError, provision, serve}; +pub use server::{provision, router}; pub use signature::SignatureError; diff --git a/rsworkspace/crates/trogon-source-slack/src/main.rs b/rsworkspace/crates/trogon-source-slack/src/main.rs deleted file mode 100644 index fb27654da..000000000 --- a/rsworkspace/crates/trogon-source-slack/src/main.rs +++ /dev/null @@ -1,64 +0,0 @@ -#[cfg(not(coverage))] -use { - acp_telemetry::ServiceName, tracing::error, tracing::info, trogon_nats::connect, - trogon_nats::jetstream::ClaimCheckPublisher, trogon_nats::jetstream::MaxPayload, - trogon_nats::jetstream::NatsJetStreamClient, trogon_nats::jetstream::NatsObjectStore, - trogon_source_slack::SlackConfig, trogon_source_slack::constants::DEFAULT_NATS_CONNECT_TIMEOUT, - trogon_std::env::SystemEnv, trogon_std::fs::SystemFs, -}; - -#[cfg(not(coverage))] -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = SlackConfig::from_env(&SystemEnv); - - acp_telemetry::init_logger( - ServiceName::TrogonSourceSlack, - &config.subject_prefix, - &SystemEnv, - &SystemFs, - ); - - info!("Slack webhook server starting"); - - let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; - let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); - let js_context = async_nats::jetstream::new(nats); - let object_store = NatsObjectStore::provision( - &js_context, - async_nats::jetstream::object_store::Config { - bucket: "trogon-claims".to_string(), - ..Default::default() - }, - ) - .await?; - let client = NatsJetStreamClient::new(js_context); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store, - "trogon-claims".to_string(), - max_payload, - ); - let result = trogon_source_slack::serve(client, publisher, config).await; - - if let Err(ref e) = result { - error!(error = %e, "Slack webhook server stopped with error"); - } else { - info!("Slack webhook server stopped"); - } - - acp_telemetry::shutdown_otel(); - - result.map_err(|e| Box::new(e) as Box) -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(coverage, test))] -mod tests { - #[test] - fn coverage_stub() { - super::main(); - } -} diff --git a/rsworkspace/crates/trogon-source-slack/src/server.rs b/rsworkspace/crates/trogon-source-slack/src/server.rs index 6efc6c949..7bd27f342 100644 --- a/rsworkspace/crates/trogon-source-slack/src/server.rs +++ b/rsworkspace/crates/trogon-source-slack/src/server.rs @@ -2,67 +2,30 @@ use std::fmt; use std::time::Duration; use crate::config::SlackConfig; +use crate::config::SlackSigningSecret; use crate::constants::{ CONTENT_TYPE_FORM, HEADER_SIGNATURE, HEADER_TIMESTAMP, HTTP_BODY_SIZE_MAX, NATS_HEADER_EVENT_ID, NATS_HEADER_EVENT_TYPE, NATS_HEADER_PAYLOAD_KIND, NATS_HEADER_REJECT_REASON, NATS_HEADER_TEAM_ID, }; use crate::signature; +use trogon_std::NonZeroDuration; use trogon_std::SystemClock; use trogon_std::time::EpochClock; -#[cfg(not(coverage))] -use async_nats::jetstream::context::CreateStreamError; use axum::{ Router, body::Bytes, extract::DefaultBodyLimit, extract::State, http::HeaderMap, - http::StatusCode, routing::get, routing::post, + http::StatusCode, routing::post, }; use form_urlencoded; use std::future::Future; use std::pin::Pin; use tracing::{info, instrument, warn}; use trogon_nats::NatsToken; -#[cfg(not(coverage))] -use trogon_nats::jetstream::JetStreamContext; use trogon_nats::jetstream::{ - ClaimCheckPublisher, JetStreamPublisher, ObjectStorePut, PublishOutcome, + ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut, PublishOutcome, }; -#[cfg(not(coverage))] -#[derive(Debug)] -#[non_exhaustive] -pub enum ServeError { - Provision(CreateStreamError), - Io(std::io::Error), -} - -#[cfg(not(coverage))] -impl fmt::Display for ServeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServeError::Provision(e) => write!(f, "stream provisioning failed: {e}"), - ServeError::Io(e) => write!(f, "server IO error: {e}"), - } - } -} - -#[cfg(not(coverage))] -impl std::error::Error for ServeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServeError::Provision(e) => Some(e), - ServeError::Io(e) => Some(e), - } - } -} - -#[cfg(not(coverage))] -impl From for ServeError { - fn from(e: std::io::Error) -> Self { - ServeError::Io(e) - } -} - fn outcome_to_status(outcome: PublishOutcome) -> StatusCode { if outcome.is_ok() { info!("Published Slack event to NATS"); @@ -78,7 +41,7 @@ async fn publish_unroutable( subject_prefix: &NatsToken, reason: &str, body: Bytes, - ack_timeout: Duration, + ack_timeout: NonZeroDuration, ) { let subject = format!("{}.unroutable", subject_prefix); let mut headers = async_nats::HeaderMap::new(); @@ -86,7 +49,7 @@ async fn publish_unroutable( headers.insert(NATS_HEADER_PAYLOAD_KIND, "unroutable"); let outcome = publisher - .publish_event(subject, headers, body, ack_timeout) + .publish_event(subject, headers, body, ack_timeout.into()) .await; outcome.log_on_error("slack.unroutable"); } @@ -95,27 +58,24 @@ async fn publish_unroutable( struct AppState { publisher: ClaimCheckPublisher, clock: C, - signing_secret: String, + signing_secret: SlackSigningSecret, subject_prefix: NatsToken, - nats_ack_timeout: Duration, - timestamp_max_drift: Duration, + nats_ack_timeout: NonZeroDuration, + timestamp_max_drift: NonZeroDuration, } -#[cfg(not(coverage))] pub async fn provision(js: &C, config: &SlackConfig) -> Result<(), C::Error> { js.get_or_create_stream(async_nats::jetstream::stream::Config { name: config.stream_name.to_string(), subjects: vec![format!("{}.>", config.subject_prefix)], - max_age: config.stream_max_age, + max_age: config.stream_max_age.into(), ..Default::default() }) .await?; - let max_age_secs = config.stream_max_age.as_secs(); - info!( - stream = config.stream_name.as_str(), - max_age_secs, "JetStream stream ready" - ); + let max_age_secs = Duration::from(config.stream_max_age).as_secs(); + let stream_name = config.stream_name.as_str(); + info!(stream = stream_name, max_age_secs, "JetStream stream ready"); Ok(()) } @@ -142,43 +102,10 @@ fn router_with_clock( Router::new() .route("/webhook", post(handle_webhook::)) - .route("/health", get(handle_health)) .layer(DefaultBodyLimit::max(HTTP_BODY_SIZE_MAX.as_usize())) .with_state(state) } -#[cfg(not(coverage))] -pub async fn serve( - context: C, - publisher: ClaimCheckPublisher, - config: SlackConfig, -) -> Result<(), ServeError> -where - C: JetStreamContext, - P: JetStreamPublisher, - S: ObjectStorePut, -{ - provision(&context, &config) - .await - .map_err(ServeError::Provision)?; - - let app = router(publisher, &config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "Slack webhook server listening"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - info!("Slack webhook server shut down"); - Ok(()) -} - -async fn handle_health() -> StatusCode { - StatusCode::OK -} - fn handle_webhook( State(state): State>, headers: HeaderMap, @@ -218,7 +145,7 @@ async fn handle_webhook_inner state.timestamp_max_drift.as_secs() { + if drift > Duration::from(state.timestamp_max_drift).as_secs() { warn!(drift_secs = drift, "Slack request timestamp too old"); return (StatusCode::UNAUTHORIZED, String::new()); } @@ -228,7 +155,7 @@ async fn handle_webhook_inner( let outcome = state .publisher - .publish_event(subject, nats_headers, body.clone(), state.nats_ack_timeout) + .publish_event( + subject, + nats_headers, + body.clone(), + state.nats_ack_timeout.into(), + ) .await; (outcome_to_status(outcome), String::new()) @@ -478,7 +410,7 @@ async fn handle_interaction( subject, nats_headers, Bytes::from(payload_json.to_owned()), - state.nats_ack_timeout, + state.nats_ack_timeout.into(), ) .await; @@ -537,7 +469,7 @@ async fn handle_slash_command( subject, nats_headers, raw_body.clone(), - state.nats_ack_timeout, + state.nats_ack_timeout.into(), ) .await; @@ -553,11 +485,12 @@ mod tests { use sha2::Sha256; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; - #[cfg(not(coverage))] - use trogon_nats::jetstream::MockJetStreamContext; + use trogon_nats::jetstream::StreamMaxAge; use trogon_nats::jetstream::{ - ClaimCheckPublisher, MaxPayload, MockJetStreamPublisher, MockObjectStore, + ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, + MockObjectStore, }; + use trogon_std::NonZeroDuration; type HmacSha256 = Hmac; @@ -593,14 +526,12 @@ mod tests { fn test_config() -> SlackConfig { SlackConfig { - signing_secret: TEST_SECRET.to_string(), - port: 0, + signing_secret: SlackSigningSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("slack").unwrap(), stream_name: NatsToken::new("SLACK").unwrap(), - stream_max_age: Duration::from_secs(3600), - nats_ack_timeout: Duration::from_secs(10), - timestamp_max_drift: Duration::from_secs(300), - nats: trogon_nats::NatsConfig::from_env(&trogon_std::env::InMemoryEnv::new()), + stream_max_age: StreamMaxAge::from_secs(3600).unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + timestamp_max_drift: NonZeroDuration::from_secs(300).unwrap(), } } @@ -662,29 +593,6 @@ mod tests { builder.body(Body::from(body.to_vec())).unwrap() } - #[cfg(not(coverage))] - #[test] - fn serve_error_display_and_source() { - use async_nats::jetstream::context::{CreateStreamError, CreateStreamErrorKind}; - - let io_err = ServeError::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "port taken", - )); - assert_eq!(io_err.to_string(), "server IO error: port taken"); - assert!(std::error::Error::source(&io_err).is_some()); - - let prov_err = ServeError::Provision(CreateStreamError::new( - CreateStreamErrorKind::EmptyStreamName, - )); - assert!(prov_err.to_string().contains("stream provisioning failed")); - assert!(std::error::Error::source(&prov_err).is_some()); - - let io_err: ServeError = std::io::Error::other("boom").into(); - assert!(matches!(io_err, ServeError::Io(_))); - } - - #[cfg(not(coverage))] #[tokio::test] async fn provision_creates_stream() { let _guard = tracing_guard(); @@ -700,7 +608,6 @@ mod tests { assert_eq!(streams[0].max_age, Duration::from_secs(3600)); } - #[cfg(not(coverage))] #[tokio::test] async fn provision_propagates_error() { let _guard = tracing_guard(); @@ -712,6 +619,26 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn router_wrapper_mounts_webhook_route() { + let _guard = tracing_guard(); + let publisher = MockJetStreamPublisher::new(); + let app = router(wrap_publisher(publisher), &test_config()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/webhook") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_ne!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn event_callback_publishes_to_nats_and_returns_200() { let _guard = tracing_guard(); @@ -936,10 +863,10 @@ mod tests { let state = AppState { publisher: wrap_publisher(publisher.clone()), clock: FixedEpochClock::from_secs(TEST_NOW), - signing_secret: TEST_SECRET.to_string(), + signing_secret: SlackSigningSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("custom").unwrap(), - nats_ack_timeout: Duration::from_secs(10), - timestamp_max_drift: Duration::from_secs(300), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + timestamp_max_drift: NonZeroDuration::from_secs(300).unwrap(), }; let app = Router::new() @@ -965,21 +892,6 @@ mod tests { ); } - #[tokio::test] - async fn health_endpoint_returns_200() { - let _guard = tracing_guard(); - let app = mock_app(MockJetStreamPublisher::new()); - - let req = Request::builder() - .method("GET") - .uri("/health") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } - #[tokio::test] async fn missing_event_type_returns_400() { let _guard = tracing_guard(); @@ -1113,10 +1025,10 @@ mod tests { MaxPayload::from_server_limit(usize::MAX), ), clock: FixedEpochClock::from_secs(TEST_NOW), - signing_secret: TEST_SECRET.to_string(), + signing_secret: SlackSigningSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("slack").unwrap(), - nats_ack_timeout: Duration::from_secs(10), - timestamp_max_drift: Duration::from_secs(300), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), + timestamp_max_drift: NonZeroDuration::from_secs(300).unwrap(), }; let app = Router::new() @@ -1151,10 +1063,10 @@ mod tests { MaxPayload::from_server_limit(usize::MAX), ), clock: FixedEpochClock::from_secs(TEST_NOW), - signing_secret: TEST_SECRET.to_string(), + signing_secret: SlackSigningSecret::new(TEST_SECRET).unwrap(), subject_prefix: NatsToken::new("slack").unwrap(), - nats_ack_timeout: Duration::from_millis(10), - timestamp_max_drift: Duration::from_secs(300), + nats_ack_timeout: NonZeroDuration::from_millis(10).unwrap(), + timestamp_max_drift: NonZeroDuration::from_secs(300).unwrap(), }; let app = Router::new() @@ -1431,22 +1343,4 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); assert_unroutable(&publisher, "missing_interaction_type"); } - - #[tokio::test] - async fn router_with_system_clock_responds_to_health() { - let publisher = MockJetStreamPublisher::new(); - let app = router(wrap_publisher(publisher), &test_config()); - - let resp = app - .oneshot( - Request::builder() - .uri("/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(resp.status(), StatusCode::OK); - } } diff --git a/rsworkspace/crates/trogon-source-telegram/src/config.rs b/rsworkspace/crates/trogon-source-telegram/src/config.rs index cd446e859..acda67ecf 100644 --- a/rsworkspace/crates/trogon-source-telegram/src/config.rs +++ b/rsworkspace/crates/trogon-source-telegram/src/config.rs @@ -1,150 +1,49 @@ -use std::time::Duration; +use std::fmt; -use trogon_nats::NatsConfig; -use trogon_std::env::ReadEnv; +use trogon_nats::NatsToken; +use trogon_nats::jetstream::StreamMaxAge; +use trogon_std::{EmptySecret, NonZeroDuration, SecretString}; -use crate::constants::{ - DEFAULT_NATS_ACK_TIMEOUT, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE, DEFAULT_STREAM_NAME, - DEFAULT_SUBJECT_PREFIX, -}; +#[derive(Clone)] +pub struct TelegramWebhookSecret(SecretString); -/// Configuration for the Telegram webhook source. -/// -/// Resolved from environment variables: -/// - `TELEGRAM_WEBHOOK_SECRET`: secret token configured via `setWebhook` (**required**) -/// - `TELEGRAM_SOURCE_PORT`: HTTP listening port (default: 8080) -/// - `TELEGRAM_SUBJECT_PREFIX`: NATS subject prefix (default: `telegram`) -/// - `TELEGRAM_STREAM_NAME`: JetStream stream name (default: `TELEGRAM`) -/// - `TELEGRAM_STREAM_MAX_AGE_SECS`: max age in seconds (default: 604800 / 7 days) -/// - `TELEGRAM_NATS_ACK_TIMEOUT_SECS`: NATS ack timeout in seconds (default: 10) -/// - `TELEGRAM_MAX_BODY_SIZE`: maximum body size in bytes (default: 10 MB) -/// - Standard `NATS_*` variables for NATS connection (see `trogon-nats`) -pub struct TelegramSourceConfig { - pub webhook_secret: String, - pub port: u16, - pub subject_prefix: String, - pub stream_name: String, - pub stream_max_age: Duration, - pub nats_ack_timeout: Duration, - pub nats: NatsConfig, +impl TelegramWebhookSecret { + pub fn new(s: impl AsRef) -> Result { + SecretString::new(s).map(Self) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } } -impl TelegramSourceConfig { - pub fn from_env(env: &E) -> Self { - Self { - webhook_secret: env - .var("TELEGRAM_WEBHOOK_SECRET") - .ok() - .filter(|s| !s.is_empty()) - .expect("TELEGRAM_WEBHOOK_SECRET is required"), - port: env - .var("TELEGRAM_SOURCE_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(DEFAULT_PORT), - subject_prefix: env - .var("TELEGRAM_SUBJECT_PREFIX") - .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), - stream_name: env - .var("TELEGRAM_STREAM_NAME") - .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), - stream_max_age: env - .var("TELEGRAM_STREAM_MAX_AGE_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_STREAM_MAX_AGE), - nats_ack_timeout: env - .var("TELEGRAM_NATS_ACK_TIMEOUT_SECS") - .ok() - .and_then(|v| v.parse().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_NATS_ACK_TIMEOUT), - nats: NatsConfig::from_env(env), - } +impl fmt::Debug for TelegramWebhookSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("TelegramWebhookSecret(****)") } } +pub struct TelegramSourceConfig { + pub webhook_secret: TelegramWebhookSecret, + pub subject_prefix: NatsToken, + pub stream_name: NatsToken, + pub stream_max_age: StreamMaxAge, + pub nats_ack_timeout: NonZeroDuration, +} + #[cfg(test)] mod tests { use super::*; - use trogon_std::env::InMemoryEnv; - - fn env_with_secret() -> InMemoryEnv { - let env = InMemoryEnv::new(); - env.set("TELEGRAM_WEBHOOK_SECRET", "test-secret"); - env - } - - #[test] - fn defaults_with_required_secret() { - let env = env_with_secret(); - let config = TelegramSourceConfig::from_env(&env); - - assert_eq!(config.webhook_secret, "test-secret"); - assert_eq!(config.port, 8080); - assert_eq!(config.subject_prefix, "telegram"); - assert_eq!(config.stream_name, "TELEGRAM"); - assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(10)); - } - - #[test] - fn reads_all_env_vars() { - let env = InMemoryEnv::new(); - env.set("TELEGRAM_WEBHOOK_SECRET", "my-secret"); - env.set("TELEGRAM_SOURCE_PORT", "9090"); - env.set("TELEGRAM_SUBJECT_PREFIX", "tg"); - env.set("TELEGRAM_STREAM_NAME", "TG_EVENTS"); - env.set("TELEGRAM_STREAM_MAX_AGE_SECS", "3600"); - env.set("TELEGRAM_NATS_ACK_TIMEOUT_SECS", "30"); - - let config = TelegramSourceConfig::from_env(&env); - - assert_eq!(config.webhook_secret, "my-secret"); - assert_eq!(config.port, 9090); - assert_eq!(config.subject_prefix, "tg"); - assert_eq!(config.stream_name, "TG_EVENTS"); - assert_eq!(config.stream_max_age, Duration::from_secs(3600)); - assert_eq!(config.nats_ack_timeout, Duration::from_secs(30)); - } - - #[test] - #[should_panic(expected = "TELEGRAM_WEBHOOK_SECRET is required")] - fn missing_webhook_secret_panics() { - let env = InMemoryEnv::new(); - TelegramSourceConfig::from_env(&env); - } - - #[test] - #[should_panic(expected = "TELEGRAM_WEBHOOK_SECRET is required")] - fn empty_webhook_secret_panics() { - let env = InMemoryEnv::new(); - env.set("TELEGRAM_WEBHOOK_SECRET", ""); - TelegramSourceConfig::from_env(&env); - } - - #[test] - fn invalid_port_falls_back_to_default() { - let env = env_with_secret(); - env.set("TELEGRAM_SOURCE_PORT", "not-a-number"); - let config = TelegramSourceConfig::from_env(&env); - assert_eq!(config.port, 8080); - } #[test] - fn invalid_max_age_falls_back_to_default() { - let env = env_with_secret(); - env.set("TELEGRAM_STREAM_MAX_AGE_SECS", "not-a-number"); - let config = TelegramSourceConfig::from_env(&env); - assert_eq!(config.stream_max_age, DEFAULT_STREAM_MAX_AGE); + fn telegram_webhook_secret_roundtrips() { + let secret = TelegramWebhookSecret::new("super-secret").unwrap(); + assert_eq!(secret.as_str(), "super-secret"); } #[test] - fn invalid_nats_ack_timeout_falls_back_to_default() { - let env = env_with_secret(); - env.set("TELEGRAM_NATS_ACK_TIMEOUT_SECS", "not-a-number"); - let config = TelegramSourceConfig::from_env(&env); - assert_eq!(config.nats_ack_timeout, DEFAULT_NATS_ACK_TIMEOUT); + fn telegram_webhook_secret_debug_redacts() { + let secret = TelegramWebhookSecret::new("super-secret").unwrap(); + assert_eq!(format!("{secret:?}"), "TelegramWebhookSecret(****)"); } } diff --git a/rsworkspace/crates/trogon-source-telegram/src/constants.rs b/rsworkspace/crates/trogon-source-telegram/src/constants.rs index 003f2679d..b95c14a1a 100644 --- a/rsworkspace/crates/trogon-source-telegram/src/constants.rs +++ b/rsworkspace/crates/trogon-source-telegram/src/constants.rs @@ -1,14 +1,5 @@ -use std::time::Duration; - use trogon_std::{ByteSize, HttpBodySizeMax}; -pub const DEFAULT_PORT: u16 = 8080; -pub const DEFAULT_SUBJECT_PREFIX: &str = "telegram"; -pub const DEFAULT_STREAM_NAME: &str = "TELEGRAM"; -pub const DEFAULT_STREAM_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days -pub const DEFAULT_NATS_ACK_TIMEOUT: Duration = Duration::from_secs(10); -pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); - pub const HTTP_BODY_SIZE_MAX: HttpBodySizeMax = HttpBodySizeMax::new(ByteSize::mib(10)).unwrap(); pub const HEADER_SECRET_TOKEN: &str = "x-telegram-bot-api-secret-token"; diff --git a/rsworkspace/crates/trogon-source-telegram/src/lib.rs b/rsworkspace/crates/trogon-source-telegram/src/lib.rs index 07c6ed854..cb81b42ed 100644 --- a/rsworkspace/crates/trogon-source-telegram/src/lib.rs +++ b/rsworkspace/crates/trogon-source-telegram/src/lib.rs @@ -37,7 +37,5 @@ pub mod server; pub mod signature; pub use config::TelegramSourceConfig; -#[cfg(not(coverage))] -pub use server::{ServeError, serve}; pub use server::{provision, router}; pub use signature::SignatureError; diff --git a/rsworkspace/crates/trogon-source-telegram/src/main.rs b/rsworkspace/crates/trogon-source-telegram/src/main.rs deleted file mode 100644 index f0166e5ce..000000000 --- a/rsworkspace/crates/trogon-source-telegram/src/main.rs +++ /dev/null @@ -1,65 +0,0 @@ -#[cfg(not(coverage))] -use { - acp_telemetry::ServiceName, tracing::error, tracing::info, trogon_nats::connect, - trogon_nats::jetstream::ClaimCheckPublisher, trogon_nats::jetstream::MaxPayload, - trogon_nats::jetstream::NatsJetStreamClient, trogon_nats::jetstream::NatsObjectStore, - trogon_source_telegram::TelegramSourceConfig, - trogon_source_telegram::constants::DEFAULT_NATS_CONNECT_TIMEOUT, trogon_std::env::SystemEnv, - trogon_std::fs::SystemFs, -}; - -#[cfg(not(coverage))] -#[tokio::main] -async fn main() -> Result<(), Box> { - let config = TelegramSourceConfig::from_env(&SystemEnv); - - acp_telemetry::init_logger( - ServiceName::TrogonSourceTelegram, - &config.subject_prefix, - &SystemEnv, - &SystemFs, - ); - - info!("Telegram webhook source starting"); - - let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; - let max_payload = MaxPayload::from_server_limit(nats.server_info().max_payload); - let js_context = async_nats::jetstream::new(nats); - let object_store = NatsObjectStore::provision( - &js_context, - async_nats::jetstream::object_store::Config { - bucket: "trogon-claims".to_string(), - ..Default::default() - }, - ) - .await?; - let client = NatsJetStreamClient::new(js_context); - let publisher = ClaimCheckPublisher::new( - client.clone(), - object_store, - "trogon-claims".to_string(), - max_payload, - ); - let result = trogon_source_telegram::serve(client, publisher, config).await; - - if let Err(ref e) = result { - error!(error = %e, "Telegram webhook source stopped with error"); - } else { - info!("Telegram webhook source stopped"); - } - - acp_telemetry::shutdown_otel(); - - result.map_err(|e| Box::new(e) as Box) -} - -#[cfg(coverage)] -fn main() {} - -#[cfg(all(coverage, test))] -mod tests { - #[test] - fn coverage_stub() { - super::main(); - } -} diff --git a/rsworkspace/crates/trogon-source-telegram/src/server.rs b/rsworkspace/crates/trogon-source-telegram/src/server.rs index e0e28191e..e252c9f5b 100644 --- a/rsworkspace/crates/trogon-source-telegram/src/server.rs +++ b/rsworkspace/crates/trogon-source-telegram/src/server.rs @@ -1,59 +1,24 @@ -use std::fmt; -use std::time::Duration; - use crate::config::TelegramSourceConfig; +use crate::config::TelegramWebhookSecret; use crate::constants::{ HEADER_SECRET_TOKEN, HTTP_BODY_SIZE_MAX, NATS_HEADER_UPDATE_ID, NATS_HEADER_UPDATE_TYPE, UPDATE_TYPES, }; use crate::signature; -#[cfg(not(coverage))] -use async_nats::jetstream::context::CreateStreamError; use axum::{ Router, body::Bytes, extract::DefaultBodyLimit, extract::State, http::HeaderMap, - http::StatusCode, routing::get, routing::post, + http::StatusCode, routing::post, }; +use std::fmt; use std::future::Future; use std::pin::Pin; +use std::time::Duration; use tracing::{info, instrument, warn}; +use trogon_nats::NatsToken; use trogon_nats::jetstream::{ ClaimCheckPublisher, JetStreamContext, JetStreamPublisher, ObjectStorePut, PublishOutcome, }; - -#[cfg(not(coverage))] -#[derive(Debug)] -#[non_exhaustive] -pub enum ServeError { - Provision(CreateStreamError), - Io(std::io::Error), -} - -#[cfg(not(coverage))] -impl fmt::Display for ServeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ServeError::Provision(e) => write!(f, "stream provisioning failed: {e}"), - ServeError::Io(e) => write!(f, "server IO error: {e}"), - } - } -} - -#[cfg(not(coverage))] -impl std::error::Error for ServeError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ServeError::Provision(e) => Some(e), - ServeError::Io(e) => Some(e), - } - } -} - -#[cfg(not(coverage))] -impl From for ServeError { - fn from(e: std::io::Error) -> Self { - ServeError::Io(e) - } -} +use trogon_std::NonZeroDuration; fn outcome_to_status(outcome: PublishOutcome) -> StatusCode { if outcome.is_ok() { @@ -68,9 +33,9 @@ fn outcome_to_status(outcome: PublishOutcome) -> StatusCode #[derive(Clone)] struct AppState { publisher: ClaimCheckPublisher, - webhook_secret: String, - subject_prefix: String, - nats_ack_timeout: Duration, + webhook_secret: TelegramWebhookSecret, + subject_prefix: NatsToken, + nats_ack_timeout: NonZeroDuration, } pub async fn provision( @@ -78,18 +43,16 @@ pub async fn provision( config: &TelegramSourceConfig, ) -> Result<(), C::Error> { js.get_or_create_stream(async_nats::jetstream::stream::Config { - name: config.stream_name.clone(), + name: config.stream_name.as_str().to_owned(), subjects: vec![format!("{}.>", config.subject_prefix)], - max_age: config.stream_max_age, + max_age: config.stream_max_age.into(), ..Default::default() }) .await?; - let max_age_secs = config.stream_max_age.as_secs(); - info!( - stream = config.stream_name, - max_age_secs, "JetStream stream ready" - ); + let stream = config.stream_name.as_str(); + let max_age_secs = Duration::from(config.stream_max_age).as_secs(); + info!(stream, max_age_secs, "JetStream stream ready"); Ok(()) } @@ -106,43 +69,10 @@ pub fn router( Router::new() .route("/webhook", post(handle_webhook::)) - .route("/health", get(handle_health)) .layer(DefaultBodyLimit::max(HTTP_BODY_SIZE_MAX.as_usize())) .with_state(state) } -#[cfg(not(coverage))] -pub async fn serve( - context: C, - publisher: ClaimCheckPublisher, - config: TelegramSourceConfig, -) -> Result<(), ServeError> -where - C: JetStreamContext, - P: JetStreamPublisher, - S: ObjectStorePut, -{ - provision(&context, &config) - .await - .map_err(ServeError::Provision)?; - - let app = router(publisher, &config); - let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); - info!(addr = %addr, "Telegram webhook source listening"); - - let listener = tokio::net::TcpListener::bind(addr).await?; - axum::serve(listener, app) - .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) - .await?; - - info!("Telegram webhook source shut down"); - Ok(()) -} - -async fn handle_health() -> StatusCode { - StatusCode::OK -} - fn handle_webhook( State(state): State>, headers: HeaderMap, @@ -178,7 +108,7 @@ async fn handle_webhook_inner( .get(HEADER_SECRET_TOKEN) .and_then(|v| v.to_str().ok()); - if let Err(e) = signature::verify(&state.webhook_secret, token) { + if let Err(e) = signature::verify(state.webhook_secret.as_str(), token) { warn!(reason = %e, "Telegram webhook secret validation failed"); return StatusCode::UNAUTHORIZED; } @@ -215,7 +145,7 @@ async fn handle_webhook_inner( let outcome = state .publisher - .publish_event(subject, nats_headers, body, state.nats_ack_timeout) + .publish_event(subject, nats_headers, body, state.nats_ack_timeout.into()) .await; outcome_to_status(outcome) @@ -226,24 +156,25 @@ mod tests { use super::*; use axum::body::Body; use axum::http::Request; + use std::time::Duration; use tower::ServiceExt; use tracing_subscriber::util::SubscriberInitExt; + use trogon_nats::jetstream::StreamMaxAge; use trogon_nats::jetstream::{ ClaimCheckPublisher, MaxPayload, MockJetStreamContext, MockJetStreamPublisher, MockObjectStore, }; + use trogon_std::NonZeroDuration; const TEST_SECRET: &str = "test-secret"; fn test_config() -> TelegramSourceConfig { TelegramSourceConfig { - webhook_secret: TEST_SECRET.to_string(), - port: 0, - subject_prefix: "telegram".to_string(), - stream_name: "TELEGRAM".to_string(), - stream_max_age: Duration::from_secs(3600), - nats_ack_timeout: Duration::from_secs(10), - nats: trogon_nats::NatsConfig::from_env(&trogon_std::env::InMemoryEnv::new()), + webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("telegram").unwrap(), + stream_name: NatsToken::new("TELEGRAM").unwrap(), + stream_max_age: StreamMaxAge::from_secs(3600).unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), } } @@ -284,28 +215,6 @@ mod tests { .unwrap() } - #[cfg(not(coverage))] - #[test] - fn serve_error_display_and_source() { - use async_nats::jetstream::context::{CreateStreamError, CreateStreamErrorKind}; - - let io_err = ServeError::Io(std::io::Error::new( - std::io::ErrorKind::AddrInUse, - "port taken", - )); - assert_eq!(io_err.to_string(), "server IO error: port taken"); - assert!(std::error::Error::source(&io_err).is_some()); - - let prov_err = ServeError::Provision(CreateStreamError::new( - CreateStreamErrorKind::EmptyStreamName, - )); - assert!(prov_err.to_string().contains("stream provisioning failed")); - assert!(std::error::Error::source(&prov_err).is_some()); - - let io_err: ServeError = std::io::Error::other("boom").into(); - assert!(matches!(io_err, ServeError::Io(_))); - } - #[tokio::test] async fn provision_creates_stream() { let _guard = tracing_guard(); @@ -497,9 +406,9 @@ mod tests { let state = AppState { publisher: wrap_publisher(publisher.clone()), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "custom".to_string(), - nats_ack_timeout: Duration::from_secs(10), + webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("custom").unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -520,21 +429,6 @@ mod tests { assert_eq!(publisher.published_subjects(), vec!["custom.message"]); } - #[tokio::test] - async fn health_endpoint_returns_200() { - let _guard = tracing_guard(); - let app = mock_app(MockJetStreamPublisher::new()); - - let req = Request::builder() - .method("GET") - .uri("/health") - .body(Body::empty()) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } - #[tokio::test] async fn update_id_used_as_nats_message_id() { let _guard = tracing_guard(); @@ -566,9 +460,9 @@ mod tests { let state = AppState { publisher: wrap_publisher(publisher.clone()), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "telegram".to_string(), - nats_ack_timeout: Duration::from_secs(10), + webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("telegram").unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -693,9 +587,9 @@ mod tests { "test-bucket".to_string(), MaxPayload::from_server_limit(usize::MAX), ), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "telegram".to_string(), - nats_ack_timeout: Duration::from_secs(10), + webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("telegram").unwrap(), + nats_ack_timeout: NonZeroDuration::from_secs(10).unwrap(), }; let app = Router::new() @@ -727,9 +621,9 @@ mod tests { "test-bucket".to_string(), MaxPayload::from_server_limit(usize::MAX), ), - webhook_secret: TEST_SECRET.to_string(), - subject_prefix: "telegram".to_string(), - nats_ack_timeout: Duration::from_millis(10), + webhook_secret: TelegramWebhookSecret::new(TEST_SECRET).unwrap(), + subject_prefix: NatsToken::new("telegram").unwrap(), + nats_ack_timeout: NonZeroDuration::from_millis(10).unwrap(), }; let app = Router::new() diff --git a/rsworkspace/crates/trogon-std/src/dirs/system.rs b/rsworkspace/crates/trogon-std/src/dirs/system.rs index 5fd8f1c14..ebebad217 100644 --- a/rsworkspace/crates/trogon-std/src/dirs/system.rs +++ b/rsworkspace/crates/trogon-std/src/dirs/system.rs @@ -53,50 +53,73 @@ fn non_empty_var(name: &str) -> Option { } fn home_dir_impl() -> Option { - if cfg!(target_os = "windows") { + #[cfg(target_os = "windows")] + { non_empty_var("USERPROFILE") - } else { + } + #[cfg(not(target_os = "windows"))] + { non_empty_var("HOME") } } fn config_dir_impl() -> Option { - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { home_dir_impl().map(|h| h.join("Library").join("Application Support")) - } else if cfg!(target_os = "windows") { + } + #[cfg(target_os = "windows")] + { non_empty_var("APPDATA") - } else { + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { non_empty_var("XDG_CONFIG_HOME").or_else(|| home_dir_impl().map(|h| h.join(".config"))) } } fn cache_dir_impl() -> Option { - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { home_dir_impl().map(|h| h.join("Library").join("Caches")) - } else if cfg!(target_os = "windows") { + } + #[cfg(target_os = "windows")] + { non_empty_var("LOCALAPPDATA") - } else { + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { non_empty_var("XDG_CACHE_HOME").or_else(|| home_dir_impl().map(|h| h.join(".cache"))) } } fn data_dir_impl() -> Option { - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { home_dir_impl().map(|h| h.join("Library").join("Application Support")) - } else if cfg!(target_os = "windows") { + } + #[cfg(target_os = "windows")] + { non_empty_var("APPDATA") - } else { + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { non_empty_var("XDG_DATA_HOME") .or_else(|| home_dir_impl().map(|h| h.join(".local").join("share"))) } } fn data_local_dir_impl() -> Option { - if cfg!(target_os = "macos") { + #[cfg(target_os = "macos")] + { home_dir_impl().map(|h| h.join("Library").join("Application Support")) - } else if cfg!(target_os = "windows") { + } + #[cfg(target_os = "windows")] + { non_empty_var("LOCALAPPDATA") - } else { + } + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { non_empty_var("XDG_DATA_HOME") .or_else(|| home_dir_impl().map(|h| h.join(".local").join("share"))) } @@ -105,9 +128,12 @@ fn data_local_dir_impl() -> Option { // macOS and Windows have no native state directory concept — returning None // avoids silently aliasing ~/Library/Application Support or LOCALAPPDATA. fn state_dir_impl() -> Option { - if cfg!(target_os = "macos") || cfg!(target_os = "windows") { + #[cfg(any(target_os = "macos", target_os = "windows"))] + { None - } else { + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { non_empty_var("XDG_STATE_HOME") .or_else(|| home_dir_impl().map(|h| h.join(".local").join("state"))) } @@ -148,13 +174,17 @@ mod tests { } #[test] - fn state_dir_depends_on_platform() { + #[cfg(any(target_os = "macos", target_os = "windows"))] + fn state_dir_is_none_on_this_platform() { let dirs = SystemDirs; - if cfg!(target_os = "macos") || cfg!(target_os = "windows") { - assert!(dirs.state_dir().is_none()); - } else { - assert!(dirs.state_dir().is_some()); - } + assert!(dirs.state_dir().is_none()); + } + + #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + fn state_dir_is_some_on_this_platform() { + let dirs = SystemDirs; + assert!(dirs.state_dir().is_some()); } #[test] diff --git a/rsworkspace/crates/trogon-std/src/duration.rs b/rsworkspace/crates/trogon-std/src/duration.rs new file mode 100644 index 000000000..631647958 --- /dev/null +++ b/rsworkspace/crates/trogon-std/src/duration.rs @@ -0,0 +1,84 @@ +use std::fmt; +use std::time::Duration; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct NonZeroDuration(Duration); + +#[derive(Debug, PartialEq, Eq)] +pub struct ZeroDuration; + +impl fmt::Display for ZeroDuration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("duration must not be zero") + } +} + +impl std::error::Error for ZeroDuration {} + +impl NonZeroDuration { + pub fn from_secs(secs: u64) -> Result { + if secs == 0 { + return Err(ZeroDuration); + } + Ok(Self(Duration::from_secs(secs))) + } + + pub fn from_millis(millis: u64) -> Result { + if millis == 0 { + return Err(ZeroDuration); + } + Ok(Self(Duration::from_millis(millis))) + } +} + +impl From for Duration { + fn from(d: NonZeroDuration) -> Self { + d.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_secs_valid() { + let d = NonZeroDuration::from_secs(10).unwrap(); + assert_eq!(Duration::from(d), Duration::from_secs(10)); + } + + #[test] + fn from_secs_zero_rejected() { + assert!(matches!(NonZeroDuration::from_secs(0), Err(ZeroDuration))); + } + + #[test] + fn from_millis_valid() { + let d = NonZeroDuration::from_millis(500).unwrap(); + assert_eq!(Duration::from(d), Duration::from_millis(500)); + } + + #[test] + fn from_millis_zero_rejected() { + assert!(matches!(NonZeroDuration::from_millis(0), Err(ZeroDuration))); + } + + #[test] + fn error_display() { + assert_eq!(ZeroDuration.to_string(), "duration must not be zero"); + } + + #[test] + fn copy_semantics() { + let a = NonZeroDuration::from_secs(5).unwrap(); + let b = a; + assert_eq!(a, b); + } + + #[test] + fn ordering() { + let short = NonZeroDuration::from_secs(1).unwrap(); + let long = NonZeroDuration::from_secs(10).unwrap(); + assert!(short < long); + } +} diff --git a/rsworkspace/crates/trogon-std/src/lib.rs b/rsworkspace/crates/trogon-std/src/lib.rs index dbcfa2256..93f0c4fee 100644 --- a/rsworkspace/crates/trogon-std/src/lib.rs +++ b/rsworkspace/crates/trogon-std/src/lib.rs @@ -32,10 +32,12 @@ pub mod args; pub mod dirs; +pub mod duration; pub mod env; pub mod fs; pub mod http; pub mod json; +pub mod secret_string; pub mod time; #[cfg(all(feature = "clap", not(coverage)))] @@ -44,10 +46,12 @@ pub use args::CliArgs; pub use args::FixedArgs; pub use args::ParseArgs; pub use dirs::{CacheDir, ConfigDir, DataDir, DataLocalDir, HomeDir, StateDir, SystemDirs}; +pub use duration::{NonZeroDuration, ZeroDuration}; pub use env::{ReadEnv, SystemEnv}; pub use fs::{CreateDirAll, ExistsFile, OpenAppendFile, ReadFile, SystemFs, WriteFile}; pub use http::{ByteSize, HttpBodySizeMax}; #[cfg(any(test, feature = "test-support"))] pub use json::FailNextSerialize; pub use json::{JsonSerialize, StdJsonSerialize}; +pub use secret_string::{EmptySecret, SecretString}; pub use time::{EpochClock, GetElapsed, GetNow, SystemClock}; diff --git a/rsworkspace/crates/trogon-std/src/secret_string.rs b/rsworkspace/crates/trogon-std/src/secret_string.rs new file mode 100644 index 000000000..d0f6450d4 --- /dev/null +++ b/rsworkspace/crates/trogon-std/src/secret_string.rs @@ -0,0 +1,70 @@ +use std::fmt; +use std::sync::Arc; + +#[derive(Clone)] +pub struct SecretString(Arc); + +#[derive(Debug, PartialEq, Eq)] +pub struct EmptySecret; + +impl fmt::Display for EmptySecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("secret must not be empty") + } +} + +impl std::error::Error for EmptySecret {} + +impl SecretString { + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() { + return Err(EmptySecret); + } + Ok(Self(Arc::from(s))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretString(***)") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_secret() { + let secret = SecretString::new("my-secret").unwrap(); + assert_eq!(secret.as_str(), "my-secret"); + } + + #[test] + fn empty_secret_rejected() { + assert!(matches!(SecretString::new(""), Err(EmptySecret))); + } + + #[test] + fn debug_redacts_value() { + let secret = SecretString::new("super-secret").unwrap(); + assert_eq!(format!("{secret:?}"), "SecretString(***)"); + } + + #[test] + fn clone_shares_arc() { + let a = SecretString::new("secret").unwrap(); + let b = a.clone(); + assert_eq!(a.as_str(), b.as_str()); + } + + #[test] + fn error_display() { + assert_eq!(EmptySecret.to_string(), "secret must not be empty"); + } +} diff --git a/rsworkspace/crates/trogon-std/src/time/system.rs b/rsworkspace/crates/trogon-std/src/time/system.rs index 82b22a25f..a92f2e92a 100644 --- a/rsworkspace/crates/trogon-std/src/time/system.rs +++ b/rsworkspace/crates/trogon-std/src/time/system.rs @@ -43,6 +43,16 @@ mod tests { assert!(elapsed < Duration::from_secs(1)); } + #[test] + fn system_time_returns_recent_epoch() { + let clock = SystemClock; + let st = clock.system_time(); + let elapsed = st + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("system time before UNIX epoch"); + assert!(elapsed.as_secs() > 1_700_000_000); + } + #[test] fn test_generic_function_with_system_clock() { fn is_expired(