Repository navigation
Expand file tree
/
Copy pathdoctor_cmd.go
More file actions
349 lines (327 loc) · 12.3 KB
/
Copy pathdoctor_cmd.go
File metadata and controls
349 lines (327 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/renezander030/draftcat/internal/config"
"github.com/renezander030/draftcat/internal/schedule"
skillsapi "github.com/renezander030/draftcat/internal/skills"
statestore "github.com/renezander030/draftcat/internal/state"
"github.com/renezander030/draftcat/internal/validate"
)
// doctorCheck is one line of `draftcat doctor`. Status is "ok", "warn" or
// "fail"; Hint says what to do when it is not ok.
type doctorCheck struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
Hint string `json:"hint,omitempty"`
}
type doctorReport struct {
OK bool `json:"ok"`
Checks []doctorCheck `json:"checks"`
}
func (r *doctorReport) add(name, status, detail, hint string) {
r.Checks = append(r.Checks, doctorCheck{Name: name, Status: status, Detail: detail, Hint: hint})
}
// runDoctor implements `draftcat doctor`: a read-only preflight of everything
// the engine needs at start — config, credentials, operator channel, state
// store, listeners and schedules. It never writes and never creates the state
// store; it contacts Telegram, the provider and the relay only with --online,
// and then only with read-only calls. Exit 1 when any check fails.
func runDoctor(args []string, stdout io.Writer) int {
configPath := "config.yaml"
skillsDir := "skills"
jsonOut, online := false, false
for i := 0; i < len(args); i++ {
switch a := args[i]; a {
case "--json", "-json":
jsonOut = true
case "--online", "-online":
online = true
case "--config", "-config":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "doctor: --config requires a path")
return 2
}
configPath = args[i+1]
i++
case "--skills", "-skills":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "doctor: --skills requires a path")
return 2
}
skillsDir = args[i+1]
i++
case "-h", "--help", "help":
_, _ = io.WriteString(stdout, "Usage: draftcat doctor [--config path] [--skills dir] [--online] [--json]\n\n"+
"Read-only preflight: config, credentials, operator channel, state store, listeners, schedules.\n"+
"--online also asks Telegram, the provider and the relay whether they accept the credentials,\n"+
"with read-only calls that send no message and spend no tokens.\n"+
"Exits 1 when a check fails.\n")
return 0
default:
fmt.Fprintf(os.Stderr, "doctor: unknown option %q\n", a)
return 2
}
}
// The report is the output; loader chatter would interleave with it.
log.SetOutput(io.Discard)
defer log.SetOutput(os.Stderr)
rep := doctor(configPath, skillsDir, time.Now(), online)
if jsonOut {
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
_ = enc.Encode(rep)
} else {
printDoctor(stdout, rep)
}
if !rep.OK {
return 1
}
return 0
}
// doctor runs every check on one config; online adds the remote probes.
func doctor(configPath, skillsDir string, now time.Time, online bool) (rep doctorReport) {
defer func() {
rep.OK = true
for _, c := range rep.Checks {
if c.Status == "fail" {
rep.OK = false
}
}
}()
// #nosec G304 -- the operator's own --config argument.
data, err := os.ReadFile(configPath)
if err != nil {
rep.add("config", "fail", fmt.Sprintf("cannot read %s: %v", configPath, err), "pass --config <path> or run from the directory holding config.yaml")
return rep
}
var cfg config.Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
rep.add("config", "fail", fmt.Sprintf("cannot parse %s: %v", configPath, err), "fix the YAML syntax, then run draftcat validate")
return rep
}
rep.add("config", "ok", fmt.Sprintf("%s: %d pipeline(s)", configPath, len(cfg.Pipelines)), "")
errs, warns := 0, 0
var firstErr string
for _, f := range validate.CheckAtStartup(&cfg, skillsDir) {
if f.Level == "error" {
if errs == 0 {
firstErr = f.Path + ": " + f.Message
}
errs++
} else {
warns++
}
}
switch {
case errs > 0:
rep.add("validate", "fail", fmt.Sprintf("%d error(s), %d warning(s); first: %s", errs, warns, firstErr), "run draftcat validate for the full list")
case warns > 0:
rep.add("validate", "warn", fmt.Sprintf("%d warning(s)", warns), "run draftcat validate for details")
default:
rep.add("validate", "ok", "no findings", "")
}
doctorSkills(&rep, skillsDir)
doctorChannel(&rep, &cfg)
doctorProvider(&rep, &cfg)
doctorSigning(&rep)
doctorSecretsFile(&rep)
doctorState(&rep, &cfg, now)
doctorListeners(&rep, &cfg)
doctorSchedules(&rep, &cfg, now)
if online {
doctorOnline(&rep, &cfg)
}
return rep
}
func envSet(name string) bool { return strings.TrimSpace(os.Getenv(name)) != "" }
func doctorSkills(rep *doctorReport, dir string) {
reg, err := skillsapi.LoadSkills(dir)
if err != nil {
rep.add("skills", "warn", fmt.Sprintf("%s: %v", dir, err), "pass the skills directory as --skills <dir>")
return
}
rep.add("skills", "ok", fmt.Sprintf("%s: %d skill(s)", dir, len(reg.List())), "")
}
func doctorChannel(rep *doctorReport, cfg *config.Config) {
tokenEnv := cfg.Telegram.TokenEnvName()
if envSet(tokenEnv) || envSet(config.DefaultTelegramTokenEnv) {
rep.add("telegram token", "ok", tokenEnv+" is set", "")
} else {
rep.add("telegram token", "fail", tokenEnv+" is empty", "export "+tokenEnv+"=<bot token from @BotFather>; the engine refuses to start without it")
}
chat := cfg.Telegram.ChatID != 0 || envSet("DRAFTCAT_TG_CHAT_ID") || len(cfg.Telegram.Security.AllowedUsers) > 0 || envSet("DRAFTCAT_TG_ALLOWED_USERS")
users := len(cfg.Telegram.Security.AllowedUsers) > 0 || envSet("DRAFTCAT_TG_ALLOWED_USERS")
switch {
case !users:
rep.add("operators", "fail", "no telegram.security.allowed_users", "set allowed_users in config or DRAFTCAT_TG_ALLOWED_USERS=<id,id>; without an operator no approval can be decided")
case !chat:
rep.add("operators", "fail", "no chat_id", "set telegram.chat_id or DRAFTCAT_TG_CHAT_ID")
default:
rep.add("operators", "ok", "operator chat and allowed users configured", "")
}
if cfg.Relay.Enabled() {
secretEnv := cfg.Relay.SecretEnvName()
if envSet(secretEnv) || envSet(config.DefaultRelaySecretEnv) {
rep.add("relay", "ok", fmt.Sprintf("approvals go to %s; %s is set", cfg.Relay.URL, secretEnv), "")
} else {
rep.add("relay", "fail", secretEnv+" is empty", "export the shared HMAC secret the relay was configured with")
}
}
}
func doctorProvider(rep *doctorReport, cfg *config.Config) {
keyEnv := cfg.Provider.APIKeyEnvName()
if envSet(keyEnv) || envSet(config.DefaultProviderKeyEnv) {
rep.add("provider key", "ok", keyEnv+" is set", "")
} else {
rep.add("provider key", "fail", keyEnv+" is empty", "export "+keyEnv+"=<provider API key>")
}
if cfg.GHL.APIKeyEnv != "" && !envSet(cfg.GHL.APIKeyEnv) {
rep.add("gohighlevel key", "warn", cfg.GHL.APIKeyEnv+" is empty", "GoHighLevel steps fail until it is set")
}
if cfg.Observ.OTLP.Enabled && cfg.Observ.OTLP.HeaderEnv != "" && !envSet(cfg.Observ.OTLP.HeaderEnv) {
rep.add("otlp headers", "warn", cfg.Observ.OTLP.HeaderEnv+" is empty", "the collector may reject unauthenticated spans")
}
}
func doctorSigning(rep *doctorReport) {
if envSet("DRAFTCAT_APPROVAL_SECRET") {
rep.add("receipt signing", "ok", "DRAFTCAT_APPROVAL_SECRET is set; approval receipts are signed", "")
return
}
rep.add("receipt signing", "warn", "DRAFTCAT_APPROVAL_SECRET is empty; approval receipts are recorded unsigned", "export a long random secret so audit-verify and receipts verify can prove each decision")
}
func doctorSecretsFile(rep *doctorReport) {
fi, err := os.Stat("secrets.yaml")
if err != nil {
return
}
if fi.Mode().Perm()&0o077 != 0 {
rep.add("secrets.yaml", "warn", fmt.Sprintf("mode %04o: readable by other users", fi.Mode().Perm()), "chmod 600 secrets.yaml")
return
}
rep.add("secrets.yaml", "ok", fmt.Sprintf("mode %04o", fi.Mode().Perm()), "")
}
// doctorState inspects the state store without creating or migrating it.
func doctorState(rep *doctorReport, cfg *config.Config, now time.Time) {
path := resolveStatePath(cfg)
if _, err := os.Stat(path); err != nil {
dir := filepath.Dir(path)
if fi, derr := os.Stat(dir); derr != nil || !fi.IsDir() {
rep.add("state store", "fail", fmt.Sprintf("%s does not exist and %s is not a directory", path, dir), "create the directory or set state.path / DRAFTCAT_STATE_PATH")
return
}
probe, perr := os.CreateTemp(dir, ".draftcat-doctor-*")
if perr != nil {
rep.add("state store", "fail", fmt.Sprintf("%s will be created, but %s is not writable: %v", path, dir, perr), "fix the directory permissions or point state.path at a writable volume")
return
}
name := probe.Name()
_ = probe.Close()
_ = os.Remove(name)
rep.add("state store", "ok", fmt.Sprintf("%s will be created at first start", path), "")
return
}
st, err := statestore.OpenStateStoreReadOnly(path)
if err != nil {
rep.add("state store", "fail", fmt.Sprintf("cannot open %s: %v", path, err), "check the file permissions; the engine needs read-write access")
return
}
defer func() { _ = st.Close() }()
day, err := st.BudgetDay(context.Background(), now)
if err != nil {
rep.add("state store", "warn", fmt.Sprintf("%s opened, but the usage ledger is unreadable: %v", path, err), "start the engine once to upgrade an older store")
return
}
rep.add("state store", "ok", fmt.Sprintf("%s: today %d tokens, %.4f spent", path, day.Tokens, day.Cost), "")
if day.Unsettled > 0 {
rep.add("budget", "warn", fmt.Sprintf("%d provider call(s) with unsettled usage block new model calls", day.Unsettled), "run draftcat budget status, then draftcat budget reconcile")
}
if open, err := st.OpenApprovals(); err == nil && len(open) > 0 {
rep.add("approvals", "warn", fmt.Sprintf("%d approval gate(s) still open", len(open)), "they are closed out and reported at the next engine start; see draftcat pending")
}
}
func doctorListeners(rep *doctorReport, cfg *config.Config) {
if cfg.Webhook.Enabled {
addr := firstNonEmpty(cfg.Webhook.Addr, "127.0.0.1:8088")
if cfg.Webhook.SecretEnv == "" || !envSet(cfg.Webhook.SecretEnv) {
rep.add("webhook", "fail", "webhook.enabled but its secret env is empty", "set webhook.secret_env and export that variable; the engine refuses an unauthenticated trigger")
} else {
checkListen(rep, "webhook", addr)
}
}
if cfg.Observ.Prometheus.Enabled {
checkListen(rep, "metrics", firstNonEmpty(cfg.Observ.Prometheus.Addr, "127.0.0.1:9090"))
}
}
func checkListen(rep *doctorReport, name, addr string) {
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", addr)
if err != nil {
rep.add(name, "warn", fmt.Sprintf("%s is not free: %v", addr, err), "another process (or a running engine) holds the port")
return
}
_ = ln.Close()
rep.add(name, "ok", addr+" is free", "")
}
func doctorSchedules(rep *doctorReport, cfg *config.Config, now time.Time) {
for _, p := range cfg.Pipelines {
if !schedule.IsTimer(p.Schedule) {
continue
}
sc, err := schedule.Parse(p.Schedule, p.Timezone)
if err != nil {
rep.add("schedule "+p.Name, "fail", err.Error(), "fix pipelines[].schedule")
continue
}
next := sc.Next(now)
if next.IsZero() {
rep.add("schedule "+p.Name, "warn", p.Schedule+" never fires", "check the day and month fields")
continue
}
detail := fmt.Sprintf("%s: next run %s", p.Schedule, next.In(sc.Location()).Format("Mon 02 Jan 2006 15:04 MST"))
if sc.IsInterval() {
detail = fmt.Sprintf("every %s (continues from the last recorded run)", sc.Interval())
}
rep.add("schedule "+p.Name, "ok", detail, "")
}
}
func printDoctor(w io.Writer, rep doctorReport) {
width := 0
for _, c := range rep.Checks {
if len(c.Name) > width {
width = len(c.Name)
}
}
var sb strings.Builder
fails, warns := 0, 0
for _, c := range rep.Checks {
sb.WriteString(fmt.Sprintf("%-4s %-*s %s\n", c.Status, width, c.Name, c.Detail))
if c.Hint != "" && c.Status != "ok" {
sb.WriteString(fmt.Sprintf(" %-*s → %s\n", width, "", c.Hint))
}
switch c.Status {
case "fail":
fails++
case "warn":
warns++
}
}
switch {
case fails > 0:
sb.WriteString(fmt.Sprintf("\n%d check(s) failed, %d warning(s). The engine will not start cleanly until the failures are fixed.\n", fails, warns))
case warns > 0:
sb.WriteString(fmt.Sprintf("\nReady to start, with %d warning(s).\n", warns))
default:
sb.WriteString("\nReady to start.\n")
}
_, _ = io.WriteString(w, sb.String())
}