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

lmata 2026-03-31 05:13 trunk
Commit 2d8a379990b4963912b5af5065f04e1a14052f4d396b2f4b9924da01ca48f7e6
+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
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -23,10 +23,11 @@
2323
func main() {
2424
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
2525
2626
cfg := &config.Config{}
2727
cfg.Defaults()
28
+ cfg.ApplyEnv()
2829
2930
// Generate an API token for the Ergo management API if not set.
3031
if cfg.Ergo.APIToken == "" {
3132
cfg.Ergo.APIToken = mustGenToken()
3233
}
@@ -71,11 +72,11 @@
7172
// Start HTTP API server.
7273
apiToken := mustGenToken()
7374
log.Info("api token", "token", apiToken) // printed once on startup — user copies this
7475
apiSrv := api.New(reg, []string{apiToken}, log)
7576
httpServer := &http.Server{
76
- Addr: ":8080",
77
+ Addr: cfg.APIAddr,
7778
Handler: apiSrv.Handler(),
7879
}
7980
8081
go func() {
8182
log.Info("api server listening", "addr", httpServer.Addr)
8283
8384
ADDED deploy/compose/.env.example
8485
ADDED deploy/compose/README.md
8586
ADDED deploy/compose/docker-compose.override.yml
8687
ADDED deploy/compose/docker-compose.yml
8788
ADDED deploy/compose/ergo/Dockerfile
8889
ADDED deploy/compose/ergo/entrypoint.sh
8990
ADDED deploy/compose/ergo/ircd.yaml.tmpl
9091
ADDED deploy/docker/Dockerfile
9192
ADDED internal/api/agents.go
9293
ADDED internal/api/api_test.go
9394
ADDED internal/api/middleware.go
9495
ADDED internal/api/respond.go
9596
ADDED internal/api/server.go
9697
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
--- 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
--- 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
--- 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 }
--- 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(&regBody); 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(&regBody); 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
--- 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 }
--- 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 }
--- 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 }
--- internal/config/config.go
+++ internal/config/config.go
@@ -1,20 +1,33 @@
11
// Package config defines scuttlebot's configuration schema.
22
package config
3
+
4
+import "os"
35
46
// Config is the top-level scuttlebot configuration.
57
type Config struct {
6
- Ergo ErgoConfig `yaml:"ergo"`
8
+ Ergo ErgoConfig `yaml:"ergo"`
79
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"`
814
}
915
1016
// ErgoConfig holds settings for the managed Ergo IRC server.
1117
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
+
1223
// BinaryPath is the path to the ergo binary. Defaults to "ergo" (looks in PATH).
24
+ // Unused when External is true.
1325
BinaryPath string `yaml:"binary_path"`
1426
1527
// DataDir is the directory where Ergo stores ircd.db and generated config.
28
+ // Unused when External is true.
1629
DataDir string `yaml:"data_dir"`
1730
1831
// NetworkName is the human-readable IRC network name.
1932
NetworkName string `yaml:"network_name"`
2033
@@ -94,6 +107,55 @@
94107
c.Datastore.Driver = "sqlite"
95108
}
96109
if c.Datastore.DSN == "" {
97110
c.Datastore.DSN = "./data/scuttlebot.db"
98111
}
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
+ }
99161
}
100162
--- 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
--- internal/ergo/manager.go
+++ internal/ergo/manager.go
@@ -39,15 +39,35 @@
3939
// API returns the Ergo HTTP API client. Available after Start succeeds.
4040
func (m *Manager) API() *APIClient {
4141
return m.api
4242
}
4343
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.
4850
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 {
4969
if err := m.writeConfig(); err != nil {
5070
return fmt.Errorf("ergo: write config: %w", err)
5171
}
5272
5373
wait := restartBaseWait
5474
--- 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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button