ScuttleBot

scuttlebot / internal / ergo / manager.go
Blame History Raw 175 lines
1
package ergo
2
3
import (
4
"context"
5
"fmt"
6
"log/slog"
7
"os"
8
"os/exec"
9
"path/filepath"
10
"time"
11
12
"github.com/conflicthq/scuttlebot/internal/config"
13
)
14
15
const (
16
ircdConfigFile = "ircd.yaml"
17
restartBaseWait = 2 * time.Second
18
restartMaxWait = 60 * time.Second
19
healthTimeout = 30 * time.Second
20
healthInterval = 500 * time.Millisecond
21
)
22
23
// Manager manages the Ergo IRC server subprocess.
24
type Manager struct {
25
cfg config.ErgoConfig
26
api *APIClient
27
log *slog.Logger
28
}
29
30
// NewManager creates a new Manager. Call Start to launch the Ergo process.
31
func NewManager(cfg config.ErgoConfig, log *slog.Logger) *Manager {
32
return &Manager{
33
cfg: cfg,
34
api: NewAPIClient(cfg.APIAddr, cfg.APIToken),
35
log: log,
36
}
37
}
38
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
var wait time.Duration //nolint:ineffassign
74
for {
75
if err := ctx.Err(); err != nil {
76
return nil
77
}
78
79
m.log.Info("starting ergo", "binary", m.cfg.BinaryPath)
80
cmd := exec.CommandContext(ctx, m.cfg.BinaryPath, "run", "--conf", m.configPath())
81
cmd.Stdout = os.Stdout
82
cmd.Stderr = os.Stderr
83
cmd.Dir = m.cfg.DataDir
84
85
if err := cmd.Start(); err != nil {
86
return fmt.Errorf("ergo: start process: %w", err)
87
}
88
89
if err := m.waitHealthy(ctx); err != nil {
90
_ = cmd.Process.Kill()
91
return fmt.Errorf("ergo: did not become healthy: %w", err)
92
}
93
m.log.Info("ergo is healthy")
94
wait = restartBaseWait // reset backoff on successful start
95
96
// Wait for process exit.
97
done := make(chan error, 1)
98
go func() { done <- cmd.Wait() }()
99
100
select {
101
case <-ctx.Done():
102
m.log.Info("shutting down ergo")
103
_ = cmd.Process.Signal(os.Interrupt)
104
<-done
105
return nil
106
case err := <-done:
107
if ctx.Err() != nil {
108
return nil
109
}
110
m.log.Warn("ergo exited unexpectedly, restarting", "err", err, "wait", wait)
111
select {
112
case <-ctx.Done():
113
return nil
114
case <-time.After(wait):
115
}
116
wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck
117
}
118
}
119
}
120
121
// UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes.
122
// Use when scuttlebot.yaml Ergo settings change at runtime.
123
func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error {
124
m.cfg = cfg
125
return m.Rehash()
126
}
127
128
// Rehash reloads the Ergo config. Call after writing a new ircd.yaml.
129
func (m *Manager) Rehash() error {
130
if err := m.writeConfig(); err != nil {
131
return fmt.Errorf("ergo: write config: %w", err)
132
}
133
return m.api.Rehash()
134
}
135
136
func (m *Manager) writeConfig() error {
137
if err := os.MkdirAll(m.cfg.DataDir, 0o700); err != nil {
138
return err
139
}
140
data, err := GenerateConfig(m.cfg)
141
if err != nil {
142
return err
143
}
144
return os.WriteFile(m.configPath(), data, 0o600)
145
}
146
147
func (m *Manager) configPath() string {
148
p := filepath.Join(m.cfg.DataDir, ircdConfigFile)
149
if abs, err := filepath.Abs(p); err == nil {
150
return abs
151
}
152
return p
153
}
154
155
func (m *Manager) waitHealthy(ctx context.Context) error {
156
deadline := time.Now().Add(healthTimeout)
157
for time.Now().Before(deadline) {
158
if ctx.Err() != nil {
159
return ctx.Err()
160
}
161
if _, err := m.api.Status(); err == nil {
162
return nil
163
}
164
time.Sleep(healthInterval)
165
}
166
return fmt.Errorf("timed out after %s", healthTimeout)
167
}
168
169
func min(a, b time.Duration) time.Duration {
170
if a < b {
171
return a
172
}
173
return b
174
}
175

Keyboard Shortcuts

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