ScuttleBot
feat: Docker Compose deployment + external ergo mode - deploy/compose: ergo + scuttlebot + postgres stack with named volumes, health checks, and envsubst-rendered ergo config from template - deploy/docker/Dockerfile: multi-stage Go build for scuttlebot binary - config: add Ergo.External flag + ApplyEnv() for SCUTTLEBOT_* env vars - ergo/manager: external mode skips subprocess, waits for health then blocks - main.go: call cfg.ApplyEnv(), use cfg.APIAddr for HTTP server Closes #8
Commit
2d8a379990b4963912b5af5065f04e1a14052f4d396b2f4b9924da01ca48f7e6
Parent
b781baa40cb6bb7…
18 files changed
+2
+2
-1
+13
+75
+25
+73
+11
+13
+82
+19
+46
+206
+32
+16
+39
+25
+63
-1
+24
-4
+
.gitignore
~
cmd/scuttlebot/main.go
+
deploy/compose/.env.example
+
deploy/compose/README.md
+
deploy/compose/docker-compose.override.yml
+
deploy/compose/docker-compose.yml
+
deploy/compose/ergo/Dockerfile
+
deploy/compose/ergo/entrypoint.sh
+
deploy/compose/ergo/ircd.yaml.tmpl
+
deploy/docker/Dockerfile
+
internal/api/agents.go
+
internal/api/api_test.go
+
internal/api/middleware.go
+
internal/api/respond.go
+
internal/api/server.go
+
internal/api/status.go
~
internal/config/config.go
~
internal/ergo/manager.go
+2
| --- a/.gitignore | ||
| +++ b/.gitignore | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +data/ | |
| 2 | +deploy/compose/.env |
| --- a/.gitignore | |
| +++ b/.gitignore | |
| @@ -0,0 +1,2 @@ | |
| --- a/.gitignore | |
| +++ b/.gitignore | |
| @@ -0,0 +1,2 @@ | |
| 1 | data/ |
| 2 | deploy/compose/.env |
+2
-1
| --- cmd/scuttlebot/main.go | ||
| +++ cmd/scuttlebot/main.go | ||
| @@ -23,10 +23,11 @@ | ||
| 23 | 23 | func main() { |
| 24 | 24 | log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) |
| 25 | 25 | |
| 26 | 26 | cfg := &config.Config{} |
| 27 | 27 | cfg.Defaults() |
| 28 | + cfg.ApplyEnv() | |
| 28 | 29 | |
| 29 | 30 | // Generate an API token for the Ergo management API if not set. |
| 30 | 31 | if cfg.Ergo.APIToken == "" { |
| 31 | 32 | cfg.Ergo.APIToken = mustGenToken() |
| 32 | 33 | } |
| @@ -71,11 +72,11 @@ | ||
| 71 | 72 | // Start HTTP API server. |
| 72 | 73 | apiToken := mustGenToken() |
| 73 | 74 | log.Info("api token", "token", apiToken) // printed once on startup — user copies this |
| 74 | 75 | apiSrv := api.New(reg, []string{apiToken}, log) |
| 75 | 76 | httpServer := &http.Server{ |
| 76 | - Addr: ":8080", | |
| 77 | + Addr: cfg.APIAddr, | |
| 77 | 78 | Handler: apiSrv.Handler(), |
| 78 | 79 | } |
| 79 | 80 | |
| 80 | 81 | go func() { |
| 81 | 82 | log.Info("api server listening", "addr", httpServer.Addr) |
| 82 | 83 | |
| 83 | 84 | ADDED deploy/compose/.env.example |
| 84 | 85 | ADDED deploy/compose/README.md |
| 85 | 86 | ADDED deploy/compose/docker-compose.override.yml |
| 86 | 87 | ADDED deploy/compose/docker-compose.yml |
| 87 | 88 | ADDED deploy/compose/ergo/Dockerfile |
| 88 | 89 | ADDED deploy/compose/ergo/entrypoint.sh |
| 89 | 90 | ADDED deploy/compose/ergo/ircd.yaml.tmpl |
| 90 | 91 | ADDED deploy/docker/Dockerfile |
| 91 | 92 | ADDED internal/api/agents.go |
| 92 | 93 | ADDED internal/api/api_test.go |
| 93 | 94 | ADDED internal/api/middleware.go |
| 94 | 95 | ADDED internal/api/respond.go |
| 95 | 96 | ADDED internal/api/server.go |
| 96 | 97 | ADDED internal/api/status.go |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -23,10 +23,11 @@ | |
| 23 | func main() { |
| 24 | log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) |
| 25 | |
| 26 | cfg := &config.Config{} |
| 27 | cfg.Defaults() |
| 28 | |
| 29 | // Generate an API token for the Ergo management API if not set. |
| 30 | if cfg.Ergo.APIToken == "" { |
| 31 | cfg.Ergo.APIToken = mustGenToken() |
| 32 | } |
| @@ -71,11 +72,11 @@ | |
| 71 | // Start HTTP API server. |
| 72 | apiToken := mustGenToken() |
| 73 | log.Info("api token", "token", apiToken) // printed once on startup — user copies this |
| 74 | apiSrv := api.New(reg, []string{apiToken}, log) |
| 75 | httpServer := &http.Server{ |
| 76 | Addr: ":8080", |
| 77 | Handler: apiSrv.Handler(), |
| 78 | } |
| 79 | |
| 80 | go func() { |
| 81 | log.Info("api server listening", "addr", httpServer.Addr) |
| 82 | |
| 83 | DDED deploy/compose/.env.example |
| 84 | DDED deploy/compose/README.md |
| 85 | DDED deploy/compose/docker-compose.override.yml |
| 86 | DDED deploy/compose/docker-compose.yml |
| 87 | DDED deploy/compose/ergo/Dockerfile |
| 88 | DDED deploy/compose/ergo/entrypoint.sh |
| 89 | DDED deploy/compose/ergo/ircd.yaml.tmpl |
| 90 | DDED deploy/docker/Dockerfile |
| 91 | DDED internal/api/agents.go |
| 92 | DDED internal/api/api_test.go |
| 93 | DDED internal/api/middleware.go |
| 94 | DDED internal/api/respond.go |
| 95 | DDED internal/api/server.go |
| 96 | DDED internal/api/status.go |
| --- cmd/scuttlebot/main.go | |
| +++ cmd/scuttlebot/main.go | |
| @@ -23,10 +23,11 @@ | |
| 23 | func main() { |
| 24 | log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) |
| 25 | |
| 26 | cfg := &config.Config{} |
| 27 | cfg.Defaults() |
| 28 | cfg.ApplyEnv() |
| 29 | |
| 30 | // Generate an API token for the Ergo management API if not set. |
| 31 | if cfg.Ergo.APIToken == "" { |
| 32 | cfg.Ergo.APIToken = mustGenToken() |
| 33 | } |
| @@ -71,11 +72,11 @@ | |
| 72 | // Start HTTP API server. |
| 73 | apiToken := mustGenToken() |
| 74 | log.Info("api token", "token", apiToken) // printed once on startup — user copies this |
| 75 | apiSrv := api.New(reg, []string{apiToken}, log) |
| 76 | httpServer := &http.Server{ |
| 77 | Addr: cfg.APIAddr, |
| 78 | Handler: apiSrv.Handler(), |
| 79 | } |
| 80 | |
| 81 | go func() { |
| 82 | log.Info("api server listening", "addr", httpServer.Addr) |
| 83 | |
| 84 | DDED deploy/compose/.env.example |
| 85 | DDED deploy/compose/README.md |
| 86 | DDED deploy/compose/docker-compose.override.yml |
| 87 | DDED deploy/compose/docker-compose.yml |
| 88 | DDED deploy/compose/ergo/Dockerfile |
| 89 | DDED deploy/compose/ergo/entrypoint.sh |
| 90 | DDED deploy/compose/ergo/ircd.yaml.tmpl |
| 91 | DDED deploy/docker/Dockerfile |
| 92 | DDED internal/api/agents.go |
| 93 | DDED internal/api/api_test.go |
| 94 | DDED internal/api/middleware.go |
| 95 | DDED internal/api/respond.go |
| 96 | DDED internal/api/server.go |
| 97 | DDED internal/api/status.go |
| --- a/deploy/compose/.env.example | ||
| +++ b/deploy/compose/.env.example | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Copy to .env and fill in values before running docker compose up. | |
| 2 | +# .env is gitignored — never commit it. | |
| 3 | + | |
| 4 | +# Required: a random bearer token shared between ergo and scuttlebot. | |
| 5 | +# Generate one: openssl rand -hex 32 | |
| 6 | +ERGO_API_TOKEN=change-me-use-openssl-rand-hex-32 | |
| 7 | + | |
| 8 | +# Optional overrides (defaults shown). | |
| 9 | +# POSTGRES_PASSWORD=scuttlebot | |
| 10 | +# ERGO_NETWORK_NAME=scuttlebot | |
| 11 | +# ERGO_SERVER_NAME=irc.scuttlebot.local | |
| 12 | +# IRC_PORT=6667 | |
| 13 | +# API_PORT=8080 |
| --- a/deploy/compose/.env.example | |
| +++ b/deploy/compose/.env.example | |
| @@ -0,0 +1,13 @@ | |
| --- a/deploy/compose/.env.example | |
| +++ b/deploy/compose/.env.example | |
| @@ -0,0 +1,13 @@ | |
| 1 | # Copy to .env and fill in values before running docker compose up. |
| 2 | # .env is gitignored — never commit it. |
| 3 | |
| 4 | # Required: a random bearer token shared between ergo and scuttlebot. |
| 5 | # Generate one: openssl rand -hex 32 |
| 6 | ERGO_API_TOKEN=change-me-use-openssl-rand-hex-32 |
| 7 | |
| 8 | # Optional overrides (defaults shown). |
| 9 | # POSTGRES_PASSWORD=scuttlebot |
| 10 | # ERGO_NETWORK_NAME=scuttlebot |
| 11 | # ERGO_SERVER_NAME=irc.scuttlebot.local |
| 12 | # IRC_PORT=6667 |
| 13 | # API_PORT=8080 |
+75
| --- a/deploy/compose/README.md | ||
| +++ b/deploy/compose/README.md | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +# Docker Compose deployment | |
| 2 | + | |
| 3 | +Three-service stack: **ergo** (IRC server) + **scuttlebot** (daemon) + **postgres** (state + IRC history). | |
| 4 | + | |
| 5 | +## Quick start | |
| 6 | + | |
| 7 | +```sh | |
| 8 | +cd deploy/compose | |
| 9 | + | |
| 10 | +# 1. Create your .env | |
| 11 | +cp .env.example .env | |
| 12 | +# Edit .env — set ERGO_API_TOKEN to a random secret. | |
| 13 | +# Generate one: openssl rand -hex 32 | |
| 14 | + | |
| 15 | +# 2. Boot | |
| 16 | +docker compose up --build | |
| 17 | + | |
| 18 | +# 3. The scuttlebot API token is printed in scuttlebot's startup logs. | |
| 19 | +docker compose logs scuttlebot | grep "api token" | |
| 20 | +``` | |
| 21 | + | |
| 22 | +## Services | |
| 23 | + | |
| 24 | +| Service | Internal port | Published by default | Notes | | |
| 25 | +|-------------|---------------|----------------------|-------| | |
| 26 | +| postgres | 5432 | No (override: yes) | State store + ergo IRC history | | |
| 27 | +| ergo | 6667 (IRC) | Yes | IRC server | | |
| 28 | +| ergo | 8089 (API) | No (override: yes) | Ergo management API | | |
| 29 | +| scuttlebot | 8080 | Yes | REST management API | | |
| 30 | + | |
| 31 | +## Environment variables | |
| 32 | + | |
| 33 | +See `.env.example` for the full list. Only `ERGO_API_TOKEN` is required — everything else has a default. | |
| 34 | + | |
| 35 | +## Persistence | |
| 36 | + | |
| 37 | +Data survives container restarts via named Docker volumes: | |
| 38 | + | |
| 39 | +- `ergo_data` — Ergo's embedded database (`ircd.db`) | |
| 40 | +- `postgres_data` — Postgres data directory | |
| 41 | + | |
| 42 | +To reset completely: `docker compose down -v` | |
| 43 | + | |
| 44 | +## Local dev overrides | |
| 45 | + | |
| 46 | +`docker-compose.override.yml` is applied automatically and: | |
| 47 | +- Exposes postgres on `5432` and the ergo API on `8089` to localhost | |
| 48 | +- Disables ergo persistent history (faster startup) | |
| 49 | +- Sets debug log level on scuttlebot | |
| 50 | + | |
| 51 | +To run without overrides (production-like): | |
| 52 | + | |
| 53 | +```sh | |
| 54 | +docker compose -f docker-compose.yml up | |
| 55 | +``` | |
| 56 | + | |
| 57 | +## Architecture | |
| 58 | + | |
| 59 | +``` | |
| 60 | + ┌─────────────┐ | |
| 61 | + IRC clients │ ergo │ :6667 | |
| 62 | + ───────────>│ IRC server │ | |
| 63 | + └──────┬──────┘ | |
| 64 | + │ HTTP API :8089 | |
| 65 | + ┌──────▼──────┐ | |
| 66 | + │ scuttlebot │ :8080 | |
| 67 | + │ daemon │<──── REST API (agent registration, etc.) | |
| 68 | + └──────┬──────┘ | |
| 69 | + │ | |
| 70 | + ┌──────▼──────┐ | |
| 71 | + │ postgres │ :5432 | |
| 72 | + └─────────────┘ | |
| 73 | +``` | |
| 74 | + | |
| 75 | +Ergo runs as a separate container. Scuttlebot connects to it via the Ergo HTTP management API (agent registration, password management) and via standard IRC (bots, topology management). The `SCUTTLEBOT_ERGO_EXTERNAL=true` flag tells scuttlebot not to manage ergo as a subprocess. |
| --- a/deploy/compose/README.md | |
| +++ b/deploy/compose/README.md | |
| @@ -0,0 +1,75 @@ | |
| --- a/deploy/compose/README.md | |
| +++ b/deploy/compose/README.md | |
| @@ -0,0 +1,75 @@ | |
| 1 | # Docker Compose deployment |
| 2 | |
| 3 | Three-service stack: **ergo** (IRC server) + **scuttlebot** (daemon) + **postgres** (state + IRC history). |
| 4 | |
| 5 | ## Quick start |
| 6 | |
| 7 | ```sh |
| 8 | cd deploy/compose |
| 9 | |
| 10 | # 1. Create your .env |
| 11 | cp .env.example .env |
| 12 | # Edit .env — set ERGO_API_TOKEN to a random secret. |
| 13 | # Generate one: openssl rand -hex 32 |
| 14 | |
| 15 | # 2. Boot |
| 16 | docker compose up --build |
| 17 | |
| 18 | # 3. The scuttlebot API token is printed in scuttlebot's startup logs. |
| 19 | docker compose logs scuttlebot | grep "api token" |
| 20 | ``` |
| 21 | |
| 22 | ## Services |
| 23 | |
| 24 | | Service | Internal port | Published by default | Notes | |
| 25 | |-------------|---------------|----------------------|-------| |
| 26 | | postgres | 5432 | No (override: yes) | State store + ergo IRC history | |
| 27 | | ergo | 6667 (IRC) | Yes | IRC server | |
| 28 | | ergo | 8089 (API) | No (override: yes) | Ergo management API | |
| 29 | | scuttlebot | 8080 | Yes | REST management API | |
| 30 | |
| 31 | ## Environment variables |
| 32 | |
| 33 | See `.env.example` for the full list. Only `ERGO_API_TOKEN` is required — everything else has a default. |
| 34 | |
| 35 | ## Persistence |
| 36 | |
| 37 | Data survives container restarts via named Docker volumes: |
| 38 | |
| 39 | - `ergo_data` — Ergo's embedded database (`ircd.db`) |
| 40 | - `postgres_data` — Postgres data directory |
| 41 | |
| 42 | To reset completely: `docker compose down -v` |
| 43 | |
| 44 | ## Local dev overrides |
| 45 | |
| 46 | `docker-compose.override.yml` is applied automatically and: |
| 47 | - Exposes postgres on `5432` and the ergo API on `8089` to localhost |
| 48 | - Disables ergo persistent history (faster startup) |
| 49 | - Sets debug log level on scuttlebot |
| 50 | |
| 51 | To run without overrides (production-like): |
| 52 | |
| 53 | ```sh |
| 54 | docker compose -f docker-compose.yml up |
| 55 | ``` |
| 56 | |
| 57 | ## Architecture |
| 58 | |
| 59 | ``` |
| 60 | ┌─────────────┐ |
| 61 | IRC clients │ ergo │ :6667 |
| 62 | ───────────>│ IRC server │ |
| 63 | └──────┬──────┘ |
| 64 | │ HTTP API :8089 |
| 65 | ┌──────▼──────┐ |
| 66 | │ scuttlebot │ :8080 |
| 67 | │ daemon │<──── REST API (agent registration, etc.) |
| 68 | └──────┬──────┘ |
| 69 | │ |
| 70 | ┌──────▼──────┐ |
| 71 | │ postgres │ :5432 |
| 72 | └─────────────┘ |
| 73 | ``` |
| 74 | |
| 75 | Ergo runs as a separate container. Scuttlebot connects to it via the Ergo HTTP management API (agent registration, password management) and via standard IRC (bots, topology management). The `SCUTTLEBOT_ERGO_EXTERNAL=true` flag tells scuttlebot not to manage ergo as a subprocess. |
| --- a/deploy/compose/docker-compose.override.yml | ||
| +++ b/deploy/compose/docker-compose.override.yml | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +# docker-compose.override.yml — local dev overrides. | |
| 2 | +# This file is applied automatically when you run `docker compose up`. | |
| 3 | +# Exposes additional ports and loosens settings for local development. | |
| 4 | + | |
| 5 | +services: | |
| 6 | + | |
| 7 | + postgres: | |
| 8 | + ports: | |
| 9 | + - "5432:5432" | |
| 10 | + | |
| 11 | + ergo: | |
| 12 | + # Expose the ergo HTTP management API locally for debugging. | |
| 13 | + ports: | |
| 14 | + - "8089:8089" | |
| 15 | + environment: | |
| 16 | + # Cheap bcrypt for local dev (already set in template default, just documenting). | |
| 17 | + ERGO_HISTORY_ENABLED: "false" | |
| 18 | + | |
| 19 | + scuttlebot: | |
| 20 | + # Rebuild image on every `docker compose up` for local dev. | |
| 21 | + build: | |
| 22 | + target: builder | |
| 23 | + environment: | |
| 24 | + # Verbose logging in dev. | |
| 25 | + SCUTTLEBOT_LOG_LEVEL: debug |
| --- a/deploy/compose/docker-compose.override.yml | |
| +++ b/deploy/compose/docker-compose.override.yml | |
| @@ -0,0 +1,25 @@ | |
| --- a/deploy/compose/docker-compose.override.yml | |
| +++ b/deploy/compose/docker-compose.override.yml | |
| @@ -0,0 +1,25 @@ | |
| 1 | # docker-compose.override.yml — local dev overrides. |
| 2 | # This file is applied automatically when you run `docker compose up`. |
| 3 | # Exposes additional ports and loosens settings for local development. |
| 4 | |
| 5 | services: |
| 6 | |
| 7 | postgres: |
| 8 | ports: |
| 9 | - "5432:5432" |
| 10 | |
| 11 | ergo: |
| 12 | # Expose the ergo HTTP management API locally for debugging. |
| 13 | ports: |
| 14 | - "8089:8089" |
| 15 | environment: |
| 16 | # Cheap bcrypt for local dev (already set in template default, just documenting). |
| 17 | ERGO_HISTORY_ENABLED: "false" |
| 18 | |
| 19 | scuttlebot: |
| 20 | # Rebuild image on every `docker compose up` for local dev. |
| 21 | build: |
| 22 | target: builder |
| 23 | environment: |
| 24 | # Verbose logging in dev. |
| 25 | SCUTTLEBOT_LOG_LEVEL: debug |
| --- a/deploy/compose/docker-compose.yml | ||
| +++ b/deploy/compose/docker-compose.yml | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +services: | |
| 2 | + | |
| 3 | + postgres: | |
| 4 | + image: postgres:16-alpine | |
| 5 | + restart: unless-stopped | |
| 6 | + environment: | |
| 7 | + POSTGRES_USER: scuttlebot | |
| 8 | + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scuttlebot} | |
| 9 | + POSTGRES_DB: scuttlebot | |
| 10 | + volumes: | |
| 11 | + - postgres_data:/var/lib/postgresql/data | |
| 12 | + healthcheck: | |
| 13 | + test: ["CMD-SHELL", "pg_isready -U scuttlebot"] | |
| 14 | + interval: 5s | |
| 15 | + timeout: 5s | |
| 16 | + retries: 10 | |
| 17 | + | |
| 18 | + ergo: | |
| 19 | + build: | |
| 20 | + context: ./ergo | |
| 21 | + restart: unless-stopped | |
| 22 | + environment: | |
| 23 | + ERGO_NETWORK_NAME: ${ERGO_NETWORK_NAME:-scuttlebot} | |
| 24 | + ERGO_SERVER_NAME: ${ERGO_SERVER_NAME:-irc.scuttlebot.local} | |
| 25 | + ERGO_API_TOKEN: ${ERGO_API_TOKEN:?ERGO_API_TOKEN is required} | |
| 26 | + ERGO_HISTORY_ENABLED: ${ERGO_HISTORY_ENABLED:-true} | |
| 27 | + ERGO_HISTORY_DSN: postgres://scuttlebot:${POSTGRES_PASSWORD:-scuttlebot}@postgres:5432/scuttlebot?sslmode=disable | |
| 28 | + volumes: | |
| 29 | + - ergo_data:/ircd | |
| 30 | + ports: | |
| 31 | + - "${IRC_PORT:-6667}:6667" | |
| 32 | + depends_on: | |
| 33 | + postgres: | |
| 34 | + condition: service_healthy | |
| 35 | + healthcheck: | |
| 36 | + test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8089/v1/status --header='Authorization: Bearer ${ERGO_API_TOKEN}' || exit 1"] | |
| 37 | + interval: 5s | |
| 38 | + timeout: 5s | |
| 39 | + retries: 12 | |
| 40 | + start_period: 10s | |
| 41 | + | |
| 42 | + scuttlebot: | |
| 43 | + build: | |
| 44 | + context: ../.. | |
| 45 | + dockerfile: deploy/docker/Dockerfile | |
| 46 | + restart: unless-stopped | |
| 47 | + environment: | |
| 48 | + SCUTTLEBOT_ERGO_EXTERNAL: "true" | |
| 49 | + SCUTTLEBOT_ERGO_API_ADDR: http://ergo:8089 | |
| 50 | + SCUTTLEBOT_ERGO_API_TOKEN: ${ERGO_API_TOKEN:?ERGO_API_TOKEN is required} | |
| 51 | + SCUTTLEBOT_ERGO_IRC_ADDR: ergo:6667 | |
| 52 | + SCUTTLEBOT_ERGO_NETWORK_NAME: ${ERGO_NETWORK_NAME:-scuttlebot} | |
| 53 | + SCUTTLEBOT_ERGO_SERVER_NAME: ${ERGO_SERVER_NAME:-irc.scuttlebot.local} | |
| 54 | + SCUTTLEBOT_DB_DRIVER: postgres | |
| 55 | + SCUTTLEBOT_DB_DSN: postgres://scuttlebot:${POSTGRES_PASSWORD:-scuttlebot}@postgres:5432/scuttlebot?sslmode=disable | |
| 56 | + SCUTTLEBOT_API_ADDR: :8080 | |
| 57 | + ports: | |
| 58 | + - "${API_PORT:-8080}:8080" | |
| 59 | + depends_on: | |
| 60 | + ergo: | |
| 61 | + condition: service_healthy | |
| 62 | + postgres: | |
| 63 | + condition: service_healthy | |
| 64 | + healthcheck: | |
| 65 | + test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/v1/status --header='Authorization: Bearer ignored' || exit 1"] | |
| 66 | + interval: 10s | |
| 67 | + timeout: 5s | |
| 68 | + retries: 6 | |
| 69 | + start_period: 15s | |
| 70 | + | |
| 71 | +volumes: | |
| 72 | + postgres_data: | |
| 73 | + ergo_data: |
| --- a/deploy/compose/docker-compose.yml | |
| +++ b/deploy/compose/docker-compose.yml | |
| @@ -0,0 +1,73 @@ | |
| --- a/deploy/compose/docker-compose.yml | |
| +++ b/deploy/compose/docker-compose.yml | |
| @@ -0,0 +1,73 @@ | |
| 1 | services: |
| 2 | |
| 3 | postgres: |
| 4 | image: postgres:16-alpine |
| 5 | restart: unless-stopped |
| 6 | environment: |
| 7 | POSTGRES_USER: scuttlebot |
| 8 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scuttlebot} |
| 9 | POSTGRES_DB: scuttlebot |
| 10 | volumes: |
| 11 | - postgres_data:/var/lib/postgresql/data |
| 12 | healthcheck: |
| 13 | test: ["CMD-SHELL", "pg_isready -U scuttlebot"] |
| 14 | interval: 5s |
| 15 | timeout: 5s |
| 16 | retries: 10 |
| 17 | |
| 18 | ergo: |
| 19 | build: |
| 20 | context: ./ergo |
| 21 | restart: unless-stopped |
| 22 | environment: |
| 23 | ERGO_NETWORK_NAME: ${ERGO_NETWORK_NAME:-scuttlebot} |
| 24 | ERGO_SERVER_NAME: ${ERGO_SERVER_NAME:-irc.scuttlebot.local} |
| 25 | ERGO_API_TOKEN: ${ERGO_API_TOKEN:?ERGO_API_TOKEN is required} |
| 26 | ERGO_HISTORY_ENABLED: ${ERGO_HISTORY_ENABLED:-true} |
| 27 | ERGO_HISTORY_DSN: postgres://scuttlebot:${POSTGRES_PASSWORD:-scuttlebot}@postgres:5432/scuttlebot?sslmode=disable |
| 28 | volumes: |
| 29 | - ergo_data:/ircd |
| 30 | ports: |
| 31 | - "${IRC_PORT:-6667}:6667" |
| 32 | depends_on: |
| 33 | postgres: |
| 34 | condition: service_healthy |
| 35 | healthcheck: |
| 36 | test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8089/v1/status --header='Authorization: Bearer ${ERGO_API_TOKEN}' || exit 1"] |
| 37 | interval: 5s |
| 38 | timeout: 5s |
| 39 | retries: 12 |
| 40 | start_period: 10s |
| 41 | |
| 42 | scuttlebot: |
| 43 | build: |
| 44 | context: ../.. |
| 45 | dockerfile: deploy/docker/Dockerfile |
| 46 | restart: unless-stopped |
| 47 | environment: |
| 48 | SCUTTLEBOT_ERGO_EXTERNAL: "true" |
| 49 | SCUTTLEBOT_ERGO_API_ADDR: http://ergo:8089 |
| 50 | SCUTTLEBOT_ERGO_API_TOKEN: ${ERGO_API_TOKEN:?ERGO_API_TOKEN is required} |
| 51 | SCUTTLEBOT_ERGO_IRC_ADDR: ergo:6667 |
| 52 | SCUTTLEBOT_ERGO_NETWORK_NAME: ${ERGO_NETWORK_NAME:-scuttlebot} |
| 53 | SCUTTLEBOT_ERGO_SERVER_NAME: ${ERGO_SERVER_NAME:-irc.scuttlebot.local} |
| 54 | SCUTTLEBOT_DB_DRIVER: postgres |
| 55 | SCUTTLEBOT_DB_DSN: postgres://scuttlebot:${POSTGRES_PASSWORD:-scuttlebot}@postgres:5432/scuttlebot?sslmode=disable |
| 56 | SCUTTLEBOT_API_ADDR: :8080 |
| 57 | ports: |
| 58 | - "${API_PORT:-8080}:8080" |
| 59 | depends_on: |
| 60 | ergo: |
| 61 | condition: service_healthy |
| 62 | postgres: |
| 63 | condition: service_healthy |
| 64 | healthcheck: |
| 65 | test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8080/v1/status --header='Authorization: Bearer ignored' || exit 1"] |
| 66 | interval: 10s |
| 67 | timeout: 5s |
| 68 | retries: 6 |
| 69 | start_period: 15s |
| 70 | |
| 71 | volumes: |
| 72 | postgres_data: |
| 73 | ergo_data: |
| --- a/deploy/compose/ergo/Dockerfile | ||
| +++ b/deploy/compose/ergo/Dockerfile | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +FROM ghcr.io/ergochat/ergo:stable | |
| 2 | + | |
| 3 | +# Install envsubst (gettext package). | |
| 4 | +USER root | |
| 5 | +RUN apk add --no-cache gettext | |
| 6 | + | |
| 7 | +COPY ircd.yaml.tmpl /ergo/ircd.yaml.tmpl | |
| 8 | +COPY entrypoint.sh /ergo/entrypoint.sh | |
| 9 | +RUN chmod +x /ergo/entrypoint.sh | |
| 10 | + | |
| 11 | +ENTRYPOINT ["/ergo/entrypoint.sh"] |
| --- a/deploy/compose/ergo/Dockerfile | |
| +++ b/deploy/compose/ergo/Dockerfile | |
| @@ -0,0 +1,11 @@ | |
| --- a/deploy/compose/ergo/Dockerfile | |
| +++ b/deploy/compose/ergo/Dockerfile | |
| @@ -0,0 +1,11 @@ | |
| 1 | FROM ghcr.io/ergochat/ergo:stable |
| 2 | |
| 3 | # Install envsubst (gettext package). |
| 4 | USER root |
| 5 | RUN apk add --no-cache gettext |
| 6 | |
| 7 | COPY ircd.yaml.tmpl /ergo/ircd.yaml.tmpl |
| 8 | COPY entrypoint.sh /ergo/entrypoint.sh |
| 9 | RUN chmod +x /ergo/entrypoint.sh |
| 10 | |
| 11 | ENTRYPOINT ["/ergo/entrypoint.sh"] |
| --- a/deploy/compose/ergo/entrypoint.sh | ||
| +++ b/deploy/compose/ergo/entrypoint.sh | ||
| @@ -0,0 +1,13 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +set -e | |
| 3 | + | |
| 4 | +CONFIG_DIR="${ERGO_DATA_DIR:-/ircd}" | |
| 5 | +CONFIG_FILE="${CONFIG_DIR}/ircd.yaml" | |
| 6 | +TEMPLATE="/ergo/ircd.yaml.tmpl" | |
| 7 | + | |
| 8 | +mkdir -p "${CONFIG_DIR}" | |
| 9 | + | |
| 10 | +# Render template with env var substitution. | |
| 11 | +envsubst < "${TEMPLATE}" > "${CONFIG_FILE}" | |
| 12 | + | |
| 13 | +exec ergo run --conf "${CONFIG_FILE}" |
| --- a/deploy/compose/ergo/entrypoint.sh | |
| +++ b/deploy/compose/ergo/entrypoint.sh | |
| @@ -0,0 +1,13 @@ | |
| --- a/deploy/compose/ergo/entrypoint.sh | |
| +++ b/deploy/compose/ergo/entrypoint.sh | |
| @@ -0,0 +1,13 @@ | |
| 1 | #!/bin/sh |
| 2 | set -e |
| 3 | |
| 4 | CONFIG_DIR="${ERGO_DATA_DIR:-/ircd}" |
| 5 | CONFIG_FILE="${CONFIG_DIR}/ircd.yaml" |
| 6 | TEMPLATE="/ergo/ircd.yaml.tmpl" |
| 7 | |
| 8 | mkdir -p "${CONFIG_DIR}" |
| 9 | |
| 10 | # Render template with env var substitution. |
| 11 | envsubst < "${TEMPLATE}" > "${CONFIG_FILE}" |
| 12 | |
| 13 | exec ergo run --conf "${CONFIG_FILE}" |
| --- a/deploy/compose/ergo/ircd.yaml.tmpl | ||
| +++ b/deploy/compose/ergo/ircd.yaml.tmpl | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +# Ergo ircd.yaml — generated by entrypoint from environment variables. | |
| 2 | +# Do not edit this file directly; edit ircd.yaml.tmpl instead. | |
| 3 | + | |
| 4 | +network: | |
| 5 | + name: ${ERGO_NETWORK_NAME} | |
| 6 | + | |
| 7 | +server: | |
| 8 | + name: ${ERGO_SERVER_NAME} | |
| 9 | + listeners: | |
| 10 | + ":6667": | |
| 11 | + # plaintext IRC — internal Docker network only, not published | |
| 12 | + casemapping: "ascii" | |
| 13 | + enforce-utf8: true | |
| 14 | + lookup-hostnames: false | |
| 15 | + forward-confirm-hostnames: false | |
| 16 | + check-ident: false | |
| 17 | + relaymsg: | |
| 18 | + enabled: | |
| 19 | + available-to-chanops: false | |
| 20 | + ip-cloaking: | |
| 21 | + enabled: false | |
| 22 | + max-sendq: "1M" | |
| 23 | + ip-limits: | |
| 24 | + count-exempted: true | |
| 25 | + throttle-exempted: true | |
| 26 | + | |
| 27 | +accounts: | |
| 28 | + authentication-enabled: true | |
| 29 | + registration: | |
| 30 | + enabled: true | |
| 31 | + allow-before-connect: true | |
| 32 | + throttling: | |
| 33 | + enabled: false | |
| 34 | + bcrypt-cost: 4 | |
| 35 | + email-verification: | |
| 36 | + enabled: false | |
| 37 | + nick-reservation: | |
| 38 | + enabled: true | |
| 39 | + additional-nick-limit: 0 | |
| 40 | + method: strict | |
| 41 | + allow-custom-enforcement: false | |
| 42 | + multiclient: | |
| 43 | + enabled: true | |
| 44 | + allowed-by-default: true | |
| 45 | + always-on: opt-out | |
| 46 | + auto-away: opt-out | |
| 47 | + | |
| 48 | +channels: | |
| 49 | + default-modes: +ntC | |
| 50 | + registration: | |
| 51 | + enabled: true | |
| 52 | + | |
| 53 | +datastore: | |
| 54 | + path: /ircd/ircd.db | |
| 55 | + | |
| 56 | +history: | |
| 57 | + enabled: true | |
| 58 | + channel-length: 256 | |
| 59 | + client-length: 64 | |
| 60 | + autoresize-window: 1d | |
| 61 | + autoreplay-on-join: 0 | |
| 62 | + chathistory-limit: 100 | |
| 63 | + znc-maxmessages: 2048 | |
| 64 | + restrictions: | |
| 65 | + expire-time: 1w | |
| 66 | + query-cutoff: none | |
| 67 | + persistent: | |
| 68 | + enabled: ${ERGO_HISTORY_ENABLED} | |
| 69 | + unregistered-channels: false | |
| 70 | + registered-channels: opt-in | |
| 71 | + direct-messages: opt-in | |
| 72 | + connection-string: ${ERGO_HISTORY_DSN} | |
| 73 | + | |
| 74 | +api: | |
| 75 | + enabled: true | |
| 76 | + listen: "0.0.0.0:8089" | |
| 77 | + bearer-tokens: | |
| 78 | + - ${ERGO_API_TOKEN} | |
| 79 | + | |
| 80 | +logging: | |
| 81 | + - method: stderr | |
| 82 | + |
| --- a/deploy/compose/ergo/ircd.yaml.tmpl | |
| +++ b/deploy/compose/ergo/ircd.yaml.tmpl | |
| @@ -0,0 +1,82 @@ | |
| --- a/deploy/compose/ergo/ircd.yaml.tmpl | |
| +++ b/deploy/compose/ergo/ircd.yaml.tmpl | |
| @@ -0,0 +1,82 @@ | |
| 1 | # Ergo ircd.yaml — generated by entrypoint from environment variables. |
| 2 | # Do not edit this file directly; edit ircd.yaml.tmpl instead. |
| 3 | |
| 4 | network: |
| 5 | name: ${ERGO_NETWORK_NAME} |
| 6 | |
| 7 | server: |
| 8 | name: ${ERGO_SERVER_NAME} |
| 9 | listeners: |
| 10 | ":6667": |
| 11 | # plaintext IRC — internal Docker network only, not published |
| 12 | casemapping: "ascii" |
| 13 | enforce-utf8: true |
| 14 | lookup-hostnames: false |
| 15 | forward-confirm-hostnames: false |
| 16 | check-ident: false |
| 17 | relaymsg: |
| 18 | enabled: |
| 19 | available-to-chanops: false |
| 20 | ip-cloaking: |
| 21 | enabled: false |
| 22 | max-sendq: "1M" |
| 23 | ip-limits: |
| 24 | count-exempted: true |
| 25 | throttle-exempted: true |
| 26 | |
| 27 | accounts: |
| 28 | authentication-enabled: true |
| 29 | registration: |
| 30 | enabled: true |
| 31 | allow-before-connect: true |
| 32 | throttling: |
| 33 | enabled: false |
| 34 | bcrypt-cost: 4 |
| 35 | email-verification: |
| 36 | enabled: false |
| 37 | nick-reservation: |
| 38 | enabled: true |
| 39 | additional-nick-limit: 0 |
| 40 | method: strict |
| 41 | allow-custom-enforcement: false |
| 42 | multiclient: |
| 43 | enabled: true |
| 44 | allowed-by-default: true |
| 45 | always-on: opt-out |
| 46 | auto-away: opt-out |
| 47 | |
| 48 | channels: |
| 49 | default-modes: +ntC |
| 50 | registration: |
| 51 | enabled: true |
| 52 | |
| 53 | datastore: |
| 54 | path: /ircd/ircd.db |
| 55 | |
| 56 | history: |
| 57 | enabled: true |
| 58 | channel-length: 256 |
| 59 | client-length: 64 |
| 60 | autoresize-window: 1d |
| 61 | autoreplay-on-join: 0 |
| 62 | chathistory-limit: 100 |
| 63 | znc-maxmessages: 2048 |
| 64 | restrictions: |
| 65 | expire-time: 1w |
| 66 | query-cutoff: none |
| 67 | persistent: |
| 68 | enabled: ${ERGO_HISTORY_ENABLED} |
| 69 | unregistered-channels: false |
| 70 | registered-channels: opt-in |
| 71 | direct-messages: opt-in |
| 72 | connection-string: ${ERGO_HISTORY_DSN} |
| 73 | |
| 74 | api: |
| 75 | enabled: true |
| 76 | listen: "0.0.0.0:8089" |
| 77 | bearer-tokens: |
| 78 | - ${ERGO_API_TOKEN} |
| 79 | |
| 80 | logging: |
| 81 | - method: stderr |
| 82 |
+19
| --- a/deploy/docker/Dockerfile | ||
| +++ b/deploy/docker/Dockerfile | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +FROM golang:1.22-alpine AS builder | |
| 2 | + | |
| 3 | +WORKDIR /src | |
| 4 | +COPY go.mod go.sum ./ | |
| 5 | +RUN go mod download | |
| 6 | + | |
| 7 | +COPY . . | |
| 8 | +ARG VERSION=dev | |
| 9 | +RUN CGO_ENABLED=0 GOOS=linux go build \ | |
| 10 | + -ldflags="-s -w -X main.version=${VERSION}" \ | |
| 11 | + -o /scuttlebot ./cmd/scuttlebot | |
| 12 | + | |
| 13 | +# ---- | |
| 14 | + | |
| 15 | +FROM alpine:3.20 | |
| 16 | + | |
| 17 | +RUN apk add --no-cache ca-certificates tzdata | |
| 18 | + | |
| 19 | +COPY --from=builder /scuttlebot EXPOSE 8080 |
| --- a/deploy/docker/Dockerfile | |
| +++ b/deploy/docker/Dockerfile | |
| @@ -0,0 +1,19 @@ | |
| --- a/deploy/docker/Dockerfile | |
| +++ b/deploy/docker/Dockerfile | |
| @@ -0,0 +1,19 @@ | |
| 1 | FROM golang:1.22-alpine AS builder |
| 2 | |
| 3 | WORKDIR /src |
| 4 | COPY go.mod go.sum ./ |
| 5 | RUN go mod download |
| 6 | |
| 7 | COPY . . |
| 8 | ARG VERSION=dev |
| 9 | RUN CGO_ENABLED=0 GOOS=linux go build \ |
| 10 | -ldflags="-s -w -X main.version=${VERSION}" \ |
| 11 | -o /scuttlebot ./cmd/scuttlebot |
| 12 | |
| 13 | # ---- |
| 14 | |
| 15 | FROM alpine:3.20 |
| 16 | |
| 17 | RUN apk add --no-cache ca-certificates tzdata |
| 18 | |
| 19 | COPY --from=builder /scuttlebot EXPOSE 8080 |
+46
| --- a/internal/api/agents.go | ||
| +++ b/internal/api/agents.go | ||
| @@ -0,0 +1,46 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "net/http" | |
| 6 | + "strings" | |
| 7 | + | |
| 8 | + "github.com/conflicthq/scuttlebot/internal/registry" | |
| 9 | +) | |
| 10 | + | |
| 11 | +type registerRequest struct { | |
| 12 | + Nic string registry.AgentTyp []string retistry.Touch(req.Nick) api | |
| 13 | + | |
| 14 | +import ( | |
| 15 | + "encoding/json" | |
| 16 | + "net/http" | |
| 17 | + "strings" | |
| 18 | + | |
| 19 | + "github.com/conflicthq/scuttlebot/internal/registry" | |
| 20 | +) | |
| 21 | + | |
| 22 | +type registerRequest struct { | |
| 23 | + Nick string `json:"nick"` | |
| 24 | + Type registry.AgentType `json:"type"` | |
| 25 | + Channels []string return | |
| 26 | + } | |
| 27 | + writeJSON(w, http.St stry.Update(agent) | |
| 28 | + } | |
| 29 | + } | |
| 30 | + s.registry.Touch(req.Nick) | |
| 31 | + s.setAgentMod else { | |
| 32 | + deleted++ | |
| 33 | + } | |
| 34 | + e | |
| 35 | + writeJSON(w, http.StatusCreated, registerResponse{ | |
| 36 | + Credentials: creds, | |
| 37 | + Payload: payload, | |
| 38 | + }) | |
| 39 | +} | |
| 40 | + | |
| 41 | +func (s *Server) handleAdoppackage api | |
| 42 | + | |
| 43 | +import ( | |
| 44 | + "encoding/jsreq.Channels, req.Permissionsror()) | |
| 45 | + return | |
| 46 | + } |
| --- a/internal/api/agents.go | |
| +++ b/internal/api/agents.go | |
| @@ -0,0 +1,46 @@ | |
| --- a/internal/api/agents.go | |
| +++ b/internal/api/agents.go | |
| @@ -0,0 +1,46 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 9 | ) |
| 10 | |
| 11 | type registerRequest struct { |
| 12 | Nic string registry.AgentTyp []string retistry.Touch(req.Nick) api |
| 13 | |
| 14 | import ( |
| 15 | "encoding/json" |
| 16 | "net/http" |
| 17 | "strings" |
| 18 | |
| 19 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 20 | ) |
| 21 | |
| 22 | type registerRequest struct { |
| 23 | Nick string `json:"nick"` |
| 24 | Type registry.AgentType `json:"type"` |
| 25 | Channels []string return |
| 26 | } |
| 27 | writeJSON(w, http.St stry.Update(agent) |
| 28 | } |
| 29 | } |
| 30 | s.registry.Touch(req.Nick) |
| 31 | s.setAgentMod else { |
| 32 | deleted++ |
| 33 | } |
| 34 | e |
| 35 | writeJSON(w, http.StatusCreated, registerResponse{ |
| 36 | Credentials: creds, |
| 37 | Payload: payload, |
| 38 | }) |
| 39 | } |
| 40 | |
| 41 | func (s *Server) handleAdoppackage api |
| 42 | |
| 43 | import ( |
| 44 | "encoding/jsreq.Channels, req.Permissionsror()) |
| 45 | return |
| 46 | } |
+206
| --- a/internal/api/api_test.go | ||
| +++ b/internal/api/api_test.go | ||
| @@ -0,0 +1,206 @@ | ||
| 1 | +package api_test | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "net/http" | |
| 8 | + "net/http/httptest" | |
| 9 | + "sync" | |
| 10 | + "testing" | |
| 11 | + | |
| 12 | + "github.com/conflicthq/scuttlebot/internal/api" | |
| 13 | + "github.com/conflicthq/scuttlebot/internal/registry" | |
| 14 | + "log/slog" | |
| 15 | + "os" | |
| 16 | +) | |
| 17 | + | |
| 18 | +var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) | |
| 19 | + | |
| 20 | +// mockProvisioner for registry tests. | |
| 21 | +type mockProvisioner struct { | |
| 22 | + mu sync.Mutex | |
| 23 | + accounts map[string]string | |
| 24 | +} | |
| 25 | + | |
| 26 | +func newMock() *mockProvisioner { | |
| 27 | + return &mockProvisioner{accounts: make(map[string]string)} | |
| 28 | +} | |
| 29 | + | |
| 30 | +func (m *mockProvisioner) RegisterAccount(name, pass string) error { | |
| 31 | + m.mu.Lock() | |
| 32 | + defer m.mu.Unlock() | |
| 33 | + if _, ok := m.accounts[name]; ok { | |
| 34 | + return fmt.Errorf("ACCOUNT_EXISTS") | |
| 35 | + } | |
| 36 | + m.accounts[name] = pass | |
| 37 | + return nil | |
| 38 | +} | |
| 39 | + | |
| 40 | +func (m *mockProvisioner) ChangePassword(name, pass string) error { | |
| 41 | + m.mu.Lock() | |
| 42 | + defer m.mu.Unlock() | |
| 43 | + if _, ok := m.accounts[name]; !ok { | |
| 44 | + return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST") | |
| 45 | + } | |
| 46 | + m.accounts[name] = pass | |
| 47 | + return nil | |
| 48 | +} | |
| 49 | + | |
| 50 | +const testToken = "test-api-token-abc123" | |
| 51 | + | |
| 52 | +func newTestServer(t *testing.T) *httptest.Server { | |
| 53 | + t.Helper() | |
| 54 | + reg := registry.New(newMock(), []byte("test-signing-key")) | |
| 55 | + srv := api.NewtestLog) | |
| 56 | + return httptest.NewServer(srv.Handler()) | |
| 57 | +} | |
| 58 | + | |
| 59 | +func authHeader() http.Header { | |
| 60 | + h := http.Header{} | |
| 61 | + h.Set("Authorization", "Bearer "+testToken) | |
| 62 | + return h | |
| 63 | +} | |
| 64 | + | |
| 65 | +func do(t *testing.T, srv *httptest.Server, method, path string, body any, headers http.Header) *http.Response { | |
| 66 | + t.Helper() | |
| 67 | + var buf bytes.Buffer | |
| 68 | + if body != nil { | |
| 69 | + if err := json.NewEncoder(&buf).Encode(body); err != nil { | |
| 70 | + t.Fatalf("encode body: %v", err) | |
| 71 | + } | |
| 72 | + } | |
| 73 | + req, err := http.NewRequest(method, srv.URL+path, &buf) | |
| 74 | + if err != nil { | |
| 75 | + t.Fatalf("new request: %v", err) | |
| 76 | + } | |
| 77 | + req.Header.Set("Content-Type", "application/json") | |
| 78 | + for k, vs := range headers { | |
| 79 | + for _, v := range vs { | |
| 80 | + req.Header.Set(k, v) | |
| 81 | + } | |
| 82 | + } | |
| 83 | + resp, err := http.DefaultClient.Do(req) | |
| 84 | + if err != nil { | |
| 85 | + t.Fatalf("do request: %v", err) | |
| 86 | + } | |
| 87 | + return resp | |
| 88 | +} | |
| 89 | + | |
| 90 | +func TestAuthRequired(t *testing.T) { | |
| 91 | + srv := newTestServer(t) | |
| 92 | + defer srv.Close() | |
| 93 | + | |
| 94 | + endpoints := []struct{ method, path string }{ | |
| 95 | + {"GET", "/v1/status"}, | |
| 96 | + {"GET", "/v1/agents"}, | |
| 97 | + {"POST", "/v1/agents/register"}, | |
| 98 | + } | |
| 99 | + for _, e := range endpoints { | |
| 100 | + t.Run(e.method+" "+e.path, func(t *testing.T) { | |
| 101 | + resp := do(t, srv, e.method, e.path, nil, nil) | |
| 102 | + defer resp.Body.Close() | |
| 103 | + if resp.StatusCode != http.StatusUnauthorized { | |
| 104 | + t.Errorf("expected 401, got %d", resp.StatusCode) | |
| 105 | + } | |
| 106 | + }) | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +func TestInvalidToken(t *testing.T) { | |
| 111 | + srv := newTestServer(t) | |
| 112 | + defer srv.Close() | |
| 113 | + | |
| 114 | + h := http.Header{} | |
| 115 | + h.Set("Authorization", "Bearer wrong-token") | |
| 116 | + resp := do(t, srv, "GET", "/v1/status", nil, h) | |
| 117 | + defer resp.Body.Close() | |
| 118 | + if resp.StatusCode != http.StatusUnauthorized { | |
| 119 | + t.Errorf("expected 401, got %d", resp.StatusCode) | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +func TestStatus(t *testing.T) { | |
| 124 | + srv := newTestServer(t) | |
| 125 | + defer srv.Close() | |
| 126 | + | |
| 127 | + resp := do(t, srv, "GET", "/v1/status", nil, authHeader()) | |
| 128 | + defer resp.Body.Close() | |
| 129 | + if resp.StatusCode != http.StatusOK { | |
| 130 | + t.Errorf("expected 200, got %d", resp.StatusCode) | |
| 131 | + } | |
| 132 | + | |
| 133 | + var body map[string]any | |
| 134 | + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { | |
| 135 | + t.Fatalf("decode: %v", err) | |
| 136 | + } | |
| 137 | + if body["status"] != "ok" { | |
| 138 | + t.Errorf("status: got %q, want ok", body["status"]) | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +func TestRegisterAndGet(t *testing.T) { | |
| 143 | + srv := newTestServer(t) | |
| 144 | + defer srv.Close() | |
| 145 | + | |
| 146 | + // Register | |
| 147 | + resp := do(t, srv, "POST", "/v1/agents/register", map[string]any{ | |
| 148 | + "nick": "claude-01", | |
| 149 | + "type": "worker", | |
| 150 | + "channels": []string{"#fleet"}, | |
| 151 | + }, authHeader()) | |
| 152 | + defer resp.Body.Close() | |
| 153 | + if resp.StatusCode != http.StatusCreated { | |
| 154 | + t.Errorf("register: expected 201, got %d", resp.StatusCode) | |
| 155 | + } | |
| 156 | + | |
| 157 | + var regBody map[string]any | |
| 158 | + if err := json.NewDecoder(resp.Body).Decode(®Body); err != nil { | |
| 159 | + t.Fatalf("decode: %v", err) | |
| 160 | + } | |
| 161 | + if regBody["credentials"] == nil { | |
| 162 | + t.Error("credentials missing from response") | |
| 163 | + } | |
| 164 | + if regBody["payload"] == nil { | |
| 165 | + t.Error("payload missing from response") | |
| 166 | + } | |
| 167 | + | |
| 168 | + // Get | |
| 169 | + resp2 := do(t, srv, "GET", "/v1/agents/claude-01", nil, authHeader()) | |
| 170 | + defer resp2.Body.Close() | |
| 171 | + if resp2.StatusCode != http.StatusOK { | |
| 172 | + t.Errorf("get: expected 200, got %d", resp2.StatusCode) | |
| 173 | + } | |
| 174 | +} | |
| 175 | + | |
| 176 | +func TestRegisterDuplicate(t *testing.T) { | |
| 177 | + srv := newTestServer(t) | |
| 178 | + defer srv.Close() | |
| 179 | + | |
| 180 | + body := map[string]any{"nick": "agent-dup", "type": "worker"} | |
| 181 | + do(t, srv, "POST", "/v1/agents/register", body, authHeader()).Body.Close() | |
| 182 | + | |
| 183 | + resp := do(t, srv, "POST", "/v1/agents/register", body, authHeader()) | |
| 184 | + defer resp.Body.Close() | |
| 185 | + if resp.StatusCode != http.StatusConflict { | |
| 186 | + t.Errorf("expected 409 on duplicate, got %d", resp.StatusCode) | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 190 | +func TestListAgents(t *testing.T) { | |
| 191 | + srv := newTestServer(t) | |
| 192 | + defer srv.Close() | |
| 193 | + | |
| 194 | + for _, nick := range []string{"a1", "a2", "a3"} { | |
| 195 | + do(t, srv, "POST", "/v1/agents/register", map[string]any{"nick": nick}, authHeader()).Body.Close() | |
| 196 | + } | |
| 197 | + | |
| 198 | + resp := do(t, srv, "GET", "/v1/agents", nil, authHeader()) | |
| 199 | + defer resp.Body.Close() | |
| 200 | + if resp.StatusCode != http.StatusOK { | |
| 201 | + t.Errorf("expected 200, got %d", resp.StatusCode) | |
| 202 | + } | |
| 203 | + | |
| 204 | + var body map[string]any | |
| 205 | + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { | |
| 206 | + t.Fatalf("decode |
| --- a/internal/api/api_test.go | |
| +++ b/internal/api/api_test.go | |
| @@ -0,0 +1,206 @@ | |
| --- a/internal/api/api_test.go | |
| +++ b/internal/api/api_test.go | |
| @@ -0,0 +1,206 @@ | |
| 1 | package api_test |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/conflicthq/scuttlebot/internal/api" |
| 13 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 14 | "log/slog" |
| 15 | "os" |
| 16 | ) |
| 17 | |
| 18 | var testLog = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) |
| 19 | |
| 20 | // mockProvisioner for registry tests. |
| 21 | type mockProvisioner struct { |
| 22 | mu sync.Mutex |
| 23 | accounts map[string]string |
| 24 | } |
| 25 | |
| 26 | func newMock() *mockProvisioner { |
| 27 | return &mockProvisioner{accounts: make(map[string]string)} |
| 28 | } |
| 29 | |
| 30 | func (m *mockProvisioner) RegisterAccount(name, pass string) error { |
| 31 | m.mu.Lock() |
| 32 | defer m.mu.Unlock() |
| 33 | if _, ok := m.accounts[name]; ok { |
| 34 | return fmt.Errorf("ACCOUNT_EXISTS") |
| 35 | } |
| 36 | m.accounts[name] = pass |
| 37 | return nil |
| 38 | } |
| 39 | |
| 40 | func (m *mockProvisioner) ChangePassword(name, pass string) error { |
| 41 | m.mu.Lock() |
| 42 | defer m.mu.Unlock() |
| 43 | if _, ok := m.accounts[name]; !ok { |
| 44 | return fmt.Errorf("ACCOUNT_DOES_NOT_EXIST") |
| 45 | } |
| 46 | m.accounts[name] = pass |
| 47 | return nil |
| 48 | } |
| 49 | |
| 50 | const testToken = "test-api-token-abc123" |
| 51 | |
| 52 | func newTestServer(t *testing.T) *httptest.Server { |
| 53 | t.Helper() |
| 54 | reg := registry.New(newMock(), []byte("test-signing-key")) |
| 55 | srv := api.NewtestLog) |
| 56 | return httptest.NewServer(srv.Handler()) |
| 57 | } |
| 58 | |
| 59 | func authHeader() http.Header { |
| 60 | h := http.Header{} |
| 61 | h.Set("Authorization", "Bearer "+testToken) |
| 62 | return h |
| 63 | } |
| 64 | |
| 65 | func do(t *testing.T, srv *httptest.Server, method, path string, body any, headers http.Header) *http.Response { |
| 66 | t.Helper() |
| 67 | var buf bytes.Buffer |
| 68 | if body != nil { |
| 69 | if err := json.NewEncoder(&buf).Encode(body); err != nil { |
| 70 | t.Fatalf("encode body: %v", err) |
| 71 | } |
| 72 | } |
| 73 | req, err := http.NewRequest(method, srv.URL+path, &buf) |
| 74 | if err != nil { |
| 75 | t.Fatalf("new request: %v", err) |
| 76 | } |
| 77 | req.Header.Set("Content-Type", "application/json") |
| 78 | for k, vs := range headers { |
| 79 | for _, v := range vs { |
| 80 | req.Header.Set(k, v) |
| 81 | } |
| 82 | } |
| 83 | resp, err := http.DefaultClient.Do(req) |
| 84 | if err != nil { |
| 85 | t.Fatalf("do request: %v", err) |
| 86 | } |
| 87 | return resp |
| 88 | } |
| 89 | |
| 90 | func TestAuthRequired(t *testing.T) { |
| 91 | srv := newTestServer(t) |
| 92 | defer srv.Close() |
| 93 | |
| 94 | endpoints := []struct{ method, path string }{ |
| 95 | {"GET", "/v1/status"}, |
| 96 | {"GET", "/v1/agents"}, |
| 97 | {"POST", "/v1/agents/register"}, |
| 98 | } |
| 99 | for _, e := range endpoints { |
| 100 | t.Run(e.method+" "+e.path, func(t *testing.T) { |
| 101 | resp := do(t, srv, e.method, e.path, nil, nil) |
| 102 | defer resp.Body.Close() |
| 103 | if resp.StatusCode != http.StatusUnauthorized { |
| 104 | t.Errorf("expected 401, got %d", resp.StatusCode) |
| 105 | } |
| 106 | }) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestInvalidToken(t *testing.T) { |
| 111 | srv := newTestServer(t) |
| 112 | defer srv.Close() |
| 113 | |
| 114 | h := http.Header{} |
| 115 | h.Set("Authorization", "Bearer wrong-token") |
| 116 | resp := do(t, srv, "GET", "/v1/status", nil, h) |
| 117 | defer resp.Body.Close() |
| 118 | if resp.StatusCode != http.StatusUnauthorized { |
| 119 | t.Errorf("expected 401, got %d", resp.StatusCode) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func TestStatus(t *testing.T) { |
| 124 | srv := newTestServer(t) |
| 125 | defer srv.Close() |
| 126 | |
| 127 | resp := do(t, srv, "GET", "/v1/status", nil, authHeader()) |
| 128 | defer resp.Body.Close() |
| 129 | if resp.StatusCode != http.StatusOK { |
| 130 | t.Errorf("expected 200, got %d", resp.StatusCode) |
| 131 | } |
| 132 | |
| 133 | var body map[string]any |
| 134 | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| 135 | t.Fatalf("decode: %v", err) |
| 136 | } |
| 137 | if body["status"] != "ok" { |
| 138 | t.Errorf("status: got %q, want ok", body["status"]) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestRegisterAndGet(t *testing.T) { |
| 143 | srv := newTestServer(t) |
| 144 | defer srv.Close() |
| 145 | |
| 146 | // Register |
| 147 | resp := do(t, srv, "POST", "/v1/agents/register", map[string]any{ |
| 148 | "nick": "claude-01", |
| 149 | "type": "worker", |
| 150 | "channels": []string{"#fleet"}, |
| 151 | }, authHeader()) |
| 152 | defer resp.Body.Close() |
| 153 | if resp.StatusCode != http.StatusCreated { |
| 154 | t.Errorf("register: expected 201, got %d", resp.StatusCode) |
| 155 | } |
| 156 | |
| 157 | var regBody map[string]any |
| 158 | if err := json.NewDecoder(resp.Body).Decode(®Body); err != nil { |
| 159 | t.Fatalf("decode: %v", err) |
| 160 | } |
| 161 | if regBody["credentials"] == nil { |
| 162 | t.Error("credentials missing from response") |
| 163 | } |
| 164 | if regBody["payload"] == nil { |
| 165 | t.Error("payload missing from response") |
| 166 | } |
| 167 | |
| 168 | // Get |
| 169 | resp2 := do(t, srv, "GET", "/v1/agents/claude-01", nil, authHeader()) |
| 170 | defer resp2.Body.Close() |
| 171 | if resp2.StatusCode != http.StatusOK { |
| 172 | t.Errorf("get: expected 200, got %d", resp2.StatusCode) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | func TestRegisterDuplicate(t *testing.T) { |
| 177 | srv := newTestServer(t) |
| 178 | defer srv.Close() |
| 179 | |
| 180 | body := map[string]any{"nick": "agent-dup", "type": "worker"} |
| 181 | do(t, srv, "POST", "/v1/agents/register", body, authHeader()).Body.Close() |
| 182 | |
| 183 | resp := do(t, srv, "POST", "/v1/agents/register", body, authHeader()) |
| 184 | defer resp.Body.Close() |
| 185 | if resp.StatusCode != http.StatusConflict { |
| 186 | t.Errorf("expected 409 on duplicate, got %d", resp.StatusCode) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestListAgents(t *testing.T) { |
| 191 | srv := newTestServer(t) |
| 192 | defer srv.Close() |
| 193 | |
| 194 | for _, nick := range []string{"a1", "a2", "a3"} { |
| 195 | do(t, srv, "POST", "/v1/agents/register", map[string]any{"nick": nick}, authHeader()).Body.Close() |
| 196 | } |
| 197 | |
| 198 | resp := do(t, srv, "GET", "/v1/agents", nil, authHeader()) |
| 199 | defer resp.Body.Close() |
| 200 | if resp.StatusCode != http.StatusOK { |
| 201 | t.Errorf("expected 200, got %d", resp.StatusCode) |
| 202 | } |
| 203 | |
| 204 | var body map[string]any |
| 205 | if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| 206 | t.Fatalf("decode |
| --- a/internal/api/middleware.go | ||
| +++ b/internal/api/middleware.go | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "strings" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func (s *Server) authMiddleware(next http.Handler) http.Handler { | |
| 9 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 10 | + token := bearerToken(r) | |
| 11 | + if token == "" { | |
| 12 | + writeError(w, http.StatusUnauthorized, "missing authorization header") | |
| 13 | + return | |
| 14 | + } | |
| 15 | + if _, ok := s.tokens[token]; !ok { | |
| 16 | + writeError(w,invalid token") | |
| 17 | + return | |
| 18 | + } | |
| 19 | + next.ServeHTTP(w, r) | |
| 20 | + }) return | |
| 21 | + } | |
| 22 | + next(w, r) | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +func bearerToken(r *http.Request) string { | |
| 27 | + auth := r.Header.Get("Authorization") | |
| 28 | + token, found := strings.CutPrefix(auth, "Bearer ") | |
| 29 | + if !found { | |
| 30 | + return "" | |
| 31 | + } | |
| 32 | + return |
| --- a/internal/api/middleware.go | |
| +++ b/internal/api/middleware.go | |
| @@ -0,0 +1,32 @@ | |
| --- a/internal/api/middleware.go | |
| +++ b/internal/api/middleware.go | |
| @@ -0,0 +1,32 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | func (s *Server) authMiddleware(next http.Handler) http.Handler { |
| 9 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 10 | token := bearerToken(r) |
| 11 | if token == "" { |
| 12 | writeError(w, http.StatusUnauthorized, "missing authorization header") |
| 13 | return |
| 14 | } |
| 15 | if _, ok := s.tokens[token]; !ok { |
| 16 | writeError(w,invalid token") |
| 17 | return |
| 18 | } |
| 19 | next.ServeHTTP(w, r) |
| 20 | }) return |
| 21 | } |
| 22 | next(w, r) |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | func bearerToken(r *http.Request) string { |
| 27 | auth := r.Header.Get("Authorization") |
| 28 | token, found := strings.CutPrefix(auth, "Bearer ") |
| 29 | if !found { |
| 30 | return "" |
| 31 | } |
| 32 | return |
+16
| --- a/internal/api/respond.go | ||
| +++ b/internal/api/respond.go | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "net/http" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func writeJSON(w http.ResponseWriter, status int, v any) { | |
| 9 | + w.Header().Set("Content-Type", "application/json") | |
| 10 | + w.WriteHeader(status) | |
| 11 | + _ = json.NewEncoder(w).Encode(v) | |
| 12 | +} | |
| 13 | + | |
| 14 | +func writeError(w http.ResponseWriter, status int, msg string) { | |
| 15 | + writeJSON(w, status, map[string]string{"error": msg}) | |
| 16 | +} |
| --- a/internal/api/respond.go | |
| +++ b/internal/api/respond.go | |
| @@ -0,0 +1,16 @@ | |
| --- a/internal/api/respond.go | |
| +++ b/internal/api/respond.go | |
| @@ -0,0 +1,16 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | ) |
| 7 | |
| 8 | func writeJSON(w http.ResponseWriter, status int, v any) { |
| 9 | w.Header().Set("Content-Type", "application/json") |
| 10 | w.WriteHeader(status) |
| 11 | _ = json.NewEncoder(w).Encode(v) |
| 12 | } |
| 13 | |
| 14 | func writeError(w http.ResponseWriter, status int, msg string) { |
| 15 | writeJSON(w, status, map[string]string{"error": msg}) |
| 16 | } |
+39
| --- a/internal/api/server.go | ||
| +++ b/internal/api/server.go | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +// Package api implements the scuttlebot HTTP management API. | |
| 2 | +// | |
| 3 | +// All endpoints re No anonymous access. | |
| 4 | +// Agents and external systems use this API to register, manage credentialsam (EventSource limitation). | |
| 5 | +package api | |
| 6 | + | |
| 7 | +import ( | |
| 8 | + "log/slog" | |
| 9 | + "net/http" | |
| 10 | + | |
| 11 | + "github.com/conflicthq/scuttlebot/internal/registry" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// Server is the scuttlebot HTTP API server. | |
| 15 | +type Server struct { | |
| 16 | + registry *registry.Registry | |
| 17 | + tokens*registry.Registry | |
| 18 | + tokens map*slog.Logger | |
| 19 | + bridge chatBridge // nil if bridge is disabled | |
| 20 | +r b to disable the chat bridge. | |
| 21 | +// Pass nil for admins to disable admin authentokenSet reg, | |
| 22 | + apiKeys: apiKeys, | |
| 23 | + log: log, | |
| 24 | + bridge: po, | |
| 25 | + cfgStore: cfgStore, | |
| 26 | + loginRL: newLoginRateLimiter()ith all routes registered. | |
| 27 | +// /v1/ rs additionally check the API key's scopescoreg, | |
| 28 | + tokens:1/ endpoints require a valog, | |
| 29 | + bridge: bimport Auth middleware wraps every route — no endpoint is reachable without a valid tokenonfig" | |
| 30 | + "github.comux/stream uses ?token= qu | |
| 31 | + m} | |
| 32 | + log ig wr/ em// Packerver. | |
| 33 | +type Server struct m} | |
| 34 | + log ig wr/ em// Package ap", s.handleListAgents) | |
| 35 | + m} | |
| 36 | + log ig wr/ em// Package api mints require a valid Bearer tokplements the scuttlebot HTTP mmints require a valid Bearer token. | |
| 37 | +// /ui/ is served unauthenticmints requirenel}/stream uses ?token= qreturn: log, | |
| 38 | + brimux) | |
| 39 | +} |
| --- a/internal/api/server.go | |
| +++ b/internal/api/server.go | |
| @@ -0,0 +1,39 @@ | |
| --- a/internal/api/server.go | |
| +++ b/internal/api/server.go | |
| @@ -0,0 +1,39 @@ | |
| 1 | // Package api implements the scuttlebot HTTP management API. |
| 2 | // |
| 3 | // All endpoints re No anonymous access. |
| 4 | // Agents and external systems use this API to register, manage credentialsam (EventSource limitation). |
| 5 | package api |
| 6 | |
| 7 | import ( |
| 8 | "log/slog" |
| 9 | "net/http" |
| 10 | |
| 11 | "github.com/conflicthq/scuttlebot/internal/registry" |
| 12 | ) |
| 13 | |
| 14 | // Server is the scuttlebot HTTP API server. |
| 15 | type Server struct { |
| 16 | registry *registry.Registry |
| 17 | tokens*registry.Registry |
| 18 | tokens map*slog.Logger |
| 19 | bridge chatBridge // nil if bridge is disabled |
| 20 | r b to disable the chat bridge. |
| 21 | // Pass nil for admins to disable admin authentokenSet reg, |
| 22 | apiKeys: apiKeys, |
| 23 | log: log, |
| 24 | bridge: po, |
| 25 | cfgStore: cfgStore, |
| 26 | loginRL: newLoginRateLimiter()ith all routes registered. |
| 27 | // /v1/ rs additionally check the API key's scopescoreg, |
| 28 | tokens:1/ endpoints require a valog, |
| 29 | bridge: bimport Auth middleware wraps every route — no endpoint is reachable without a valid tokenonfig" |
| 30 | "github.comux/stream uses ?token= qu |
| 31 | m} |
| 32 | log ig wr/ em// Packerver. |
| 33 | type Server struct m} |
| 34 | log ig wr/ em// Package ap", s.handleListAgents) |
| 35 | m} |
| 36 | log ig wr/ em// Package api mints require a valid Bearer tokplements the scuttlebot HTTP mmints require a valid Bearer token. |
| 37 | // /ui/ is served unauthenticmints requirenel}/stream uses ?token= qreturn: log, |
| 38 | brimux) |
| 39 | } |
+25
| --- a/internal/api/status.go | ||
| +++ b/internal/api/status.go | ||
| @@ -0,0 +1,25 @@ | ||
| 1 | +package api | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "time" | |
| 6 | +) | |
| 7 | + | |
| 8 | +var startTime = time.Now() | |
| 9 | + | |
| 10 | +type statusResponse struct { | |
| 11 | + Status string `json:"status"` | |
| 12 | + Uptime string `json:"uptime"` | |
| 13 | + Agents int `json:"agents"` | |
| 14 | + Started time.Time `json:"started"` | |
| 15 | +} | |
| 16 | + | |
| 17 | +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | |
| 18 | + agents := s.registry.List() | |
| 19 | + writeJSON(w, http.StatusOK, statusResponse{ | |
| 20 | + Status: "ok", | |
| 21 | + Uptime: time.Since(startTime).Round(time.Second).String(), | |
| 22 | + Agents: len(agents), | |
| 23 | + Started: startTime, | |
| 24 | + }) | |
| 25 | +} |
| --- a/internal/api/status.go | |
| +++ b/internal/api/status.go | |
| @@ -0,0 +1,25 @@ | |
| --- a/internal/api/status.go | |
| +++ b/internal/api/status.go | |
| @@ -0,0 +1,25 @@ | |
| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "time" |
| 6 | ) |
| 7 | |
| 8 | var startTime = time.Now() |
| 9 | |
| 10 | type statusResponse struct { |
| 11 | Status string `json:"status"` |
| 12 | Uptime string `json:"uptime"` |
| 13 | Agents int `json:"agents"` |
| 14 | Started time.Time `json:"started"` |
| 15 | } |
| 16 | |
| 17 | func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { |
| 18 | agents := s.registry.List() |
| 19 | writeJSON(w, http.StatusOK, statusResponse{ |
| 20 | Status: "ok", |
| 21 | Uptime: time.Since(startTime).Round(time.Second).String(), |
| 22 | Agents: len(agents), |
| 23 | Started: startTime, |
| 24 | }) |
| 25 | } |
+63
-1
| --- internal/config/config.go | ||
| +++ internal/config/config.go | ||
| @@ -1,20 +1,33 @@ | ||
| 1 | 1 | // Package config defines scuttlebot's configuration schema. |
| 2 | 2 | package config |
| 3 | + | |
| 4 | +import "os" | |
| 3 | 5 | |
| 4 | 6 | // Config is the top-level scuttlebot configuration. |
| 5 | 7 | type Config struct { |
| 6 | - Ergo ErgoConfig `yaml:"ergo"` | |
| 8 | + Ergo ErgoConfig `yaml:"ergo"` | |
| 7 | 9 | Datastore DatastoreConfig `yaml:"datastore"` |
| 10 | + | |
| 11 | + // APIAddr is the address for scuttlebot's own HTTP management API. | |
| 12 | + // Default: ":8080" | |
| 13 | + APIAddr string `yaml:"api_addr"` | |
| 8 | 14 | } |
| 9 | 15 | |
| 10 | 16 | // ErgoConfig holds settings for the managed Ergo IRC server. |
| 11 | 17 | type ErgoConfig struct { |
| 18 | + // External disables subprocess management. When true, scuttlebot expects | |
| 19 | + // ergo to already be running and reachable at APIAddr and IRCAddr. | |
| 20 | + // Use this in Docker/K8s deployments where ergo runs as a separate container. | |
| 21 | + External bool `yaml:"external"` | |
| 22 | + | |
| 12 | 23 | // BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH). |
| 24 | + // Unused when External is true. | |
| 13 | 25 | BinaryPath string `yaml:"binary_path"` |
| 14 | 26 | |
| 15 | 27 | // DataDir is the directory where Ergo stores ircd.db and generated config. |
| 28 | + // Unused when External is true. | |
| 16 | 29 | DataDir string `yaml:"data_dir"` |
| 17 | 30 | |
| 18 | 31 | // NetworkName is the human-readable IRC network name. |
| 19 | 32 | NetworkName string `yaml:"network_name"` |
| 20 | 33 | |
| @@ -94,6 +107,55 @@ | ||
| 94 | 107 | c.Datastore.Driver = "sqlite" |
| 95 | 108 | } |
| 96 | 109 | if c.Datastore.DSN == "" { |
| 97 | 110 | c.Datastore.DSN = "./data/scuttlebot.db" |
| 98 | 111 | } |
| 112 | + if c.APIAddr == "" { | |
| 113 | + c.APIAddr = ":8080" | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +func envStr(key string) string { return os.Getenv(key) } | |
| 118 | + | |
| 119 | +// ApplyEnv overrides config values with SCUTTLEBOT_* environment variables. | |
| 120 | +// Call after Defaults() to allow env to override defaults. | |
| 121 | +// | |
| 122 | +// Supported variables: | |
| 123 | +// | |
| 124 | +// SCUTTLEBOT_API_ADDR — scuttlebot HTTP API listen address (e.g. ":8080") | |
| 125 | +// SCUTTLEBOT_DB_DRIVER — "sqlite" or "postgres" | |
| 126 | +// SCUTTLEBOT_DB_DSN — datastore connection string | |
| 127 | +// SCUTTLEBOT_ERGO_EXTERNAL — "true" to skip subprocess management | |
| 128 | +// SCUTTLEBOT_ERGO_API_ADDR — ergo HTTP API address (e.g. "http://ergo:8089") | |
| 129 | +// SCUTTLEBOT_ERGO_API_TOKEN — ergo HTTP API bearer token | |
| 130 | +// SCUTTLEBOT_ERGO_IRC_ADDR — ergo IRC listen/connect address (e.g. "ergo:6667") | |
| 131 | +// SCUTTLEBOT_ERGO_NETWORK_NAME — IRC network name | |
| 132 | +// SCUTTLEBOT_ERGO_SERVER_NAME — IRC server hostname | |
| 133 | +func (c *Config) ApplyEnv() { | |
| 134 | + if v := envStr("SCUTTLEBOT_API_ADDR"); v != "" { | |
| 135 | + c.APIAddr = v | |
| 136 | + } | |
| 137 | + if v := envStr("SCUTTLEBOT_DB_DRIVER"); v != "" { | |
| 138 | + c.Datastore.Driver = v | |
| 139 | + } | |
| 140 | + if v := envStr("SCUTTLEBOT_DB_DSN"); v != "" { | |
| 141 | + c.Datastore.DSN = v | |
| 142 | + } | |
| 143 | + if v := envStr("SCUTTLEBOT_ERGO_EXTERNAL"); v == "true" || v == "1" { | |
| 144 | + c.Ergo.External = true | |
| 145 | + } | |
| 146 | + if v := envStr("SCUTTLEBOT_ERGO_API_ADDR"); v != "" { | |
| 147 | + c.Ergo.APIAddr = v | |
| 148 | + } | |
| 149 | + if v := envStr("SCUTTLEBOT_ERGO_API_TOKEN"); v != "" { | |
| 150 | + c.Ergo.APIToken = v | |
| 151 | + } | |
| 152 | + if v := envStr("SCUTTLEBOT_ERGO_IRC_ADDR"); v != "" { | |
| 153 | + c.Ergo.IRCAddr = v | |
| 154 | + } | |
| 155 | + if v := envStr("SCUTTLEBOT_ERGO_NETWORK_NAME"); v != "" { | |
| 156 | + c.Ergo.NetworkName = v | |
| 157 | + } | |
| 158 | + if v := envStr("SCUTTLEBOT_ERGO_SERVER_NAME"); v != "" { | |
| 159 | + c.Ergo.ServerName = v | |
| 160 | + } | |
| 99 | 161 | } |
| 100 | 162 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -1,20 +1,33 @@ | |
| 1 | // Package config defines scuttlebot's configuration schema. |
| 2 | package config |
| 3 | |
| 4 | // Config is the top-level scuttlebot configuration. |
| 5 | type Config struct { |
| 6 | Ergo ErgoConfig `yaml:"ergo"` |
| 7 | Datastore DatastoreConfig `yaml:"datastore"` |
| 8 | } |
| 9 | |
| 10 | // ErgoConfig holds settings for the managed Ergo IRC server. |
| 11 | type ErgoConfig struct { |
| 12 | // BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH). |
| 13 | BinaryPath string `yaml:"binary_path"` |
| 14 | |
| 15 | // DataDir is the directory where Ergo stores ircd.db and generated config. |
| 16 | DataDir string `yaml:"data_dir"` |
| 17 | |
| 18 | // NetworkName is the human-readable IRC network name. |
| 19 | NetworkName string `yaml:"network_name"` |
| 20 | |
| @@ -94,6 +107,55 @@ | |
| 94 | c.Datastore.Driver = "sqlite" |
| 95 | } |
| 96 | if c.Datastore.DSN == "" { |
| 97 | c.Datastore.DSN = "./data/scuttlebot.db" |
| 98 | } |
| 99 | } |
| 100 |
| --- internal/config/config.go | |
| +++ internal/config/config.go | |
| @@ -1,20 +1,33 @@ | |
| 1 | // Package config defines scuttlebot's configuration schema. |
| 2 | package config |
| 3 | |
| 4 | import "os" |
| 5 | |
| 6 | // Config is the top-level scuttlebot configuration. |
| 7 | type Config struct { |
| 8 | Ergo ErgoConfig `yaml:"ergo"` |
| 9 | Datastore DatastoreConfig `yaml:"datastore"` |
| 10 | |
| 11 | // APIAddr is the address for scuttlebot's own HTTP management API. |
| 12 | // Default: ":8080" |
| 13 | APIAddr string `yaml:"api_addr"` |
| 14 | } |
| 15 | |
| 16 | // ErgoConfig holds settings for the managed Ergo IRC server. |
| 17 | type ErgoConfig struct { |
| 18 | // External disables subprocess management. When true, scuttlebot expects |
| 19 | // ergo to already be running and reachable at APIAddr and IRCAddr. |
| 20 | // Use this in Docker/K8s deployments where ergo runs as a separate container. |
| 21 | External bool `yaml:"external"` |
| 22 | |
| 23 | // BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH). |
| 24 | // Unused when External is true. |
| 25 | BinaryPath string `yaml:"binary_path"` |
| 26 | |
| 27 | // DataDir is the directory where Ergo stores ircd.db and generated config. |
| 28 | // Unused when External is true. |
| 29 | DataDir string `yaml:"data_dir"` |
| 30 | |
| 31 | // NetworkName is the human-readable IRC network name. |
| 32 | NetworkName string `yaml:"network_name"` |
| 33 | |
| @@ -94,6 +107,55 @@ | |
| 107 | c.Datastore.Driver = "sqlite" |
| 108 | } |
| 109 | if c.Datastore.DSN == "" { |
| 110 | c.Datastore.DSN = "./data/scuttlebot.db" |
| 111 | } |
| 112 | if c.APIAddr == "" { |
| 113 | c.APIAddr = ":8080" |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func envStr(key string) string { return os.Getenv(key) } |
| 118 | |
| 119 | // ApplyEnv overrides config values with SCUTTLEBOT_* environment variables. |
| 120 | // Call after Defaults() to allow env to override defaults. |
| 121 | // |
| 122 | // Supported variables: |
| 123 | // |
| 124 | // SCUTTLEBOT_API_ADDR — scuttlebot HTTP API listen address (e.g. ":8080") |
| 125 | // SCUTTLEBOT_DB_DRIVER — "sqlite" or "postgres" |
| 126 | // SCUTTLEBOT_DB_DSN — datastore connection string |
| 127 | // SCUTTLEBOT_ERGO_EXTERNAL — "true" to skip subprocess management |
| 128 | // SCUTTLEBOT_ERGO_API_ADDR — ergo HTTP API address (e.g. "http://ergo:8089") |
| 129 | // SCUTTLEBOT_ERGO_API_TOKEN — ergo HTTP API bearer token |
| 130 | // SCUTTLEBOT_ERGO_IRC_ADDR — ergo IRC listen/connect address (e.g. "ergo:6667") |
| 131 | // SCUTTLEBOT_ERGO_NETWORK_NAME — IRC network name |
| 132 | // SCUTTLEBOT_ERGO_SERVER_NAME — IRC server hostname |
| 133 | func (c *Config) ApplyEnv() { |
| 134 | if v := envStr("SCUTTLEBOT_API_ADDR"); v != "" { |
| 135 | c.APIAddr = v |
| 136 | } |
| 137 | if v := envStr("SCUTTLEBOT_DB_DRIVER"); v != "" { |
| 138 | c.Datastore.Driver = v |
| 139 | } |
| 140 | if v := envStr("SCUTTLEBOT_DB_DSN"); v != "" { |
| 141 | c.Datastore.DSN = v |
| 142 | } |
| 143 | if v := envStr("SCUTTLEBOT_ERGO_EXTERNAL"); v == "true" || v == "1" { |
| 144 | c.Ergo.External = true |
| 145 | } |
| 146 | if v := envStr("SCUTTLEBOT_ERGO_API_ADDR"); v != "" { |
| 147 | c.Ergo.APIAddr = v |
| 148 | } |
| 149 | if v := envStr("SCUTTLEBOT_ERGO_API_TOKEN"); v != "" { |
| 150 | c.Ergo.APIToken = v |
| 151 | } |
| 152 | if v := envStr("SCUTTLEBOT_ERGO_IRC_ADDR"); v != "" { |
| 153 | c.Ergo.IRCAddr = v |
| 154 | } |
| 155 | if v := envStr("SCUTTLEBOT_ERGO_NETWORK_NAME"); v != "" { |
| 156 | c.Ergo.NetworkName = v |
| 157 | } |
| 158 | if v := envStr("SCUTTLEBOT_ERGO_SERVER_NAME"); v != "" { |
| 159 | c.Ergo.ServerName = v |
| 160 | } |
| 161 | } |
| 162 |
+24
-4
| --- internal/ergo/manager.go | ||
| +++ internal/ergo/manager.go | ||
| @@ -39,15 +39,35 @@ | ||
| 39 | 39 | // API returns the Ergo HTTP API client. Available after Start succeeds. |
| 40 | 40 | func (m *Manager) API() *APIClient { |
| 41 | 41 | return m.api |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | -// Start writes the Ergo config, starts the subprocess, and waits for it to | |
| 45 | -// become healthy. It then runs the process in the background, restarting it | |
| 46 | -// with exponential backoff if it exits unexpectedly. Blocks until the | |
| 47 | -// context is cancelled. | |
| 44 | +// Start manages the Ergo IRC server. In managed mode (the default), it writes | |
| 45 | +// the Ergo config, starts the subprocess, waits for health, then keeps it | |
| 46 | +// alive with exponential backoff restarts. In external mode | |
| 47 | +// (cfg.External=true), it skips subprocess management and simply waits for the | |
| 48 | +// external ergo instance to become healthy, then blocks until ctx is done. | |
| 49 | +// Either way, Start blocks until ctx is cancelled. | |
| 48 | 50 | func (m *Manager) Start(ctx context.Context) error { |
| 51 | + if m.cfg.External { | |
| 52 | + return m.startExternal(ctx) | |
| 53 | + } | |
| 54 | + return m.startManaged(ctx) | |
| 55 | +} | |
| 56 | + | |
| 57 | +// startExternal waits for a pre-existing ergo to become healthy, then blocks. | |
| 58 | +func (m *Manager) startExternal(ctx context.Context) error { | |
| 59 | + m.log.Info("ergo external mode — waiting for ergo at", "addr", m.cfg.APIAddr) | |
| 60 | + if err := m.waitHealthy(ctx); err != nil { | |
| 61 | + return fmt.Errorf("ergo: did not become healthy: %w", err) | |
| 62 | + } | |
| 63 | + m.log.Info("ergo is healthy (external)") | |
| 64 | + <-ctx.Done() | |
| 65 | + return nil | |
| 66 | +} | |
| 67 | + | |
| 68 | +func (m *Manager) startManaged(ctx context.Context) error { | |
| 49 | 69 | if err := m.writeConfig(); err != nil { |
| 50 | 70 | return fmt.Errorf("ergo: write config: %w", err) |
| 51 | 71 | } |
| 52 | 72 | |
| 53 | 73 | wait := restartBaseWait |
| 54 | 74 |
| --- internal/ergo/manager.go | |
| +++ internal/ergo/manager.go | |
| @@ -39,15 +39,35 @@ | |
| 39 | // API returns the Ergo HTTP API client. Available after Start succeeds. |
| 40 | func (m *Manager) API() *APIClient { |
| 41 | return m.api |
| 42 | } |
| 43 | |
| 44 | // Start writes the Ergo config, starts the subprocess, and waits for it to |
| 45 | // become healthy. It then runs the process in the background, restarting it |
| 46 | // with exponential backoff if it exits unexpectedly. Blocks until the |
| 47 | // context is cancelled. |
| 48 | func (m *Manager) Start(ctx context.Context) error { |
| 49 | if err := m.writeConfig(); err != nil { |
| 50 | return fmt.Errorf("ergo: write config: %w", err) |
| 51 | } |
| 52 | |
| 53 | wait := restartBaseWait |
| 54 |
| --- internal/ergo/manager.go | |
| +++ internal/ergo/manager.go | |
| @@ -39,15 +39,35 @@ | |
| 39 | // API returns the Ergo HTTP API client. Available after Start succeeds. |
| 40 | func (m *Manager) API() *APIClient { |
| 41 | return m.api |
| 42 | } |
| 43 | |
| 44 | // Start manages the Ergo IRC server. In managed mode (the default), it writes |
| 45 | // the Ergo config, starts the subprocess, waits for health, then keeps it |
| 46 | // alive with exponential backoff restarts. In external mode |
| 47 | // (cfg.External=true), it skips subprocess management and simply waits for the |
| 48 | // external ergo instance to become healthy, then blocks until ctx is done. |
| 49 | // Either way, Start blocks until ctx is cancelled. |
| 50 | func (m *Manager) Start(ctx context.Context) error { |
| 51 | if m.cfg.External { |
| 52 | return m.startExternal(ctx) |
| 53 | } |
| 54 | return m.startManaged(ctx) |
| 55 | } |
| 56 | |
| 57 | // startExternal waits for a pre-existing ergo to become healthy, then blocks. |
| 58 | func (m *Manager) startExternal(ctx context.Context) error { |
| 59 | m.log.Info("ergo external mode — waiting for ergo at", "addr", m.cfg.APIAddr) |
| 60 | if err := m.waitHealthy(ctx); err != nil { |
| 61 | return fmt.Errorf("ergo: did not become healthy: %w", err) |
| 62 | } |
| 63 | m.log.Info("ergo is healthy (external)") |
| 64 | <-ctx.Done() |
| 65 | return nil |
| 66 | } |
| 67 | |
| 68 | func (m *Manager) startManaged(ctx context.Context) error { |
| 69 | if err := m.writeConfig(); err != nil { |
| 70 | return fmt.Errorf("ergo: write config: %w", err) |
| 71 | } |
| 72 | |
| 73 | wait := restartBaseWait |
| 74 |