ScuttleBot

scuttlebot / internal / ergo / manager.go
Source Blame History 174 lines
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 }

Keyboard Shortcuts

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