|
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
|
|