-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgatekit.go
More file actions
358 lines (321 loc) · 12.5 KB
/
Copy pathgatekit.go
File metadata and controls
358 lines (321 loc) · 12.5 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
350
351
352
353
354
355
356
357
358
// Package gatekit merges and renders JSON files that independent modules
// produce, and decides whether a prompt should be blocked. The engine knows
// nothing about what any module checks -- it reads sources/*.json, applies a
// tiering and degrade policy generic to any domain, and returns a Decision.
// Everything domain-specific (what to check, how urgent, what to say) is a
// module's problem, entirely outside this package.
//
// Evaluate is pure and safe to call as often as needed; RequestBypass,
// Snooze, and Disable/Enable are the only functions with side effects, and
// each is independently testable.
package gatekit
import (
"encoding/json"
"time"
)
// Duration is a time.Duration that reads and writes as "15m" in JSON rather
// than as a bare count of nanoseconds nobody hand-editing a config file
// could eyeball.
type Duration time.Duration
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if s == "" {
*d = 0
return nil
}
v, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(v)
return nil
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
// D returns d as a plain time.Duration, for arithmetic.
func (d Duration) D() time.Duration { return time.Duration(d) }
// Item is one thing a module is asking the gate to consider. FirstSeenAt is
// owned by the module: it must be stamped once per item id and carried
// forward unchanged across the module's own runs, or its SLA countdown
// resets every time the module happens to run again.
type Item struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Reason string `json:"reason,omitempty"`
URL string `json:"url,omitempty"`
From string `json:"from,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
// SLASeconds is the grace period after FirstSeenAt before the item is
// even eligible to block. 0 means eligible immediately.
SLASeconds int `json:"sla_seconds"`
}
// DegradePolicy is a source's own choice for what happens to its items when
// its health is not OK. Almost every module wants Open; a module opts into
// Closed only when "I can't tell" is itself the thing that matters.
type DegradePolicy string
const (
// DegradeOpen is the default: a degraded source's items stay visible
// (pending/queued) but are never promoted to required.
DegradeOpen DegradePolicy = "open"
// DegradeClosed lets a degraded source's eligible items still block,
// with a warning explaining why -- for checks where not knowing is
// itself the problem.
DegradeClosed DegradePolicy = "closed"
)
// Source is the parsed contents of one sources/<module>.json file. Module
// should match the filename stem; a mismatch is a warning, not a failure,
// since the filename is what the engine actually groups by.
type Source struct {
Module string `json:"module"`
GeneratedAt time.Time `json:"generated_at"`
OK bool `json:"ok"`
OnDegraded DegradePolicy `json:"on_degraded,omitempty"`
Message string `json:"message,omitempty"`
Items []Item `json:"items"`
}
// HealthState is a source's engine-computed freshness, independent of what
// the source itself claims (see degrade.go: mtime can't be lied about by a
// hung module).
type HealthState int
const (
HealthOK HealthState = iota
HealthDegraded
HealthUnknown
)
func (h HealthState) String() string {
switch h {
case HealthDegraded:
return "degraded"
case HealthUnknown:
return "unknown"
default:
return "ok"
}
}
// worse orders HealthOK < HealthDegraded < HealthUnknown.
func worse(a, b HealthState) HealthState {
if b > a {
return b
}
return a
}
// Tier is where an item currently renders. Only Overdue actually blocks.
// The three are mutually exclusive states an item is genuinely in, not
// slices of one ranked list: an item is either not due, due but excused, or
// due. Whether something is due is decided entirely by the module, through
// first_seen_at plus sla_seconds; gatekit never ranks or rations.
type Tier int
const (
// Pending: before its SLA has elapsed. Not due, so it cannot block --
// rendered with a countdown to when it comes due.
Pending Tier = iota
// Held: due, but excused from blocking, either by an active snooze
// naming its key or because its source is degraded under an open
// policy. Visible, does not hold up the prompt. Every Held item carries
// the reason it is held (SnoozeHeld or SuppressedByDegrade), so "why is
// this not blocking" is always answerable from the item itself.
Held
// Overdue: due, with nothing excusing it. This blocks. Every Overdue
// item blocks at once; there is no front-N.
Overdue
)
func (t Tier) String() string {
switch t {
case Held:
return "held"
case Overdue:
return "overdue"
default:
return "pending"
}
}
// RenderItem is an Item plus everything the tiering pass computed about it.
type RenderItem struct {
Item
Module string `json:"module"`
// Key is Module + "/" + Item.ID -- the true identity for snooze
// snapshots, since two independent modules may reuse the same id string.
Key string `json:"key"`
Tier Tier `json:"tier"`
// AgeSince is how long it's been since FirstSeenAt.
AgeSince Duration `json:"age_since"`
// BecomesOverdueIn is set only when Tier == Pending.
BecomesOverdueIn Duration `json:"becomes_overdue_in,omitempty"`
// SuppressedByDegrade marks a due item in Held only because its source
// is unhealthy under an open degrade policy.
SuppressedByDegrade bool `json:"suppressed_by_degrade,omitempty"`
// SnoozeHeld marks a due item in Held only because an active snooze
// snapshot names its key.
SnoozeHeld bool `json:"snooze_held,omitempty"`
}
// MarshalJSON renders Tier as its string name ("pending"/"held"/"overdue")
// rather than a bare int, so `gatekit status` output is self-explanatory
// without cross-referencing the Go source.
func (t Tier) MarshalJSON() ([]byte, error) {
return json.Marshal(t.String())
}
// Decision is the full result of one Evaluate call.
type Decision struct {
Blocked bool `json:"blocked"`
Overdue []RenderItem `json:"overdue,omitempty"`
Held []RenderItem `json:"held,omitempty"`
Pending []RenderItem `json:"pending,omitempty"`
// Warnings covers degraded/unknown sources, unparseable source files,
// and an active kill switch or snooze -- anything that changes why the
// result looks the way it does.
Warnings []string `json:"warnings,omitempty"`
State HealthState `json:"state"`
Snooze *SnoozeInfo `json:"snooze,omitempty"`
GeneratedAt time.Time `json:"generated_at"`
}
// MarshalJSON renders HealthState as its string name rather than a bare int.
func (h HealthState) MarshalJSON() ([]byte, error) {
return json.Marshal(h.String())
}
// Config is every knob Evaluate and the side-effecting functions need.
// Zero-value fields are filled in by DefaultConfig; callers normally start
// from that rather than constructing a Config by hand.
type Config struct {
SourcesDir string `json:"-"`
StateDir string `json:"-"`
// StaleAfter/UnknownAfter are the DEFAULT file-mtime ages that flip a
// source's engine-computed health from OK to Degraded to Unknown,
// regardless of what the source file itself claims. A module whose
// natural cadence doesn't fit these (an hourly cron job vs. an
// always-on daemon publishing every 60s) should get an entry in
// ModulePolicies instead of forcing every module to share one clock.
StaleAfter Duration `json:"stale_after"`
UnknownAfter Duration `json:"unknown_after"`
// ModulePolicies overrides StaleAfter/UnknownAfter for one module by
// name (matching its sources/<module>.json filename stem). Only the
// fields actually set here are overridden; a zero-value field falls
// back to the engine-wide default. This is separate from a source's
// own on_degraded, which a module sets in its OWN output and which
// decides what a degraded state DOES (suppress vs. still block) --
// ModulePolicies only decides HOW STALE is stale for that one module.
ModulePolicies map[string]ModulePolicy `json:"module_policies,omitempty"`
// BypassRateLimit/BypassWindow/BypassCooldown bound the escape valves
// (RequestBypass and Snooze both draw from this one shared budget).
// This is deliberately separate from, and much stricter than, the kill
// switch (Disable/Enable), which has no rate limit at all -- see
// killswitch.go for why.
BypassRateLimit int `json:"bypass_rate_limit"`
BypassWindow Duration `json:"bypass_window"`
BypassCooldown Duration `json:"bypass_cooldown"`
}
// ModulePolicy is a per-module override of the engine-wide staleness
// defaults. A zero Duration (the JSON field simply omitted) means "use the
// engine default for this one," not "zero tolerance" -- see
// staleThresholdsFor in degrade.go.
type ModulePolicy struct {
StaleAfter Duration `json:"stale_after,omitempty"`
UnknownAfter Duration `json:"unknown_after,omitempty"`
}
// DefaultConfig returns sane defaults for everything except SourcesDir and
// StateDir, which the caller must always set.
func DefaultConfig(sourcesDir, stateDir string) Config {
return Config{
SourcesDir: sourcesDir,
StateDir: stateDir,
StaleAfter: Duration(15 * time.Minute),
UnknownAfter: Duration(2 * time.Hour),
BypassRateLimit: 2,
BypassWindow: Duration(time.Hour),
BypassCooldown: Duration(5 * time.Minute),
}
}
// Evaluate reads sources/*.json, applies the degrade and tiering policy, and
// returns the current Decision. It is pure: it never writes anything except
// via readJSONLReverse/readJSON's own file handles, which it only reads
// from. Safe to call as often as needed -- gatekit status calls it too.
func Evaluate(cfg Config, now time.Time) (Decision, error) {
if isDisabled(cfg, now) {
return Decision{
Blocked: false,
Warnings: []string{"gatekit is disabled (kill switch active); run `gatekit on` to resume"},
GeneratedAt: now,
}, nil
}
sources, err := loadSources(cfg.SourcesDir)
if err != nil {
return Decision{}, err
}
snooze := readSnooze(cfg, now)
var snoozedKeys map[string]bool
if snooze != nil {
snoozedKeys = make(map[string]bool, len(snooze.SnapshotKeys))
for _, k := range snooze.SnapshotKeys {
snoozedKeys[k] = true
}
}
reloadAt, reloadRequested := lastReloadRequest(cfg)
var all []RenderItem
var warnings []string
worst := HealthOK
for _, ls := range sources {
staleAfter, unknownAfter := staleThresholdsFor(cfg, ls.Module)
health, msg := computeHealth(ls, now, staleAfter, unknownAfter)
if health != HealthOK {
worst = worse(worst, health)
if msg != "" {
warnings = append(warnings, msg)
}
}
if !ls.parsed {
continue // a file that doesn't parse contributes zero items, always
}
// A parsed source can still carry its own warning (e.g. a filename/
// module mismatch) independent of health -- computeHealth's msg
// above only covers freshness/ok, so this is never a duplicate.
if ls.warning != "" {
warnings = append(warnings, ls.warning)
}
// Purely observational: gatekit cannot make a module re-poll, only
// report whether one has caught up since the last RequestReload. A
// module unaware of reload entirely still clears this the moment it
// next republishes for any reason -- mtime is the only fact checked,
// not whether the module actually watched for the request.
if reloadRequested && ls.mtime.Before(reloadAt) {
warnings = append(warnings, ls.Module+": not refreshed since reload requested "+
now.Sub(reloadAt).Round(time.Second).String()+" ago")
}
// A degraded source sends its due items to Held only under its own
// on_degraded=open (the default) -- closed means a due item still
// blocks, with the warning above explaining why.
policy := ls.OnDegraded
if policy == "" {
policy = DegradeOpen
}
suppress := health != HealthOK && policy != DegradeClosed
all = append(all, classify(ls, now, suppress)...)
}
applySnooze(all, snoozedKeys)
var dec Decision
for _, ri := range all {
switch ri.Tier {
case Overdue:
dec.Overdue = append(dec.Overdue, ri)
case Held:
dec.Held = append(dec.Held, ri)
default:
dec.Pending = append(dec.Pending, ri)
}
}
sortByFirstSeenAt(dec.Overdue)
sortByFirstSeenAt(dec.Held)
sortByBecomesOverdueIn(dec.Pending)
if snooze != nil {
warnings = append(warnings, "snoozed until "+snooze.Until.Format(time.RFC3339))
dec.Snooze = snooze
}
dec.Blocked = len(dec.Overdue) > 0
dec.Warnings = warnings
dec.State = worst
dec.GeneratedAt = now
return dec, nil
}