|
c369cd5…
|
lmata
|
1 |
package ergo |
|
c369cd5…
|
lmata
|
2 |
|
|
c369cd5…
|
lmata
|
3 |
import ( |
|
c369cd5…
|
lmata
|
4 |
"context" |
|
c369cd5…
|
lmata
|
5 |
"fmt" |
|
c369cd5…
|
lmata
|
6 |
"log/slog" |
|
c369cd5…
|
lmata
|
7 |
"os" |
|
c369cd5…
|
lmata
|
8 |
"os/exec" |
|
c369cd5…
|
lmata
|
9 |
"path/filepath" |
|
c369cd5…
|
lmata
|
10 |
"time" |
|
c369cd5…
|
lmata
|
11 |
|
|
c369cd5…
|
lmata
|
12 |
"github.com/conflicthq/scuttlebot/internal/config" |
|
c369cd5…
|
lmata
|
13 |
) |
|
c369cd5…
|
lmata
|
14 |
|
|
c369cd5…
|
lmata
|
15 |
const ( |
|
c369cd5…
|
lmata
|
16 |
ircdConfigFile = "ircd.yaml" |
|
c369cd5…
|
lmata
|
17 |
restartBaseWait = 2 * time.Second |
|
c369cd5…
|
lmata
|
18 |
restartMaxWait = 60 * time.Second |
|
c369cd5…
|
lmata
|
19 |
healthTimeout = 30 * time.Second |
|
c369cd5…
|
lmata
|
20 |
healthInterval = 500 * time.Millisecond |
|
c369cd5…
|
lmata
|
21 |
) |
|
c369cd5…
|
lmata
|
22 |
|
|
c369cd5…
|
lmata
|
23 |
// Manager manages the Ergo IRC server subprocess. |
|
c369cd5…
|
lmata
|
24 |
type Manager struct { |
|
c369cd5…
|
lmata
|
25 |
cfg config.ErgoConfig |
|
c369cd5…
|
lmata
|
26 |
api *APIClient |
|
c369cd5…
|
lmata
|
27 |
log *slog.Logger |
|
c369cd5…
|
lmata
|
28 |
} |
|
c369cd5…
|
lmata
|
29 |
|
|
c369cd5…
|
lmata
|
30 |
// NewManager creates a new Manager. Call Start to launch the Ergo process. |
|
c369cd5…
|
lmata
|
31 |
func NewManager(cfg config.ErgoConfig, log *slog.Logger) *Manager { |
|
c369cd5…
|
lmata
|
32 |
return &Manager{ |
|
c369cd5…
|
lmata
|
33 |
cfg: cfg, |
|
c369cd5…
|
lmata
|
34 |
api: NewAPIClient(cfg.APIAddr, cfg.APIToken), |
|
c369cd5…
|
lmata
|
35 |
log: log, |
|
c369cd5…
|
lmata
|
36 |
} |
|
c369cd5…
|
lmata
|
37 |
} |
|
c369cd5…
|
lmata
|
38 |
|
|
c369cd5…
|
lmata
|
39 |
// API returns the Ergo HTTP API client. Available after Start succeeds. |
|
c369cd5…
|
lmata
|
40 |
func (m *Manager) API() *APIClient { |
|
c369cd5…
|
lmata
|
41 |
return m.api |
|
c369cd5…
|
lmata
|
42 |
} |
|
c369cd5…
|
lmata
|
43 |
|
|
2d8a379…
|
lmata
|
44 |
// Start manages the Ergo IRC server. In managed mode (the default), it writes |
|
2d8a379…
|
lmata
|
45 |
// the Ergo config, starts the subprocess, waits for health, then keeps it |
|
2d8a379…
|
lmata
|
46 |
// alive with exponential backoff restarts. In external mode |
|
2d8a379…
|
lmata
|
47 |
// (cfg.External=true), it skips subprocess management and simply waits for the |
|
2d8a379…
|
lmata
|
48 |
// external ergo instance to become healthy, then blocks until ctx is done. |
|
2d8a379…
|
lmata
|
49 |
// Either way, Start blocks until ctx is cancelled. |
|
c369cd5…
|
lmata
|
50 |
func (m *Manager) Start(ctx context.Context) error { |
|
2d8a379…
|
lmata
|
51 |
if m.cfg.External { |
|
2d8a379…
|
lmata
|
52 |
return m.startExternal(ctx) |
|
2d8a379…
|
lmata
|
53 |
} |
|
2d8a379…
|
lmata
|
54 |
return m.startManaged(ctx) |
|
2d8a379…
|
lmata
|
55 |
} |
|
2d8a379…
|
lmata
|
56 |
|
|
2d8a379…
|
lmata
|
57 |
// startExternal waits for a pre-existing ergo to become healthy, then blocks. |
|
2d8a379…
|
lmata
|
58 |
func (m *Manager) startExternal(ctx context.Context) error { |
|
2d8a379…
|
lmata
|
59 |
m.log.Info("ergo external mode — waiting for ergo at", "addr", m.cfg.APIAddr) |
|
2d8a379…
|
lmata
|
60 |
if err := m.waitHealthy(ctx); err != nil { |
|
2d8a379…
|
lmata
|
61 |
return fmt.Errorf("ergo: did not become healthy: %w", err) |
|
2d8a379…
|
lmata
|
62 |
} |
|
2d8a379…
|
lmata
|
63 |
m.log.Info("ergo is healthy (external)") |
|
2d8a379…
|
lmata
|
64 |
<-ctx.Done() |
|
2d8a379…
|
lmata
|
65 |
return nil |
|
2d8a379…
|
lmata
|
66 |
} |
|
2d8a379…
|
lmata
|
67 |
|
|
2d8a379…
|
lmata
|
68 |
func (m *Manager) startManaged(ctx context.Context) error { |
|
c369cd5…
|
lmata
|
69 |
if err := m.writeConfig(); err != nil { |
|
c369cd5…
|
lmata
|
70 |
return fmt.Errorf("ergo: write config: %w", err) |
|
c369cd5…
|
lmata
|
71 |
} |
|
c369cd5…
|
lmata
|
72 |
|
|
0e244d2…
|
lmata
|
73 |
var wait time.Duration //nolint:ineffassign |
|
c369cd5…
|
lmata
|
74 |
for { |
|
c369cd5…
|
lmata
|
75 |
if err := ctx.Err(); err != nil { |
|
c369cd5…
|
lmata
|
76 |
return nil |
|
c369cd5…
|
lmata
|
77 |
} |
|
c369cd5…
|
lmata
|
78 |
|
|
c369cd5…
|
lmata
|
79 |
m.log.Info("starting ergo", "binary", m.cfg.BinaryPath) |
|
c369cd5…
|
lmata
|
80 |
cmd := exec.CommandContext(ctx, m.cfg.BinaryPath, "run", "--conf", m.configPath()) |
|
c369cd5…
|
lmata
|
81 |
cmd.Stdout = os.Stdout |
|
c369cd5…
|
lmata
|
82 |
cmd.Stderr = os.Stderr |
|
c369cd5…
|
lmata
|
83 |
cmd.Dir = m.cfg.DataDir |
|
c369cd5…
|
lmata
|
84 |
|
|
c369cd5…
|
lmata
|
85 |
if err := cmd.Start(); err != nil { |
|
c369cd5…
|
lmata
|
86 |
return fmt.Errorf("ergo: start process: %w", err) |
|
c369cd5…
|
lmata
|
87 |
} |
|
c369cd5…
|
lmata
|
88 |
|
|
c369cd5…
|
lmata
|
89 |
if err := m.waitHealthy(ctx); err != nil { |
|
c369cd5…
|
lmata
|
90 |
_ = cmd.Process.Kill() |
|
c369cd5…
|
lmata
|
91 |
return fmt.Errorf("ergo: did not become healthy: %w", err) |
|
c369cd5…
|
lmata
|
92 |
} |
|
c369cd5…
|
lmata
|
93 |
m.log.Info("ergo is healthy") |
|
c369cd5…
|
lmata
|
94 |
wait = restartBaseWait // reset backoff on successful start |
|
c369cd5…
|
lmata
|
95 |
|
|
c369cd5…
|
lmata
|
96 |
// Wait for process exit. |
|
c369cd5…
|
lmata
|
97 |
done := make(chan error, 1) |
|
c369cd5…
|
lmata
|
98 |
go func() { done <- cmd.Wait() }() |
|
c369cd5…
|
lmata
|
99 |
|
|
c369cd5…
|
lmata
|
100 |
select { |
|
c369cd5…
|
lmata
|
101 |
case <-ctx.Done(): |
|
c369cd5…
|
lmata
|
102 |
m.log.Info("shutting down ergo") |
|
c369cd5…
|
lmata
|
103 |
_ = cmd.Process.Signal(os.Interrupt) |
|
c369cd5…
|
lmata
|
104 |
<-done |
|
c369cd5…
|
lmata
|
105 |
return nil |
|
c369cd5…
|
lmata
|
106 |
case err := <-done: |
|
c369cd5…
|
lmata
|
107 |
if ctx.Err() != nil { |
|
c369cd5…
|
lmata
|
108 |
return nil |
|
c369cd5…
|
lmata
|
109 |
} |
|
c369cd5…
|
lmata
|
110 |
m.log.Warn("ergo exited unexpectedly, restarting", "err", err, "wait", wait) |
|
c369cd5…
|
lmata
|
111 |
select { |
|
c369cd5…
|
lmata
|
112 |
case <-ctx.Done(): |
|
c369cd5…
|
lmata
|
113 |
return nil |
|
c369cd5…
|
lmata
|
114 |
case <-time.After(wait): |
|
c369cd5…
|
lmata
|
115 |
} |
|
1066004…
|
lmata
|
116 |
wait = min(wait*2, restartMaxWait) //nolint:ineffassign,staticcheck |
|
c369cd5…
|
lmata
|
117 |
} |
|
c369cd5…
|
lmata
|
118 |
} |
|
aeff8d0…
|
noreply
|
119 |
} |
|
aeff8d0…
|
noreply
|
120 |
|
|
aeff8d0…
|
noreply
|
121 |
// UpdateConfig replaces the Ergo config, regenerates ircd.yaml, and rehashes. |
|
aeff8d0…
|
noreply
|
122 |
// Use when scuttlebot.yaml Ergo settings change at runtime. |
|
aeff8d0…
|
noreply
|
123 |
func (m *Manager) UpdateConfig(cfg config.ErgoConfig) error { |
|
aeff8d0…
|
noreply
|
124 |
m.cfg = cfg |
|
aeff8d0…
|
noreply
|
125 |
return m.Rehash() |
|
c369cd5…
|
lmata
|
126 |
} |
|
c369cd5…
|
lmata
|
127 |
|
|
c369cd5…
|
lmata
|
128 |
// Rehash reloads the Ergo config. Call after writing a new ircd.yaml. |
|
c369cd5…
|
lmata
|
129 |
func (m *Manager) Rehash() error { |
|
c369cd5…
|
lmata
|
130 |
if err := m.writeConfig(); err != nil { |
|
c369cd5…
|
lmata
|
131 |
return fmt.Errorf("ergo: write config: %w", err) |
|
c369cd5…
|
lmata
|
132 |
} |
|
c369cd5…
|
lmata
|
133 |
return m.api.Rehash() |
|
c369cd5…
|
lmata
|
134 |
} |
|
c369cd5…
|
lmata
|
135 |
|
|
c369cd5…
|
lmata
|
136 |
func (m *Manager) writeConfig() error { |
|
c369cd5…
|
lmata
|
137 |
if err := os.MkdirAll(m.cfg.DataDir, 0o700); err != nil { |
|
c369cd5…
|
lmata
|
138 |
return err |
|
c369cd5…
|
lmata
|
139 |
} |
|
c369cd5…
|
lmata
|
140 |
data, err := GenerateConfig(m.cfg) |
|
c369cd5…
|
lmata
|
141 |
if err != nil { |
|
c369cd5…
|
lmata
|
142 |
return err |
|
c369cd5…
|
lmata
|
143 |
} |
|
c369cd5…
|
lmata
|
144 |
return os.WriteFile(m.configPath(), data, 0o600) |
|
c369cd5…
|
lmata
|
145 |
} |
|
c369cd5…
|
lmata
|
146 |
|
|
c369cd5…
|
lmata
|
147 |
func (m *Manager) configPath() string { |
|
c8d9310…
|
lmata
|
148 |
p := filepath.Join(m.cfg.DataDir, ircdConfigFile) |
|
c8d9310…
|
lmata
|
149 |
if abs, err := filepath.Abs(p); err == nil { |
|
c8d9310…
|
lmata
|
150 |
return abs |
|
c8d9310…
|
lmata
|
151 |
} |
|
c8d9310…
|
lmata
|
152 |
return p |
|
c369cd5…
|
lmata
|
153 |
} |
|
c369cd5…
|
lmata
|
154 |
|
|
c369cd5…
|
lmata
|
155 |
func (m *Manager) waitHealthy(ctx context.Context) error { |
|
c369cd5…
|
lmata
|
156 |
deadline := time.Now().Add(healthTimeout) |
|
c369cd5…
|
lmata
|
157 |
for time.Now().Before(deadline) { |
|
c369cd5…
|
lmata
|
158 |
if ctx.Err() != nil { |
|
c369cd5…
|
lmata
|
159 |
return ctx.Err() |
|
c369cd5…
|
lmata
|
160 |
} |
|
c369cd5…
|
lmata
|
161 |
if _, err := m.api.Status(); err == nil { |
|
c369cd5…
|
lmata
|
162 |
return nil |
|
c369cd5…
|
lmata
|
163 |
} |
|
c369cd5…
|
lmata
|
164 |
time.Sleep(healthInterval) |
|
c369cd5…
|
lmata
|
165 |
} |
|
c369cd5…
|
lmata
|
166 |
return fmt.Errorf("timed out after %s", healthTimeout) |
|
c369cd5…
|
lmata
|
167 |
} |
|
c369cd5…
|
lmata
|
168 |
|
|
c369cd5…
|
lmata
|
169 |
func min(a, b time.Duration) time.Duration { |
|
c369cd5…
|
lmata
|
170 |
if a < b { |
|
c369cd5…
|
lmata
|
171 |
return a |
|
c369cd5…
|
lmata
|
172 |
} |
|
c369cd5…
|
lmata
|
173 |
return b |
|
c369cd5…
|
lmata
|
174 |
} |