@vibelord/lanes 1.2.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""throttle-pulse --once|--loop [--control URL] [--state-dir DIR] [--token-file FILE] — the Throttle daemon's live facts,
|
|
3
|
+
read where the daemon runs (W11, spec docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md §2.1).
|
|
4
|
+
|
|
5
|
+
The daemon binds its own container's 127.0.0.1 (DEF-1525): a host poller cannot reach it. This script is shipped by
|
|
6
|
+
`lanes install-container` into ~/.lz/bin/ of every workspace container and runs, as a loop, only in the daemon's
|
|
7
|
+
container (`tmux -L lanes-pulse` session `pulse`, started by install-container). Every PULSE_INTERVAL seconds it GETs,
|
|
8
|
+
on the control port (`--control`, else http://127.0.0.1:$LANES_THROTTLE_CONTROL_PORT, else :19181):
|
|
9
|
+
|
|
10
|
+
open /pools/<pool>/ready for each of POOLS (a 503 still carries the JSON body: the pool is not ready)
|
|
11
|
+
/metrics (Prometheus text: four `throttle_*{pool="…"}` series)
|
|
12
|
+
tokened /pools/<pool>/status for each of POOLS with `Authorization: Bearer <token>` — only while the token
|
|
13
|
+
file exists (~/.lz/throttle-token, mode 0600; `lanes throttle adopt-token <container>` writes it). The
|
|
14
|
+
token is the LOCAL proxy bearer (`throttle_…`), never a provider credential. Every guarded control route
|
|
15
|
+
goes through the daemon's ControlAuth with no loopback exemption, so without it /status answers 401.
|
|
16
|
+
|
|
17
|
+
and writes ~/.lz/state/throttle.json (the shape below, by rename) only when a value changed or HEARTBEAT seconds passed
|
|
18
|
+
since the file's `at` — `at` is the liveness heartbeat lanes-statusd ages:
|
|
19
|
+
|
|
20
|
+
{"v": 1, "at": <epoch s>, "up": bool, "tokened": bool,
|
|
21
|
+
"pools": {"claude"|"codex"|"kimi": {"configured", "usable", "cooling", "exhausted", "benched", "auth_failed",
|
|
22
|
+
"overflow_ready", "pressure_phase", "spill_fraction", "overflow_in_flight", "upstream_429_total",
|
|
23
|
+
"worst_7d", "worst_5h", "soonest_reset"}},
|
|
24
|
+
"overflow": {"claude"|"codex"|"kimi": {"spend_today_usd", "budget_usd", "fraction", "alert_level_pct"}},
|
|
25
|
+
"error": null | "<why the daemon did not answer>"}
|
|
26
|
+
|
|
27
|
+
`cooling` is the remainder configured − usable − exhausted − auth_failed − disabled − billing/org-blocked − unhealthy,
|
|
28
|
+
floored at 0 (the daemon's buckets can overlap); `benched` is `model_benched_subscriptions`. `pressure_phase` is the
|
|
29
|
+
daemon's own word for the gauge (0 `closed` — calm —, 1 `spilling`, 2 `elevated`). `worst_7d` / `worst_5h` are the highest measured
|
|
30
|
+
utilization (0–1) over the pool's subscription accounts, `soonest_reset` the earliest future reset (epoch s) of either
|
|
31
|
+
window; they and `overflow` stay null without the token. `up` is false only when no open GET answered: the previous
|
|
32
|
+
pools are then kept, `error` says why. Why a source failed goes to stderr (the tmux pane, the journal) each time it
|
|
33
|
+
changes, never into the file and never the token.
|
|
34
|
+
|
|
35
|
+
POOLS are claude, codex and (W12, Lanes 1.5.0) kimi: the daemon's `/pools/:pool/ready` and `/status` routes are
|
|
36
|
+
generic (`src/proxy.rs` `scoped_pool_state`), and a daemon with no Kimi account answers `/pools/kimi/ready` with
|
|
37
|
+
`configured_subscriptions: 0` — a pool with nothing configured, not a failure. Kimi has no paid overflow lane (the
|
|
38
|
+
daemon keeps its `api_overflow` at the default, and its /status still renders a zero overflow block), so only
|
|
39
|
+
MONEY_POOLS carry overflow money: `overflow.kimi` is always nulls.
|
|
40
|
+
|
|
41
|
+
GET only, 3 s timeout, 1 MiB body cap, no redirect followed (one would carry the bearer elsewhere), no proxy from the
|
|
42
|
+
environment. Stdlib only. Exit: --once 0 when the daemon answered, 1 when not, 2 on a usage error; --loop runs until
|
|
43
|
+
SIGTERM/SIGINT.
|
|
44
|
+
"""
|
|
45
|
+
import argparse
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import signal
|
|
50
|
+
import stat
|
|
51
|
+
import sys
|
|
52
|
+
import tempfile
|
|
53
|
+
import time
|
|
54
|
+
import urllib.error
|
|
55
|
+
import urllib.request
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
SCHEMA = 1
|
|
59
|
+
PULSE_INTERVAL = 10 # seconds between two cycles of --loop
|
|
60
|
+
HEARTBEAT = 60 # throttle.json is rewritten at least this often (its `at`)
|
|
61
|
+
TIMEOUT = 3 # seconds per GET
|
|
62
|
+
MAX_BODY = 1 << 20 # bytes: a bigger answer is not the daemon's
|
|
63
|
+
TOKEN_MAX = 4096 # bytes: a token file is one short line
|
|
64
|
+
DEFAULT_PORT = 19181
|
|
65
|
+
POOLS = ('claude', 'codex', 'kimi')
|
|
66
|
+
MONEY_POOLS = frozenset({'claude', 'codex'}) # the pools with a paid overflow lane; kimi's money is always null
|
|
67
|
+
# The gauge's numbers (src/proxy.rs render_metrics: 0=calm,1=spilling,2=elevated) as the words the daemon's own JSON
|
|
68
|
+
# uses for them (src/pressure.rs PressureSnapshot.phase: "closed" is calm), which economy.json and the sidebar read.
|
|
69
|
+
PHASES = {0: 'closed', 1: 'spilling', 2: 'elevated'}
|
|
70
|
+
SUBSCRIPTION_PROVIDERS = frozenset({'anthropic', 'openai', 'kimi'}) # src/proxy.rs is_subscription_provider
|
|
71
|
+
SERIES = {'throttle_pool_spill_fraction': 'spill_fraction', 'throttle_overflow_in_flight': 'overflow_in_flight',
|
|
72
|
+
'throttle_upstream_429_total': 'upstream_429_total', 'throttle_pool_pressure_phase': 'pressure_phase'}
|
|
73
|
+
POOL_KEYS = ('configured', 'usable', 'cooling', 'exhausted', 'benched', 'auth_failed', 'overflow_ready',
|
|
74
|
+
'pressure_phase', 'spill_fraction', 'overflow_in_flight', 'upstream_429_total', 'worst_7d', 'worst_5h',
|
|
75
|
+
'soonest_reset')
|
|
76
|
+
OVERFLOW_KEYS = ('spend_today_usd', 'budget_usd', 'fraction', 'alert_level_pct')
|
|
77
|
+
TOKENED_POOL_KEYS = ('worst_7d', 'worst_5h', 'soonest_reset')
|
|
78
|
+
STATE_FILE = 'throttle.json'
|
|
79
|
+
METRIC_LINE = re.compile(r'^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+(\S+)')
|
|
80
|
+
LABEL = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def empty_pool():
|
|
84
|
+
return dict.fromkeys(POOL_KEYS)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def empty_overflow():
|
|
88
|
+
return dict.fromkeys(OVERFLOW_KEYS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def empty_document():
|
|
92
|
+
return {'v': SCHEMA, 'at': None, 'up': False, 'tokened': False,
|
|
93
|
+
'pools': {pool: empty_pool() for pool in POOLS},
|
|
94
|
+
'overflow': {pool: empty_overflow() for pool in POOLS}, 'error': None}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ HTTP
|
|
98
|
+
|
|
99
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
100
|
+
def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def default_opener():
|
|
105
|
+
"""urllib with no proxy from the environment and no redirect followed."""
|
|
106
|
+
return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()).open
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Failed(Exception):
|
|
110
|
+
"""A GET that gave no usable answer; str() is a short, secret-free reason."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def failure_class(error):
|
|
114
|
+
if isinstance(error, urllib.error.HTTPError):
|
|
115
|
+
return f'HTTP {error.code}'
|
|
116
|
+
if isinstance(error, urllib.error.URLError):
|
|
117
|
+
reason = error.reason
|
|
118
|
+
return failure_class(reason) if isinstance(reason, BaseException) else f'unreachable: {reason}'
|
|
119
|
+
if isinstance(error, ConnectionRefusedError):
|
|
120
|
+
return 'connection refused'
|
|
121
|
+
if isinstance(error, (TimeoutError,)) or type(error).__name__ == 'timeout':
|
|
122
|
+
return 'timed out'
|
|
123
|
+
if isinstance(error, OSError):
|
|
124
|
+
return f'unreachable: {error.strerror or type(error).__name__}'
|
|
125
|
+
return type(error).__name__
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _capped(stream):
|
|
129
|
+
body = stream.read(MAX_BODY + 1)
|
|
130
|
+
if len(body) > MAX_BODY:
|
|
131
|
+
raise Failed(f'body over {MAX_BODY >> 20} MiB')
|
|
132
|
+
return body
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get(control, path, token=None, opener=None, accept_status=()):
|
|
136
|
+
"""GET <control><path> -> the body bytes. An HTTP status in `accept_status` (the 503 of a pool that is not ready)
|
|
137
|
+
still yields its body; any other failure raises Failed. The token goes into the header only."""
|
|
138
|
+
request = urllib.request.Request(control + path, method='GET')
|
|
139
|
+
if token:
|
|
140
|
+
request.add_header('Authorization', f'Bearer {token}')
|
|
141
|
+
try:
|
|
142
|
+
with (opener or default_opener())(request, timeout=TIMEOUT) as response:
|
|
143
|
+
return _capped(response)
|
|
144
|
+
except urllib.error.HTTPError as error:
|
|
145
|
+
if error.code in accept_status:
|
|
146
|
+
try:
|
|
147
|
+
return _capped(error)
|
|
148
|
+
finally:
|
|
149
|
+
error.close()
|
|
150
|
+
error.close()
|
|
151
|
+
raise Failed(failure_class(error)) from None
|
|
152
|
+
except Failed:
|
|
153
|
+
raise
|
|
154
|
+
except Exception as error: # the daemon may be down, restarting or slow: the pulse degrades, never dies
|
|
155
|
+
raise Failed(failure_class(error)) from None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_json(control, path, token=None, opener=None, accept_status=()):
|
|
159
|
+
body = get(control, path, token, opener, accept_status)
|
|
160
|
+
try:
|
|
161
|
+
doc = json.loads(body)
|
|
162
|
+
except ValueError:
|
|
163
|
+
raise Failed('not JSON') from None
|
|
164
|
+
if not isinstance(doc, dict):
|
|
165
|
+
raise Failed('not a JSON object')
|
|
166
|
+
return doc
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------ parsing
|
|
170
|
+
|
|
171
|
+
def _count(value):
|
|
172
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _number(value):
|
|
176
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
|
|
177
|
+
return None
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _fraction(value):
|
|
182
|
+
value = _number(value)
|
|
183
|
+
return None if value is None else round(float(value), 3)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def from_ready(doc):
|
|
187
|
+
"""The counts of one /pools/<pool>/ready body."""
|
|
188
|
+
configured, usable = _count(doc.get('configured_subscriptions')), _count(doc.get('usable_subscriptions'))
|
|
189
|
+
exhausted, auth_failed = _count(doc.get('exhausted_subscriptions')), _count(doc.get('auth_failed_subscriptions'))
|
|
190
|
+
others = sum(_count(doc.get(key)) for key in ('disabled_subscriptions', 'billing_blocked_subscriptions',
|
|
191
|
+
'organization_blocked_subscriptions', 'unhealthy_subscriptions'))
|
|
192
|
+
pressure = doc.get('pressure') if isinstance(doc.get('pressure'), dict) else {}
|
|
193
|
+
phase = pressure.get('phase')
|
|
194
|
+
return {'configured': configured, 'usable': usable,
|
|
195
|
+
'cooling': max(0, configured - usable - exhausted - auth_failed - others),
|
|
196
|
+
'exhausted': exhausted, 'benched': _count(doc.get('model_benched_subscriptions')),
|
|
197
|
+
'auth_failed': auth_failed, 'overflow_ready': doc.get('overflow_ready') is True,
|
|
198
|
+
'pressure_phase': phase if isinstance(phase, str) and phase in PHASES.values() else None}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def parse_metrics(text):
|
|
202
|
+
"""{pool: {key: value}} of the four series this pulse reads (SERIES); any other line is ignored."""
|
|
203
|
+
found = {}
|
|
204
|
+
for line in text.splitlines():
|
|
205
|
+
match = METRIC_LINE.match(line)
|
|
206
|
+
if not match or match.group(1) not in SERIES:
|
|
207
|
+
continue
|
|
208
|
+
labels = dict(LABEL.findall(match.group(2)))
|
|
209
|
+
pool = labels.get('pool')
|
|
210
|
+
if pool not in POOLS:
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
value = float(match.group(3))
|
|
214
|
+
except ValueError:
|
|
215
|
+
continue
|
|
216
|
+
if value != value: # NaN
|
|
217
|
+
continue
|
|
218
|
+
key = SERIES[match.group(1)]
|
|
219
|
+
if key == 'pressure_phase':
|
|
220
|
+
value = PHASES.get(int(value)) if value.is_integer() else None
|
|
221
|
+
elif key == 'spill_fraction':
|
|
222
|
+
value = round(value, 3)
|
|
223
|
+
else:
|
|
224
|
+
value = int(value) if value.is_integer() and value >= 0 else None
|
|
225
|
+
found.setdefault(pool, {})[key] = value
|
|
226
|
+
return found
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def from_status(doc, now):
|
|
230
|
+
"""(pool facts, overflow facts) of one tokened /pools/<pool>/status body. Worst = the highest MEASURED utilization
|
|
231
|
+
over the pool's subscription accounts (a field the daemon lists in `unknown_fields` is a placeholder 0, skipped);
|
|
232
|
+
soonest reset = the earliest reset of either window still in the future."""
|
|
233
|
+
worst = {'5h': None, '7d': None}
|
|
234
|
+
resets = []
|
|
235
|
+
for account in doc.get('accounts') or []:
|
|
236
|
+
if not isinstance(account, dict) or account.get('provider') not in SUBSCRIPTION_PROVIDERS:
|
|
237
|
+
continue
|
|
238
|
+
unknown = account.get('unknown_fields') if isinstance(account.get('unknown_fields'), list) else []
|
|
239
|
+
for window in ('5h', '7d'):
|
|
240
|
+
value = _number(account.get(f'utilization_{window}'))
|
|
241
|
+
if value is not None and f'utilization_{window}' not in unknown:
|
|
242
|
+
worst[window] = value if worst[window] is None else max(worst[window], value)
|
|
243
|
+
reset = account.get(f'reset_{window}')
|
|
244
|
+
if isinstance(reset, int) and not isinstance(reset, bool) and reset > now:
|
|
245
|
+
resets.append(reset)
|
|
246
|
+
overflow = doc.get('overflow') if isinstance(doc.get('overflow'), dict) else {}
|
|
247
|
+
alert = overflow.get('budget_alert_level_pct')
|
|
248
|
+
return ({'worst_7d': _fraction(worst['7d']), 'worst_5h': _fraction(worst['5h']),
|
|
249
|
+
'soonest_reset': min(resets) if resets else None},
|
|
250
|
+
{'spend_today_usd': _number(overflow.get('spend_today_usd')),
|
|
251
|
+
'budget_usd': _number(overflow.get('effective_daily_budget_usd')),
|
|
252
|
+
'fraction': _fraction(overflow.get('spent_fraction')),
|
|
253
|
+
'alert_level_pct': alert if isinstance(alert, int) and not isinstance(alert, bool) else None})
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ------------------------------------------------------------------ token and file
|
|
257
|
+
|
|
258
|
+
def read_token(path):
|
|
259
|
+
"""(token or None, why or None). Absent is (None, None): the opt-in was never made. A file that is not a regular
|
|
260
|
+
file, is readable by group/other, is empty or too long is (None, why) — never used, never printed."""
|
|
261
|
+
try:
|
|
262
|
+
fd = os.open(str(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
|
|
263
|
+
except FileNotFoundError:
|
|
264
|
+
return None, None
|
|
265
|
+
except OSError as error:
|
|
266
|
+
return None, f'token file unreadable ({error.strerror or type(error).__name__})'
|
|
267
|
+
try:
|
|
268
|
+
info = os.fstat(fd)
|
|
269
|
+
if not stat.S_ISREG(info.st_mode):
|
|
270
|
+
return None, 'token file is not a regular file'
|
|
271
|
+
if info.st_mode & 0o077:
|
|
272
|
+
return None, f'token file mode {stat.S_IMODE(info.st_mode):04o}, want 0600'
|
|
273
|
+
data = os.read(fd, TOKEN_MAX + 1)
|
|
274
|
+
except OSError as error:
|
|
275
|
+
return None, f'token file unreadable ({error.strerror or type(error).__name__})'
|
|
276
|
+
finally:
|
|
277
|
+
os.close(fd)
|
|
278
|
+
try:
|
|
279
|
+
token = data.decode('ascii').strip()
|
|
280
|
+
except UnicodeDecodeError:
|
|
281
|
+
return None, 'token file is not ASCII'
|
|
282
|
+
if not token or len(data) > TOKEN_MAX or any(c.isspace() for c in token):
|
|
283
|
+
return None, 'token file holds no single token'
|
|
284
|
+
return token, None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def read_previous(path):
|
|
288
|
+
try:
|
|
289
|
+
doc = json.loads(Path(path).read_text())
|
|
290
|
+
except (OSError, ValueError):
|
|
291
|
+
return None
|
|
292
|
+
return doc if isinstance(doc, dict) and doc.get('v') == SCHEMA else None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def write_atomic(path, document):
|
|
296
|
+
path = Path(path)
|
|
297
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
fd, temporary = tempfile.mkstemp(prefix=f'.{path.name}.', dir=str(path.parent))
|
|
299
|
+
try:
|
|
300
|
+
with os.fdopen(fd, 'w') as handle:
|
|
301
|
+
handle.write(json.dumps(document, separators=(',', ':')) + '\n')
|
|
302
|
+
os.fchmod(handle.fileno(), 0o600)
|
|
303
|
+
os.replace(temporary, path)
|
|
304
|
+
except BaseException:
|
|
305
|
+
try:
|
|
306
|
+
os.unlink(temporary)
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _without_at(document):
|
|
313
|
+
return {key: value for key, value in (document or {}).items() if key != 'at'}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------ one cycle
|
|
317
|
+
|
|
318
|
+
class Pulse:
|
|
319
|
+
"""One pulse: its last document (seeded from the file, so a restart keeps the pools) and its last journal line."""
|
|
320
|
+
|
|
321
|
+
def __init__(self, control, state_dir, token_file, opener=None, clock=time.time, journal=None):
|
|
322
|
+
self.control, self.path, self.token_file = control.rstrip('/'), Path(state_dir) / STATE_FILE, token_file
|
|
323
|
+
self.opener, self.clock = opener, clock
|
|
324
|
+
self.journal = journal or (lambda line: print(f'throttle-pulse: {line}', file=sys.stderr, flush=True))
|
|
325
|
+
previous = read_previous(self.path)
|
|
326
|
+
self.document = previous if previous else empty_document()
|
|
327
|
+
self.said = None
|
|
328
|
+
|
|
329
|
+
def observe(self):
|
|
330
|
+
"""GET every source once -> (document without `at`, [problems]). A pool whose source did not answer keeps its
|
|
331
|
+
previous facts (the file's age tells a reader how old they are)."""
|
|
332
|
+
now = int(self.clock())
|
|
333
|
+
old_pools, old_overflow = self.document.get('pools') or {}, self.document.get('overflow') or {}
|
|
334
|
+
pools = {pool: dict(empty_pool(), **{k: v for k, v in (old_pools.get(pool) or {}).items() if k in POOL_KEYS})
|
|
335
|
+
for pool in POOLS}
|
|
336
|
+
overflow = {pool: dict(empty_overflow(), **{k: v for k, v in (old_overflow.get(pool) or {}).items()
|
|
337
|
+
if k in OVERFLOW_KEYS}) for pool in POOLS}
|
|
338
|
+
problems, answered, first_failure = [], False, None
|
|
339
|
+
for pool in POOLS:
|
|
340
|
+
path = f'/pools/{pool}/ready'
|
|
341
|
+
try:
|
|
342
|
+
pools[pool].update(from_ready(get_json(self.control, path, opener=self.opener, accept_status=(503,))))
|
|
343
|
+
answered = True
|
|
344
|
+
except Failed as error:
|
|
345
|
+
first_failure = first_failure or str(error)
|
|
346
|
+
problems.append(f'{path}: {error}')
|
|
347
|
+
try:
|
|
348
|
+
series = parse_metrics(get(self.control, '/metrics', opener=self.opener).decode('utf-8', 'replace'))
|
|
349
|
+
answered = True
|
|
350
|
+
for pool in POOLS:
|
|
351
|
+
for key in SERIES.values():
|
|
352
|
+
if key == 'pressure_phase' and series.get(pool, {}).get(key) is None:
|
|
353
|
+
continue # the ready body's own phase (when it carries one) stands
|
|
354
|
+
pools[pool][key] = series.get(pool, {}).get(key)
|
|
355
|
+
except Failed as error:
|
|
356
|
+
first_failure = first_failure or str(error)
|
|
357
|
+
problems.append(f'/metrics: {error}')
|
|
358
|
+
token, why = read_token(self.token_file)
|
|
359
|
+
if why:
|
|
360
|
+
problems.append(why)
|
|
361
|
+
tokened = False
|
|
362
|
+
for pool in POOLS:
|
|
363
|
+
if not token: # no opt-in, or a token file that is not usable: burn and money are never shown
|
|
364
|
+
pools[pool].update(dict.fromkeys(TOKENED_POOL_KEYS))
|
|
365
|
+
overflow[pool] = empty_overflow()
|
|
366
|
+
elif answered:
|
|
367
|
+
path = f'/pools/{pool}/status'
|
|
368
|
+
try:
|
|
369
|
+
facts, money = from_status(get_json(self.control, path, token, self.opener), now)
|
|
370
|
+
except Failed as error: # the last burn and money of this pool are kept
|
|
371
|
+
problems.append(f'{path}: {error}')
|
|
372
|
+
continue
|
|
373
|
+
pools[pool].update(facts)
|
|
374
|
+
overflow[pool] = money if pool in MONEY_POOLS else empty_overflow()
|
|
375
|
+
tokened = True
|
|
376
|
+
if not answered:
|
|
377
|
+
tokened = bool(token) and self.document.get('tokened') is True
|
|
378
|
+
document = {'v': SCHEMA, 'up': answered, 'tokened': tokened, 'pools': pools, 'overflow': overflow,
|
|
379
|
+
'error': None if answered else first_failure}
|
|
380
|
+
return document, problems
|
|
381
|
+
|
|
382
|
+
def cycle(self):
|
|
383
|
+
"""One observation; throttle.json rewritten when something but `at` changed or HEARTBEAT seconds passed since
|
|
384
|
+
the file's `at` -> True when it wrote."""
|
|
385
|
+
document, problems = self.observe()
|
|
386
|
+
line = '; '.join(problems) or 'ok'
|
|
387
|
+
if line != self.said and (self.said is not None or problems):
|
|
388
|
+
self.journal(line)
|
|
389
|
+
self.said = line
|
|
390
|
+
now = int(self.clock())
|
|
391
|
+
on_disk = read_previous(self.path)
|
|
392
|
+
at = on_disk.get('at') if on_disk else None
|
|
393
|
+
if (isinstance(at, int) and 0 <= now - at < HEARTBEAT
|
|
394
|
+
and _without_at(on_disk) == _without_at(json.loads(json.dumps(document)))):
|
|
395
|
+
self.document = on_disk
|
|
396
|
+
return False
|
|
397
|
+
full = {'v': SCHEMA, 'at': now, 'up': document['up'], 'tokened': document['tokened'],
|
|
398
|
+
'pools': document['pools'], 'overflow': document['overflow'], 'error': document['error']}
|
|
399
|
+
write_atomic(self.path, full)
|
|
400
|
+
self.document = full
|
|
401
|
+
return True
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def run_loop(pulse, interval=PULSE_INTERVAL, sleep=time.sleep, clock=time.monotonic, cycles=None):
|
|
405
|
+
"""Run `pulse.cycle()` every `interval` seconds (the time a cycle took is subtracted); `cycles` bounds it (tests)."""
|
|
406
|
+
done = 0
|
|
407
|
+
while cycles is None or done < cycles:
|
|
408
|
+
started = clock()
|
|
409
|
+
try:
|
|
410
|
+
pulse.cycle()
|
|
411
|
+
except OSError as error: # an unwritable state dir is journalled; the loop keeps going
|
|
412
|
+
pulse.journal(f'state not written ({error.strerror or type(error).__name__})')
|
|
413
|
+
done += 1
|
|
414
|
+
if cycles is not None and done >= cycles:
|
|
415
|
+
break
|
|
416
|
+
sleep(max(0.0, interval - (clock() - started)))
|
|
417
|
+
return done
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def default_control(env):
|
|
421
|
+
raw = env.get('LANES_THROTTLE_CONTROL_PORT') or str(DEFAULT_PORT)
|
|
422
|
+
if not raw.isdigit() or not 0 < int(raw) < 65536:
|
|
423
|
+
raise ValueError(f'LANES_THROTTLE_CONTROL_PORT must be a port number, not {raw!r}')
|
|
424
|
+
return f'http://127.0.0.1:{int(raw)}'
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main(argv=None, env=None):
|
|
428
|
+
env = os.environ if env is None else env
|
|
429
|
+
parser = argparse.ArgumentParser(prog='throttle-pulse', description=__doc__.split('\n\n', 1)[0])
|
|
430
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
431
|
+
mode.add_argument('--once', action='store_true', help='one cycle: write when changed, print the document')
|
|
432
|
+
mode.add_argument('--loop', action='store_true', help=f'a cycle every {PULSE_INTERVAL} s until SIGTERM')
|
|
433
|
+
parser.add_argument('--control', help='control base URL (default http://127.0.0.1:$LANES_THROTTLE_CONTROL_PORT, '
|
|
434
|
+
f'else :{DEFAULT_PORT})')
|
|
435
|
+
parser.add_argument('--state-dir', default='~/.lz/state')
|
|
436
|
+
parser.add_argument('--token-file', default='~/.lz/throttle-token')
|
|
437
|
+
args = parser.parse_args(argv)
|
|
438
|
+
try:
|
|
439
|
+
control = args.control or default_control(env)
|
|
440
|
+
except ValueError as error:
|
|
441
|
+
parser.error(str(error))
|
|
442
|
+
if not re.match(r'^https?://[^/?#\s]+$', control.rstrip('/')):
|
|
443
|
+
parser.error(f'--control must be an http(s) base URL, not {control!r}')
|
|
444
|
+
pulse = Pulse(control, os.path.expanduser(args.state_dir), os.path.expanduser(args.token_file))
|
|
445
|
+
if args.once:
|
|
446
|
+
pulse.cycle()
|
|
447
|
+
print(json.dumps(pulse.document, indent=1))
|
|
448
|
+
return 0 if pulse.document.get('up') else 1
|
|
449
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
450
|
+
signal.signal(signum, lambda *_: sys.exit(0))
|
|
451
|
+
run_loop(pulse)
|
|
452
|
+
return 0
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
if __name__ == '__main__':
|
|
456
|
+
sys.exit(main())
|