@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -1,3454 +1,302 @@
|
|
|
1
|
-
|
|
2
|
-
"""Reference kit for the fabric-service/0.1 local service extension.
|
|
1
|
+
"""Reference kit for the fabric-service/0.1 local service extension: the facade.
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
that calls these functions inherits the rule instead of re-deriving it.
|
|
8
|
-
|
|
9
|
-
Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015).
|
|
10
|
-
"""
|
|
3
|
+
Since 0.40.0 (FAA-65) the kit is a set of modules, and this file re-exports every name it defined at 0.39.1, so
|
|
4
|
+
``import fabric_service as fs`` keeps working. Install the kit with ``fabric_kit.py install <dir>``: it copies this
|
|
5
|
+
facade with the modules it needs (``kit.json`` lists them). Standard library only, Python 3.9+.
|
|
11
6
|
|
|
7
|
+
The modules, lowest first: fabric_base, fabric_platform, fabric_paths, fabric_protocols, fabric_lock, fabric_usage,
|
|
8
|
+
fabric_backups, fabric_descriptor, fabric_auth, fabric_events, fabric_logs, fabric_supervisors."""
|
|
12
9
|
from __future__ import annotations
|
|
13
10
|
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import
|
|
24
|
-
from
|
|
25
|
-
import
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import
|
|
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
|
-
sock.bind(name)
|
|
307
|
-
except OSError as exc:
|
|
308
|
-
sock.close()
|
|
309
|
-
if exc.errno == errno.EADDRINUSE:
|
|
310
|
-
raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
|
|
311
|
-
raise
|
|
312
|
-
# Held by its descriptor, like the file lock: a socket object would close when collected.
|
|
313
|
-
return sock.detach()
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _drop_lock_name(held: Any) -> None:
|
|
317
|
-
if WINDOWS:
|
|
318
|
-
_close_handle(held)
|
|
319
|
-
else:
|
|
320
|
-
os.close(held)
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def lock_name_held(lock_path: Path) -> Optional[bool]:
|
|
324
|
-
"""True when some process holds the lock's name (a running service of either kit); None where there is no name
|
|
325
|
-
to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
|
|
326
|
-
probe cannot make a service that is starting at that moment fail."""
|
|
327
|
-
name = lock_name(lock_path)
|
|
328
|
-
if name is None:
|
|
329
|
-
return None
|
|
330
|
-
if WINDOWS:
|
|
331
|
-
return _pipe_exists(name)
|
|
332
|
-
try:
|
|
333
|
-
with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
|
|
334
|
-
return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
|
|
335
|
-
except OSError:
|
|
336
|
-
return None
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def _read_pid(path: Path) -> Optional[int]:
|
|
340
|
-
try:
|
|
341
|
-
text = path.read_text().strip()
|
|
342
|
-
return int(text) if re.fullmatch(r"[0-9]{1,10}", text, re.ASCII) else None
|
|
343
|
-
except OSError:
|
|
344
|
-
return None
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
#: The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node.
|
|
348
|
-
SUPERVISORS = ("launchd", "systemd", "task-scheduler", "node")
|
|
349
|
-
#: The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action).
|
|
350
|
-
SUPERVISOR_ARG = "--fabric-supervisor"
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def _marker_arguments(argv: Sequence[Any]) -> List[str]:
|
|
354
|
-
"""The marker values an argument vector carries after the executable and before ``--``. A bare
|
|
355
|
-
``--fabric-supervisor`` followed by nothing, by ``--`` or by another ``--…`` flag is a marker with an empty value
|
|
356
|
-
and consumes nothing."""
|
|
357
|
-
values: List[str] = []
|
|
358
|
-
index = 1
|
|
359
|
-
while index < len(argv):
|
|
360
|
-
item = argv[index]
|
|
361
|
-
if item == "--":
|
|
362
|
-
break
|
|
363
|
-
if item == SUPERVISOR_ARG:
|
|
364
|
-
following = argv[index + 1] if index + 1 < len(argv) else None
|
|
365
|
-
if isinstance(following, str) and not following.startswith("--"):
|
|
366
|
-
values.append(following)
|
|
367
|
-
index += 1
|
|
368
|
-
else:
|
|
369
|
-
values.append("")
|
|
370
|
-
elif isinstance(item, str) and item.startswith(SUPERVISOR_ARG + "="):
|
|
371
|
-
values.append(item[len(SUPERVISOR_ARG) + 1:])
|
|
372
|
-
index += 1
|
|
373
|
-
return values
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
def supervisor_marker(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
377
|
-
"""How this copy was started (DEC-0057), from ``FABRIC_SERVICE_SUPERVISOR=<manager>`` and
|
|
378
|
-
``--fabric-supervisor <manager>`` / ``--fabric-supervisor=<manager>`` (``env`` and ``argv`` default to this process's):
|
|
379
|
-
|
|
380
|
-
- ``{"kind": "supervised", "manager", "forms"}`` — every marker present names the same manager of SUPERVISORS;
|
|
381
|
-
- ``{"kind": "conflict", "values", "forms"}`` — the markers disagree: the copy is unsupervised and exits 75 at once,
|
|
382
|
-
before it takes the lock (an empty or bare argument beside a variable disagrees with it);
|
|
383
|
-
- ``{"kind": "manual", "forms", "unreadable"?}`` — no marker (only an empty variable counts as none), or one that
|
|
384
|
-
names no manager.
|
|
385
|
-
|
|
386
|
-
A copy never infers supervision from its parent process. The contract's ``supervisorMarker``."""
|
|
387
|
-
env = os.environ if env is None else env
|
|
388
|
-
argv = sys.argv if argv is None else argv
|
|
389
|
-
variable = env.get(SUPERVISOR_ENV) if isinstance(env, dict) or hasattr(env, "get") else None
|
|
390
|
-
from_env = [variable] if isinstance(variable, str) and variable != "" else []
|
|
391
|
-
from_args = _marker_arguments(list(argv) if isinstance(argv, (list, tuple)) else [])
|
|
392
|
-
forms = (["environment"] if from_env else []) + (["argument"] if from_args else [])
|
|
393
|
-
values: List[str] = []
|
|
394
|
-
for value in from_env + from_args:
|
|
395
|
-
if value not in values:
|
|
396
|
-
values.append(value)
|
|
397
|
-
if len(values) > 1:
|
|
398
|
-
return {"kind": "conflict", "values": values, "forms": forms}
|
|
399
|
-
if not values:
|
|
400
|
-
return {"kind": "manual", "forms": forms}
|
|
401
|
-
if values[0] in SUPERVISORS:
|
|
402
|
-
return {"kind": "supervised", "manager": values[0], "forms": forms}
|
|
403
|
-
return {"kind": "manual", "unreadable": values[0], "forms": forms}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def lock_wait_budget_seconds(marker: Dict[str, Any]) -> float:
|
|
407
|
-
"""How long a copy started so may wait for the lock: 300 s when supervised, else not at all."""
|
|
408
|
-
return LOCK_WAIT_SUPERVISED_SECONDS if marker.get("kind") == "supervised" else 0.0
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def marker_conflict_message(marker: Dict[str, Any]) -> str:
|
|
412
|
-
"""The one sentence a copy whose markers disagree exits with, naming the markers present."""
|
|
413
|
-
shown = ", ".join(repr(v) for v in marker["values"])
|
|
414
|
-
if marker["forms"] == ["environment", "argument"]:
|
|
415
|
-
return "%s and %s disagree (%s); not starting unsupervised." % (SUPERVISOR_ENV, SUPERVISOR_ARG, shown)
|
|
416
|
-
return "%s is given with different values (%s); not starting unsupervised." % (SUPERVISOR_ARG, shown)
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def supervised(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
|
|
420
|
-
"""True when a supervisor that restarts on exit started this process and said so with the marker
|
|
421
|
-
(``launchd_plist`` and ``systemd_unit`` set the variable, ``task_xml`` writes the argument, a node sets either)."""
|
|
422
|
-
return supervisor_marker(env, argv)["kind"] == "supervised"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
def supervised_by_launchd(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
|
|
426
|
-
"""True when launchd started this process from a plist written by ``launchd_plist``.
|
|
427
|
-
|
|
428
|
-
The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
|
|
429
|
-
``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
|
|
430
|
-
return supervisor_marker(env, argv).get("manager") == "launchd"
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def add_supervisor_argument(parser: Any) -> Any:
|
|
434
|
-
"""Let a service's own argparse parser accept the marker in both spellings, and a bare one, and ignore it
|
|
435
|
-
(DEC-0057: argparse exits 2 on an unknown flag). The kit reads the marker from ``sys.argv`` itself."""
|
|
436
|
-
import argparse
|
|
437
|
-
parser.add_argument(SUPERVISOR_ARG, nargs="?", const="", default=None, help=argparse.SUPPRESS)
|
|
438
|
-
return parser
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
|
|
442
|
-
sleep: Callable[[float], None] = time.sleep,
|
|
443
|
-
clock: Callable[[], float] = time.monotonic,
|
|
444
|
-
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
|
|
445
|
-
"""Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
|
|
446
|
-
|
|
447
|
-
Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
|
|
448
|
-
marker), a held lock exits 75 at once. Under a supervisor that restarts on exit an immediate
|
|
449
|
-
exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
|
|
450
|
-
(0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
|
|
451
|
-
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
452
|
-
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
|
|
453
|
-
marker = supervisor_marker(env, argv)
|
|
454
|
-
if marker["kind"] == "conflict":
|
|
455
|
-
print(marker_conflict_message(marker), file=sys.stderr)
|
|
456
|
-
raise SystemExit(EXIT_ALREADY_RUNNING)
|
|
457
|
-
if wait_seconds is None:
|
|
458
|
-
wait_seconds = lock_wait_budget_seconds(marker)
|
|
459
|
-
deadline = clock() + max(0.0, wait_seconds)
|
|
460
|
-
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
461
|
-
announced = False
|
|
462
|
-
while True:
|
|
463
|
-
try:
|
|
464
|
-
return InstanceLock(data_dir).acquire()
|
|
465
|
-
except AlreadyRunning as exc:
|
|
466
|
-
remaining = deadline - clock()
|
|
467
|
-
if remaining <= 0:
|
|
468
|
-
print(str(exc), file=sys.stderr)
|
|
469
|
-
raise SystemExit(EXIT_ALREADY_RUNNING)
|
|
470
|
-
if not announced:
|
|
471
|
-
print("%s Waiting up to %.0f s for it to exit." % (exc, remaining), file=sys.stderr, flush=True)
|
|
472
|
-
announced = True
|
|
473
|
-
sleep(min(delay, remaining))
|
|
474
|
-
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
# --- Windows ACLs (DEC-0032) ---------------------------------------------------
|
|
478
|
-
|
|
479
|
-
#: The trustees a token file may grant anything to besides the current user: LocalSystem and the
|
|
480
|
-
#: Administrators group — the same trust as root reading a 0600 file on POSIX.
|
|
481
|
-
WINDOWS_TRUSTED = ("S-1-5-18", "S-1-5-32-544")
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def token_file_problem(path: Path, file: Dict[str, Any], user: str) -> Optional[str]:
|
|
485
|
-
"""windows_token_acl_problem as one sentence naming the file and the refusal, or None."""
|
|
486
|
-
problem = windows_token_acl_problem(file, user)
|
|
487
|
-
return None if problem is None else "Token file %s refused: %s." % (path, problem["message"])
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
SYSTEM_SID = "S-1-5-18"
|
|
491
|
-
ADMINISTRATORS_SID = "S-1-5-32-544"
|
|
492
|
-
#: A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each.
|
|
493
|
-
_SID_TEXT = re.compile(r"S-1-[0-9]{1,15}(?:-[0-9]{1,10}){0,15}", re.IGNORECASE | re.ASCII)
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
def _sid(value: Any) -> Optional[str]:
|
|
497
|
-
"""A readable SID in upper case (SIDs compare case-insensitively on their S), or None."""
|
|
498
|
-
if not isinstance(value, str) or not _SID_TEXT.fullmatch(value):
|
|
499
|
-
return None
|
|
500
|
-
return value.upper() if all(int(part) <= 0xFFFFFFFF for part in value.split("-")[3:]) else None
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
def windows_token_acl_problem(file: Any, current_user_sid: Any) -> Optional[Dict[str, Any]]:
|
|
504
|
-
"""service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read:
|
|
505
|
-
``file`` is ``{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}``, the mask
|
|
506
|
-
the ACE's unsigned 32-bit access mask. The first refusal ``{problem, at, sid?, message}``, or None when the file may
|
|
507
|
-
be read: a regular file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every
|
|
508
|
-
allow ACE with a non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything
|
|
509
|
-
unreadable refused. The contract's ``windowsTokenAclProblem``; a refusal names the SID, never the contents."""
|
|
510
|
-
def refuse(problem: str, at: str, message: str, sid: Optional[str] = None) -> Dict[str, Any]:
|
|
511
|
-
out = {"problem": problem, "at": at, "message": message}
|
|
512
|
-
if sid is not None:
|
|
513
|
-
out["sid"] = sid
|
|
514
|
-
return out
|
|
515
|
-
user = _sid(current_user_sid)
|
|
516
|
-
if user is None:
|
|
517
|
-
return refuse("unreadable", "/currentUserSid", "the current user's SID is unreadable")
|
|
518
|
-
if not isinstance(file, dict):
|
|
519
|
-
return refuse("unreadable", "/file", "the token file's metadata is unreadable")
|
|
520
|
-
for flag in ("isRegularFile", "isReparsePoint", "realPathInProfile"):
|
|
521
|
-
if not isinstance(file.get(flag), bool):
|
|
522
|
-
return refuse("unreadable", "/file/" + flag, flag + " is unreadable")
|
|
523
|
-
if file["isReparsePoint"]:
|
|
524
|
-
return refuse("reparse-point", "/file/isReparsePoint", "the token file is a reparse point (a symlink or a junction)")
|
|
525
|
-
if not file["isRegularFile"]:
|
|
526
|
-
return refuse("not-regular-file", "/file/isRegularFile", "the token file is not a regular file")
|
|
527
|
-
if not file["realPathInProfile"]:
|
|
528
|
-
return refuse("outside-profile", "/file/realPathInProfile", "the token file's real path lies outside the user's profile")
|
|
529
|
-
trusted = {user, SYSTEM_SID, ADMINISTRATORS_SID}
|
|
530
|
-
owner = _sid(file.get("ownerSid"))
|
|
531
|
-
if owner is None:
|
|
532
|
-
return refuse("unreadable", "/file/ownerSid", "the owner SID is unreadable")
|
|
533
|
-
if owner not in trusted:
|
|
534
|
-
return refuse("owner", "/file/ownerSid", "the token file is owned by %s, not by the current user, SYSTEM or Administrators" % owner, owner)
|
|
535
|
-
aces = file.get("aces")
|
|
536
|
-
if not isinstance(aces, list):
|
|
537
|
-
return refuse("unreadable", "/file/aces", "the ACL is unreadable")
|
|
538
|
-
for index, ace in enumerate(aces):
|
|
539
|
-
at = "/file/aces/%d" % index
|
|
540
|
-
if not isinstance(ace, dict):
|
|
541
|
-
return refuse("unreadable", at, "ACE %d is unreadable" % index)
|
|
542
|
-
if not isinstance(ace.get("type"), str):
|
|
543
|
-
return refuse("unreadable", at + "/type", "ACE %d has an unreadable type" % index)
|
|
544
|
-
trustee = _sid(ace.get("sid"))
|
|
545
|
-
if trustee is None:
|
|
546
|
-
return refuse("unreadable", at + "/sid", "ACE %d names an unreadable SID" % index)
|
|
547
|
-
mask = ace.get("mask")
|
|
548
|
-
if (not isinstance(mask, (int, float)) or isinstance(mask, bool) or (isinstance(mask, float) and not mask.is_integer())
|
|
549
|
-
or not 0 <= mask <= 0xFFFFFFFF):
|
|
550
|
-
return refuse("unreadable", at + "/mask", "ACE %d has an unreadable access mask" % index)
|
|
551
|
-
if ace["type"] == "deny":
|
|
552
|
-
continue
|
|
553
|
-
if ace["type"] != "allow":
|
|
554
|
-
return refuse("ace-type", at + "/type", "ACE %d for %s is of a type the rule does not read" % (index, trustee), trustee)
|
|
555
|
-
if mask == 0:
|
|
556
|
-
continue
|
|
557
|
-
if trustee not in trusted:
|
|
558
|
-
return refuse("ace-sid", at + "/sid", "the token file grants access to %s; only the current user, SYSTEM and Administrators may hold any" % trustee, trustee)
|
|
559
|
-
return None
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
#: SDDL ACE types (MS-DTYP §2.5.1.1) as the rule's ACE type: allow and deny are read, every other type is refused.
|
|
563
|
-
_SDDL_ACE_TYPES = {"A": "allow", "D": "deny", "OA": "allow-object", "OD": "deny-object", "XA": "allow-callback",
|
|
564
|
-
"XD": "deny-callback", "ZA": "allow-object-callback"}
|
|
565
|
-
#: SDDL access-right aliases (MS-DTYP §2.5.1.1, "ace_rights") and their masks.
|
|
566
|
-
_SDDL_RIGHTS = {"GA": 0x10000000, "GR": 0x80000000, "GW": 0x40000000, "GX": 0x20000000, "RC": 0x20000, "SD": 0x10000,
|
|
567
|
-
"WD": 0x40000, "WO": 0x80000, "RP": 0x10, "WP": 0x20, "CC": 0x1, "DC": 0x2, "LC": 0x4, "SW": 0x8,
|
|
568
|
-
"LO": 0x80, "DT": 0x40, "CR": 0x100, "FA": 0x1F01FF, "FR": 0x120089, "FW": 0x120116, "FX": 0x1200A0,
|
|
569
|
-
"KA": 0xF003F, "KR": 0x20019, "KX": 0x20019, "KW": 0x20006}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def _sddl_ace_strings(dacl: str) -> List[str]:
|
|
573
|
-
"""The top-level parenthesized ACE strings of an SDDL DACL; a callback ACE's conditional expression may nest."""
|
|
574
|
-
aces, depth, start = [], 0, 0
|
|
575
|
-
for index, char in enumerate(dacl):
|
|
576
|
-
if char == "(":
|
|
577
|
-
if depth == 0:
|
|
578
|
-
start = index + 1
|
|
579
|
-
depth += 1
|
|
580
|
-
elif char == ")" and depth:
|
|
581
|
-
depth -= 1
|
|
582
|
-
if depth == 0:
|
|
583
|
-
aces.append(dacl[start:index])
|
|
584
|
-
return aces
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
def _sddl_mask(rights: str) -> Optional[int]:
|
|
588
|
-
"""An SDDL rights field as the unsigned 32-bit mask, or None when it cannot be read."""
|
|
589
|
-
if re.fullmatch(r"0[xX][0-9a-fA-F]{1,8}", rights):
|
|
590
|
-
return int(rights, 16)
|
|
591
|
-
if len(rights) % 2:
|
|
592
|
-
return None
|
|
593
|
-
mask = 0
|
|
594
|
-
for i in range(0, len(rights), 2):
|
|
595
|
-
if rights[i:i + 2] not in _SDDL_RIGHTS:
|
|
596
|
-
return None
|
|
597
|
-
mask |= _SDDL_RIGHTS[rights[i:i + 2]]
|
|
598
|
-
return mask
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
def acl_from_sddl(sddl: str, canonical: Callable[[str], str] = lambda sid: sid) -> Tuple[str, List[Dict[str, Any]]]:
|
|
602
|
-
"""(owner SID, the ACEs that apply to the object itself as ``{type, sid, mask}``) from an SDDL owner-and-DACL
|
|
603
|
-
string. ``canonical`` turns an SDDL alias (SY, BA, WD…) into its SID. A missing or NULL DACL grants everyone
|
|
604
|
-
everything and is read as an allow ACE for Everyone (S-1-1-0); an inherit-only ACE (flag IO) applies to children
|
|
605
|
-
only and is not read; an unreadable rights field gives mask None, which the rule refuses."""
|
|
606
|
-
owner = sddl.split("O:", 1)[1].split("G:", 1)[0].split("D:", 1)[0] if "O:" in sddl else ""
|
|
607
|
-
dacl = sddl.split("D:", 1)[1] if "D:" in sddl else ""
|
|
608
|
-
owner_sid = canonical(owner) if owner else ""
|
|
609
|
-
if not dacl or dacl.startswith("NO_ACCESS_CONTROL"):
|
|
610
|
-
return owner_sid, [{"type": "allow", "sid": "S-1-1-0", "mask": 0x1F01FF}]
|
|
611
|
-
aces = []
|
|
612
|
-
for text in _sddl_ace_strings(dacl):
|
|
613
|
-
fields = text.split(";", 6)
|
|
614
|
-
if len(fields) < 6: # unreadable: the rule refuses it
|
|
615
|
-
aces.append({"type": None, "sid": None, "mask": None})
|
|
616
|
-
continue
|
|
617
|
-
kind, flags, rights, trustee = fields[0], fields[1], fields[2], fields[5]
|
|
618
|
-
if "IO" in [flags[i:i + 2] for i in range(0, len(flags), 2)]:
|
|
619
|
-
continue
|
|
620
|
-
aces.append({"type": _SDDL_ACE_TYPES.get(kind, kind), "sid": canonical(trustee), "mask": _sddl_mask(rights)})
|
|
621
|
-
return owner_sid, aces
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
625
|
-
import ctypes
|
|
626
|
-
from ctypes import wintypes
|
|
627
|
-
|
|
628
|
-
_advapi32 = ctypes.WinDLL("advapi32", use_last_error=True)
|
|
629
|
-
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
630
|
-
_PVOID = ctypes.c_void_p
|
|
631
|
-
_SE_FILE_OBJECT, _OWNER_INFO, _DACL_INFO, _PROTECTED_DACL = 1, 0x1, 0x4, 0x80000000
|
|
632
|
-
_REPARSE_POINT = 0x400
|
|
633
|
-
_advapi32.GetNamedSecurityInfoW.argtypes = [wintypes.LPCWSTR, ctypes.c_int, wintypes.DWORD] + [ctypes.POINTER(_PVOID)] * 5
|
|
634
|
-
_advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
635
|
-
_advapi32.SetNamedSecurityInfoW.argtypes = [wintypes.LPWSTR, ctypes.c_int, wintypes.DWORD] + [_PVOID] * 4
|
|
636
|
-
_advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD
|
|
637
|
-
_advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW.argtypes = [
|
|
638
|
-
_PVOID, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(wintypes.LPWSTR), ctypes.POINTER(wintypes.ULONG)]
|
|
639
|
-
_advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [
|
|
640
|
-
wintypes.LPCWSTR, wintypes.DWORD, ctypes.POINTER(_PVOID), ctypes.POINTER(wintypes.ULONG)]
|
|
641
|
-
_advapi32.GetSecurityDescriptorDacl.argtypes = [_PVOID, ctypes.POINTER(wintypes.BOOL), ctypes.POINTER(_PVOID),
|
|
642
|
-
ctypes.POINTER(wintypes.BOOL)]
|
|
643
|
-
_advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)]
|
|
644
|
-
_advapi32.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, _PVOID, wintypes.DWORD,
|
|
645
|
-
ctypes.POINTER(wintypes.DWORD)]
|
|
646
|
-
_advapi32.ConvertSidToStringSidW.argtypes = [_PVOID, ctypes.POINTER(wintypes.LPWSTR)]
|
|
647
|
-
_advapi32.ConvertStringSidToSidW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(_PVOID)]
|
|
648
|
-
_kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
649
|
-
_kernel32.LocalFree.argtypes = [_PVOID]
|
|
650
|
-
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
651
|
-
_CANONICAL: Dict[str, str] = {}
|
|
652
|
-
|
|
653
|
-
def _win_error(what: str, code: Optional[int] = None) -> OSError:
|
|
654
|
-
code = ctypes.get_last_error() if code is None else code
|
|
655
|
-
return OSError(code, "%s: %s" % (what, ctypes.FormatError(code).strip()))
|
|
656
|
-
|
|
657
|
-
def _sid_text(sid: Any) -> str:
|
|
658
|
-
text = wintypes.LPWSTR()
|
|
659
|
-
if not _advapi32.ConvertSidToStringSidW(sid, ctypes.byref(text)):
|
|
660
|
-
raise _win_error("ConvertSidToStringSidW")
|
|
661
|
-
try:
|
|
662
|
-
return text.value
|
|
663
|
-
finally:
|
|
664
|
-
_kernel32.LocalFree(text)
|
|
665
|
-
|
|
666
|
-
def _canonical_sid(trustee: str) -> str:
|
|
667
|
-
"""SDDL writes well-known accounts by alias (SY, BA, LA, OW); compare SIDs."""
|
|
668
|
-
if trustee not in _CANONICAL:
|
|
669
|
-
sid = _PVOID()
|
|
670
|
-
if _advapi32.ConvertStringSidToSidW(trustee, ctypes.byref(sid)):
|
|
671
|
-
try:
|
|
672
|
-
_CANONICAL[trustee] = _sid_text(sid)
|
|
673
|
-
finally:
|
|
674
|
-
_kernel32.LocalFree(sid)
|
|
675
|
-
else:
|
|
676
|
-
_CANONICAL[trustee] = trustee
|
|
677
|
-
return _CANONICAL[trustee]
|
|
678
|
-
|
|
679
|
-
def windows_user_sid() -> str:
|
|
680
|
-
token = wintypes.HANDLE()
|
|
681
|
-
if not _advapi32.OpenProcessToken(_kernel32.GetCurrentProcess(), 0x8, ctypes.byref(token)):
|
|
682
|
-
raise _win_error("OpenProcessToken")
|
|
683
|
-
try:
|
|
684
|
-
size = wintypes.DWORD()
|
|
685
|
-
_advapi32.GetTokenInformation(token, 1, None, 0, ctypes.byref(size))
|
|
686
|
-
buf = ctypes.create_string_buffer(size.value)
|
|
687
|
-
if not _advapi32.GetTokenInformation(token, 1, buf, size, ctypes.byref(size)):
|
|
688
|
-
raise _win_error("GetTokenInformation")
|
|
689
|
-
return _sid_text(ctypes.cast(buf, ctypes.POINTER(_PVOID))[0])
|
|
690
|
-
finally:
|
|
691
|
-
_kernel32.CloseHandle(token)
|
|
692
|
-
|
|
693
|
-
def _sddl(path: Path) -> str:
|
|
694
|
-
sd = _PVOID()
|
|
695
|
-
refs = [_PVOID() for _ in range(4)]
|
|
696
|
-
code = _advapi32.GetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _OWNER_INFO | _DACL_INFO,
|
|
697
|
-
*[ctypes.byref(r) for r in refs], ctypes.byref(sd))
|
|
698
|
-
if code:
|
|
699
|
-
raise _win_error("GetNamedSecurityInfoW", code)
|
|
700
|
-
try:
|
|
701
|
-
text = wintypes.LPWSTR()
|
|
702
|
-
if not _advapi32.ConvertSecurityDescriptorToStringSecurityDescriptorW(
|
|
703
|
-
sd, 1, _OWNER_INFO | _DACL_INFO, ctypes.byref(text), None):
|
|
704
|
-
raise _win_error("ConvertSecurityDescriptorToStringSecurityDescriptorW")
|
|
705
|
-
try:
|
|
706
|
-
return text.value or ""
|
|
707
|
-
finally:
|
|
708
|
-
_kernel32.LocalFree(text)
|
|
709
|
-
finally:
|
|
710
|
-
_kernel32.LocalFree(sd)
|
|
711
|
-
|
|
712
|
-
def windows_acl(path: Path) -> Tuple[str, List[Dict[str, Any]]]:
|
|
713
|
-
"""(owner SID, the ACEs on the object itself as ``{type, sid, mask}``), read from its SDDL (acl_from_sddl)."""
|
|
714
|
-
return acl_from_sddl(_sddl(path), _canonical_sid)
|
|
715
|
-
|
|
716
|
-
def _win_protect(path: Path, folder: bool) -> None:
|
|
717
|
-
"""A protected list (inheritance off): this user, SYSTEM, Administrators, full control;
|
|
718
|
-
inherited by what a folder holds."""
|
|
719
|
-
inherit = "OICI" if folder else ""
|
|
720
|
-
sddl = "D:P" + "".join("(A;%s;FA;;;%s)" % (inherit, who) for who in (windows_user_sid(), "SY", "BA"))
|
|
721
|
-
sd = _PVOID()
|
|
722
|
-
if not _advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl, 1, ctypes.byref(sd), None):
|
|
723
|
-
raise _win_error("ConvertStringSecurityDescriptorToSecurityDescriptorW")
|
|
724
|
-
try:
|
|
725
|
-
present, defaulted, dacl = wintypes.BOOL(), wintypes.BOOL(), _PVOID()
|
|
726
|
-
if not _advapi32.GetSecurityDescriptorDacl(sd, ctypes.byref(present), ctypes.byref(dacl), ctypes.byref(defaulted)):
|
|
727
|
-
raise _win_error("GetSecurityDescriptorDacl")
|
|
728
|
-
code = _advapi32.SetNamedSecurityInfoW(str(path), _SE_FILE_OBJECT, _DACL_INFO | _PROTECTED_DACL,
|
|
729
|
-
None, None, dacl, None)
|
|
730
|
-
if code:
|
|
731
|
-
raise _win_error("SetNamedSecurityInfoW", code)
|
|
732
|
-
finally:
|
|
733
|
-
_kernel32.LocalFree(sd)
|
|
734
|
-
|
|
735
|
-
_kernel32.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR] + [wintypes.DWORD] * 6 + [_PVOID]
|
|
736
|
-
_kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
|
|
737
|
-
_INVALID_HANDLE = wintypes.HANDLE(-1).value
|
|
738
|
-
|
|
739
|
-
def _create_first_pipe(name: str) -> Any:
|
|
740
|
-
"""The pipe's first instance, or None when another process holds it (ERROR_ACCESS_DENIED)."""
|
|
741
|
-
# PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE; PIPE_REJECT_REMOTE_CLIENTS; unlimited instances,
|
|
742
|
-
# as libuv creates them: then a second first-instance attempt, ours or the Node kit's, is refused with
|
|
743
|
-
# ERROR_ACCESS_DENIED (Node: EADDRINUSE), never ERROR_PIPE_BUSY.
|
|
744
|
-
handle = _kernel32.CreateNamedPipeW(name, 0x1 | 0x00080000, 0x8, 255, 0, 0, 0, None)
|
|
745
|
-
if handle == _INVALID_HANDLE:
|
|
746
|
-
code = ctypes.get_last_error()
|
|
747
|
-
if code in (5, 231): # ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY: another process holds it
|
|
748
|
-
return None
|
|
749
|
-
raise _win_error("CreateNamedPipeW", code)
|
|
750
|
-
return handle
|
|
751
|
-
|
|
752
|
-
def _close_handle(handle: Any) -> None:
|
|
753
|
-
_kernel32.CloseHandle(handle)
|
|
754
|
-
|
|
755
|
-
_kernel32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
|
|
756
|
-
_kernel32.WaitNamedPipeW.restype = wintypes.BOOL
|
|
757
|
-
|
|
758
|
-
def _pipe_exists(name: str) -> bool:
|
|
759
|
-
"""Some process holds the pipe: waiting for it does not fail with ERROR_FILE_NOT_FOUND. Nothing connects."""
|
|
760
|
-
if _kernel32.WaitNamedPipeW(name, 1):
|
|
761
|
-
return True
|
|
762
|
-
return ctypes.get_last_error() != 2
|
|
763
|
-
|
|
764
|
-
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
|
|
765
|
-
profile = os.path.normcase(os.path.realpath(os.environ.get("USERPROFILE") or str(Path.home())))
|
|
766
|
-
real = os.path.normcase(os.path.realpath(str(path)))
|
|
767
|
-
try:
|
|
768
|
-
inside = os.path.commonpath([profile, real]) == profile
|
|
769
|
-
except ValueError: # another drive
|
|
770
|
-
inside = False
|
|
771
|
-
owner, aces = windows_acl(path)
|
|
772
|
-
file = {"isRegularFile": stat.S_ISREG(info.st_mode), "isReparsePoint": bool(getattr(info, "st_file_attributes", 0) & _REPARSE_POINT),
|
|
773
|
-
"realPathInProfile": inside, "ownerSid": owner, "aces": aces}
|
|
774
|
-
return token_file_problem(path, file, windows_user_sid())
|
|
775
|
-
else:
|
|
776
|
-
def _create_first_pipe(name: str) -> Any: # noqa: ARG001
|
|
777
|
-
raise ServiceError("named pipes exist only on Windows")
|
|
778
|
-
|
|
779
|
-
def _close_handle(handle: Any) -> None: # noqa: ARG001
|
|
780
|
-
raise ServiceError("handles exist only on Windows")
|
|
781
|
-
|
|
782
|
-
def _pipe_exists(name: str) -> bool: # noqa: ARG001
|
|
783
|
-
raise ServiceError("named pipes exist only on Windows")
|
|
784
|
-
|
|
785
|
-
def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
|
|
786
|
-
raise ServiceError("Windows ACLs exist only on Windows")
|
|
787
|
-
|
|
788
|
-
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]: # noqa: ARG001
|
|
789
|
-
raise ServiceError("Windows token rules apply only on Windows")
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
# --- token -------------------------------------------------------------------
|
|
793
|
-
|
|
794
|
-
def ensure_token(path: Path) -> str:
|
|
795
|
-
"""Create the service token once (0600, owner only); never rotate silently."""
|
|
796
|
-
path = Path(path)
|
|
797
|
-
if path.exists():
|
|
798
|
-
return read_token(path)
|
|
799
|
-
ensure_private_dir(path.parent)
|
|
800
|
-
token = secrets.token_urlsafe(32)
|
|
801
|
-
atomic_write(path, token.encode(), 0o600)
|
|
802
|
-
return token
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
def read_token(path: Path) -> str:
|
|
806
|
-
"""Refuse a symlink, a file owned by someone else, or one others can read.
|
|
807
|
-
|
|
808
|
-
On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057):
|
|
809
|
-
windows_token_acl_problem over the file's flags, owner and ACEs; a refusal names the SID,
|
|
810
|
-
never the contents."""
|
|
811
|
-
path = Path(path)
|
|
812
|
-
info = os.lstat(path)
|
|
813
|
-
if stat.S_ISLNK(info.st_mode):
|
|
814
|
-
raise ServiceError("Token file %s is a symlink; refusing it." % path)
|
|
815
|
-
if WINDOWS:
|
|
816
|
-
problem = _win_token_problem(path, info)
|
|
817
|
-
if problem:
|
|
818
|
-
raise ServiceError(problem)
|
|
819
|
-
else:
|
|
820
|
-
if info.st_uid != os.getuid():
|
|
821
|
-
raise ServiceError("Token file %s belongs to another user." % path)
|
|
822
|
-
if info.st_mode & 0o077:
|
|
823
|
-
raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
|
|
824
|
-
token = path.read_text().strip()
|
|
825
|
-
if len(token) < 16:
|
|
826
|
-
raise ServiceError("Token file %s holds no usable token." % path)
|
|
827
|
-
return token
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def token_matches(presented: Optional[str], token: str, scheme: str = "Bearer") -> bool:
|
|
831
|
-
if not presented:
|
|
832
|
-
return False
|
|
833
|
-
value = presented
|
|
834
|
-
if scheme == "Bearer":
|
|
835
|
-
if not presented.startswith("Bearer "):
|
|
836
|
-
return False
|
|
837
|
-
value = presented[len("Bearer "):]
|
|
838
|
-
return hmac.compare_digest(value.strip().encode(), token.encode())
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
# --- network guard -----------------------------------------------------------
|
|
842
|
-
|
|
843
|
-
def check_request(port: int, host: Optional[str], origin: Optional[str] = None,
|
|
844
|
-
sec_fetch_site: Optional[str] = None) -> Optional[str]:
|
|
845
|
-
"""Return why a request must be refused (403), or None when it may proceed."""
|
|
846
|
-
allowed_hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port, "[::1]:%d" % port}
|
|
847
|
-
if host not in allowed_hosts:
|
|
848
|
-
return "Host %r is not this service." % host
|
|
849
|
-
if origin is not None and origin not in {"http://" + h for h in allowed_hosts}:
|
|
850
|
-
return "Origin %r is not this service." % origin
|
|
851
|
-
if sec_fetch_site == "cross-site":
|
|
852
|
-
return "Cross-site requests are refused."
|
|
853
|
-
return None
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
# --- descriptor --------------------------------------------------------------
|
|
857
|
-
|
|
858
|
-
class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
|
|
859
|
-
"""ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
|
|
860
|
-
no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
|
|
861
|
-
on a Mac with a slow resolver the port then stays bound but silent, so a host sees
|
|
862
|
-
neither an answer nor a refusal."""
|
|
863
|
-
|
|
864
|
-
daemon_threads = True
|
|
865
|
-
|
|
866
|
-
def server_bind(self) -> None:
|
|
867
|
-
socketserver.TCPServer.server_bind(self)
|
|
868
|
-
host, port = self.server_address[:2]
|
|
869
|
-
self.server_name, self.server_port = host, port
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
873
|
-
return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[str, Any]]]:
|
|
877
|
-
root = directory or services_dir()
|
|
878
|
-
found: List[Tuple[Path, Dict[str, Any]]] = []
|
|
879
|
-
if not root.is_dir():
|
|
880
|
-
return found
|
|
881
|
-
for path in sorted(root.glob("*.json")):
|
|
882
|
-
try:
|
|
883
|
-
found.append((path, json.loads(path.read_text(encoding="utf-8"))))
|
|
884
|
-
except (OSError, ValueError):
|
|
885
|
-
continue
|
|
886
|
-
return found
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
890
|
-
"""Structural checks mirroring service-descriptor.schema.json (the schema stays normative)."""
|
|
891
|
-
problems: List[str] = []
|
|
892
|
-
remote = placement_of(descriptor) == "remote"
|
|
893
|
-
if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
|
|
894
|
-
problems.append("placement must be local or remote")
|
|
895
|
-
life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
896
|
-
app = life_in.get("manager") == "person"
|
|
897
|
-
# DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
|
|
898
|
-
required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
|
|
899
|
-
if not remote:
|
|
900
|
-
required = required + ("paths",)
|
|
901
|
-
for key in required:
|
|
902
|
-
if key not in descriptor:
|
|
903
|
-
problems.append("missing %s" % key)
|
|
904
|
-
if problems:
|
|
905
|
-
return problems
|
|
906
|
-
if descriptor["protocol"] != PROTOCOL:
|
|
907
|
-
problems.append("protocol must be %s" % PROTOCOL)
|
|
908
|
-
if not _ID.match(str(descriptor["id"])):
|
|
909
|
-
problems.append("id must match %s" % _ID.pattern)
|
|
910
|
-
if not _INSTANCE.match(str(descriptor["instance"])):
|
|
911
|
-
problems.append("instance must match %s" % _INSTANCE.pattern)
|
|
912
|
-
if remote:
|
|
913
|
-
problems.extend(remote_origin_problems(descriptor["origin"]))
|
|
914
|
-
elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
|
|
915
|
-
problems.append("origin must be http://127.0.0.1:<port>")
|
|
916
|
-
auth = descriptor.get("auth") or {}
|
|
917
|
-
if "tokenFile" not in auth:
|
|
918
|
-
problems.append("auth.tokenFile is required")
|
|
919
|
-
elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
|
|
920
|
-
problems.append("auth.tokenFile must be a local path, never a network share")
|
|
921
|
-
paths = descriptor.get("paths") or {}
|
|
922
|
-
for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
|
|
923
|
-
if not _LOCAL_PATH.match(str(value)):
|
|
924
|
-
problems.append("paths must be local paths, never a network share: %r" % value)
|
|
925
|
-
header = auth.get("header", "Authorization")
|
|
926
|
-
scheme = auth.get("scheme", "Bearer")
|
|
927
|
-
if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
|
|
928
|
-
problems.append("auth.header must match %s" % _AUTH_HEADER.pattern)
|
|
929
|
-
if scheme not in ("Bearer", "none"):
|
|
930
|
-
problems.append("auth.scheme is Bearer or none")
|
|
931
|
-
if header != "Authorization" and scheme != "none":
|
|
932
|
-
problems.append("a custom auth header carries the raw token: scheme must be none")
|
|
933
|
-
life = descriptor.get("lifecycle") or {}
|
|
934
|
-
manager = life.get("manager")
|
|
935
|
-
if manager not in MANAGERS:
|
|
936
|
-
problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
|
|
937
|
-
# DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
|
|
938
|
-
if manager == "person" and "app" not in life:
|
|
939
|
-
problems.append("a person's app declares lifecycle.app, the installed app")
|
|
940
|
-
if "app" in life and manager != "person":
|
|
941
|
-
problems.append("lifecycle.app belongs to manager person only")
|
|
942
|
-
if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
|
|
943
|
-
problems.append("lifecycle.app must be a local path")
|
|
944
|
-
if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
945
|
-
problems.append("a launchd service declares label and plist")
|
|
946
|
-
if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
|
|
947
|
-
problems.append("lifecycle.plist must be a local path")
|
|
948
|
-
# DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
949
|
-
for field in ("label", "plist"):
|
|
950
|
-
if field in life and manager in _NOT_LAUNCHD:
|
|
951
|
-
problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
|
|
952
|
-
# DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
953
|
-
for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
|
|
954
|
-
if manager == owner and field not in life:
|
|
955
|
-
problems.append("a %s service declares %s" % (owner, field))
|
|
956
|
-
if field in life and manager != owner:
|
|
957
|
-
problems.append("lifecycle.%s belongs to manager %s only" % (field, owner))
|
|
958
|
-
if field in life and not pattern.match(str(life[field])):
|
|
959
|
-
problems.append("lifecycle.%s must match %s" % (field, pattern.pattern))
|
|
960
|
-
if remote:
|
|
961
|
-
if manager != "none":
|
|
962
|
-
problems.append("a remote service is supervised by its platform: lifecycle.manager must be none")
|
|
963
|
-
for field, of in (("label", "launchd"), ("plist", "launchd"), ("unit", "systemd"), ("task", "Task Scheduler")):
|
|
964
|
-
if field in life:
|
|
965
|
-
problems.append("a remote service has no %s %s" % (of, field))
|
|
966
|
-
if "update" in (descriptor.get("commands") or {}):
|
|
967
|
-
problems.append("a remote service declares no update command")
|
|
968
|
-
problems.extend(descriptor_member_problems(descriptor))
|
|
969
|
-
for name, argv in (descriptor.get("commands") or {}).items():
|
|
970
|
-
if name not in ("doctor", "update"):
|
|
971
|
-
problems.append("unknown command %s" % name)
|
|
972
|
-
elif not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
|
|
973
|
-
problems.append("command %s must be an argument array" % name)
|
|
974
|
-
elif not _LOCAL_PATH.match(argv[0]):
|
|
975
|
-
problems.append("command %s must start with an absolute or ~/ executable" % name)
|
|
976
|
-
return problems
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
# --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
|
|
980
|
-
|
|
981
|
-
#: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
|
|
982
|
-
PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
|
|
983
|
-
#: The kind of a runner's sign-in, never a credential's value (DEC-0061).
|
|
984
|
-
CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
|
|
985
|
-
#: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
|
|
986
|
-
SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
|
|
987
|
-
_PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
|
|
988
|
-
_RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
|
|
989
|
-
_RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
def _pointer(name: str) -> str:
|
|
993
|
-
"""A JSON Pointer token (RFC 6901 §4)."""
|
|
994
|
-
return str(name).replace("~", "~0").replace("/", "~1")
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
def _text(value: Any, low: int = 1, high: int = 200) -> bool:
|
|
998
|
-
return isinstance(value, str) and low <= len(value) <= high
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
|
|
1002
|
-
"""DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
|
|
1003
|
-
FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
|
|
1004
|
-
named by its place, never its value)."""
|
|
1005
|
-
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
1006
|
-
problems: List[str] = []
|
|
1007
|
-
life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
1008
|
-
by = descriptor.get("installedBy")
|
|
1009
|
-
places: List[Tuple[str, Any]] = []
|
|
1010
|
-
if isinstance(by, str):
|
|
1011
|
-
if not _text(by):
|
|
1012
|
-
problems.append("installedBy is the installer and its version, 1 to 200 characters")
|
|
1013
|
-
places.append(("/installedBy", by))
|
|
1014
|
-
elif isinstance(by, dict):
|
|
1015
|
-
problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
|
|
1016
|
-
if by.get("by") not in ("person", "node", "organization"):
|
|
1017
|
-
problems.append("installedBy.by is person, node or organization")
|
|
1018
|
-
if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
|
|
1019
|
-
problems.append("installedBy.role is operator or administrator for a person")
|
|
1020
|
-
if by.get("by") != "person" and "role" in by:
|
|
1021
|
-
problems.append("installedBy.role belongs to a person only")
|
|
1022
|
-
if not _text(by.get("tool")):
|
|
1023
|
-
problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
|
|
1024
|
-
places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
|
|
1025
|
-
else:
|
|
1026
|
-
problems.append("installedBy is the installer's name or {by, role?, tool}")
|
|
1027
|
-
if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
|
|
1028
|
-
problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
|
|
1029
|
-
runner = descriptor.get("runner")
|
|
1030
|
-
if "runner" in descriptor:
|
|
1031
|
-
if not isinstance(runner, dict):
|
|
1032
|
-
problems.append("runner is {kind?, credentialKind}")
|
|
1033
|
-
runner = {}
|
|
1034
|
-
problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
|
|
1035
|
-
if runner.get("credentialKind") not in CREDENTIAL_KINDS:
|
|
1036
|
-
problems.append("runner.credentialKind is subscription, key, platform-metered or none")
|
|
1037
|
-
if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
|
|
1038
|
-
problems.append("runner.kind is a runner kind's name")
|
|
1039
|
-
if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
|
|
1040
|
-
problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
|
|
1041
|
-
places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
|
|
1042
|
-
if "peer" in descriptor:
|
|
1043
|
-
peer = descriptor.get("peer")
|
|
1044
|
-
if not isinstance(peer, dict):
|
|
1045
|
-
problems.append("peer is {endpoint, custody, residency, maxDataClass}")
|
|
1046
|
-
peer = {}
|
|
1047
|
-
problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
|
|
1048
|
-
endpoint = peer.get("endpoint")
|
|
1049
|
-
match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
|
|
1050
|
-
if not match:
|
|
1051
|
-
problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
|
|
1052
|
-
elif _RESERVED_HOST.search(match.group("host")):
|
|
1053
|
-
problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
|
|
1054
|
-
if peer.get("custody") not in ("organization-managed", "personal"):
|
|
1055
|
-
problems.append("peer.custody is organization-managed or personal")
|
|
1056
|
-
residency = peer.get("residency")
|
|
1057
|
-
if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
|
|
1058
|
-
or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
|
|
1059
|
-
problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
|
|
1060
|
-
if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
|
|
1061
|
-
problems.append("peer.maxDataClass is public, internal, confidential or restricted")
|
|
1062
|
-
if "runner" not in descriptor:
|
|
1063
|
-
problems.append("a service offered to others declares runner (DEC-0061)")
|
|
1064
|
-
elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
|
|
1065
|
-
problems.append("a service offered to others never runs on a subscription (DEC-0061)")
|
|
1066
|
-
for place, value in places:
|
|
1067
|
-
if isinstance(value, str) and fabric_secrets.secret_argument(value):
|
|
1068
|
-
problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
|
|
1069
|
-
return problems
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
|
|
1073
|
-
"""The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
|
|
1074
|
-
`not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
|
|
1075
|
-
request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
|
|
1076
|
-
`peerRefusal`."""
|
|
1077
|
-
if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
|
|
1078
|
-
return "not-offered"
|
|
1079
|
-
ceiling = descriptor["peer"].get("maxDataClass")
|
|
1080
|
-
if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
|
|
1081
|
-
return "peer-invalid"
|
|
1082
|
-
if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
|
|
1083
|
-
return "data-class-unknown"
|
|
1084
|
-
return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
|
|
1088
|
-
"""What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
|
|
1089
|
-
translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
|
|
1090
|
-
when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
|
|
1091
|
-
`appDescriptorAction`."""
|
|
1092
|
-
if "/AppTranslocation/" in app_path:
|
|
1093
|
-
return "keep"
|
|
1094
|
-
if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
|
|
1095
|
-
return "write"
|
|
1096
|
-
recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
|
|
1097
|
-
return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
_MISSING_APP = object()
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
|
|
1104
|
-
"""Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
|
|
1105
|
-
A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
|
|
1106
|
-
life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
|
|
1107
|
-
return isinstance(life, dict) and life.get("manager") == "person" and not app_present
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
def app_present(descriptor: Dict[str, Any]) -> bool:
|
|
1111
|
-
"""Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
|
|
1112
|
-
life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
1113
|
-
app = life.get("app")
|
|
1114
|
-
try:
|
|
1115
|
-
return isinstance(app, str) and expand(app).exists()
|
|
1116
|
-
except ServiceError: # not a local path: nothing installed there
|
|
1117
|
-
return False
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
#: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
|
|
1121
|
-
FABRIC_SERVICE = ("fabric", "default")
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
def fabric_button_presence(directory: Optional[Path] = None) -> str:
|
|
1125
|
-
"""`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
|
|
1126
|
-
descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
|
|
1127
|
-
`data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
|
|
1128
|
-
not read here; a caller that sends anything reads it."""
|
|
1129
|
-
path = descriptor_path(*FABRIC_SERVICE, directory=directory)
|
|
1130
|
-
try:
|
|
1131
|
-
found = json.loads(path.read_text(encoding="utf-8"))
|
|
1132
|
-
except (OSError, ValueError):
|
|
1133
|
-
return "absent"
|
|
1134
|
-
if not isinstance(found, dict) or validate_descriptor(found):
|
|
1135
|
-
return "absent"
|
|
1136
|
-
return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
def placement_of(descriptor: Dict[str, Any]) -> str:
|
|
1140
|
-
"""DEC-0019: ``local`` unless the descriptor says ``remote``."""
|
|
1141
|
-
return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
def remote_origin_problems(origin: Any) -> List[str]:
|
|
1145
|
-
"""Problems with a remote origin: https, a public DNS name, an optional port, nothing else."""
|
|
1146
|
-
match = _REMOTE_ORIGIN.match(str(origin or ""))
|
|
1147
|
-
if not match:
|
|
1148
|
-
return ["a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal"]
|
|
1149
|
-
if _RESERVED_HOST.search(match.group(1)):
|
|
1150
|
-
return ["a remote service cannot live on the reserved name %s" % match.group(1)]
|
|
1151
|
-
if match.group(2) is not None and not 1 <= int(match.group(2)) <= 65535:
|
|
1152
|
-
return ["the origin port is out of range"]
|
|
1153
|
-
return []
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
def port_of(origin: str) -> int:
|
|
1157
|
-
match = _ORIGIN.match(origin)
|
|
1158
|
-
if not match:
|
|
1159
|
-
raise ServiceError("Origin %r is not http://127.0.0.1:<port>." % origin)
|
|
1160
|
-
return int(match.group(1))
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Path:
|
|
1164
|
-
"""Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim)."""
|
|
1165
|
-
problems = validate_descriptor(descriptor)
|
|
1166
|
-
if problems:
|
|
1167
|
-
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
1168
|
-
root = directory or services_dir()
|
|
1169
|
-
me = "%s.%s" % (descriptor["id"], descriptor["instance"])
|
|
1170
|
-
# DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
1171
|
-
port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
|
|
1172
|
-
for path, other in read_descriptors(root):
|
|
1173
|
-
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
1174
|
-
if key == me or port is None: # a remote origin's port is another computer's; an app claims none
|
|
1175
|
-
continue
|
|
1176
|
-
try:
|
|
1177
|
-
other_port = port_of(str(other.get("origin", "")))
|
|
1178
|
-
except ServiceError:
|
|
1179
|
-
continue
|
|
1180
|
-
if other_port == port:
|
|
1181
|
-
raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
|
|
1182
|
-
ensure_private_dir(root)
|
|
1183
|
-
target = descriptor_path(descriptor["id"], descriptor["instance"], root)
|
|
1184
|
-
atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
|
|
1185
|
-
return target
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
def remove_descriptor(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> bool:
|
|
1189
|
-
try:
|
|
1190
|
-
descriptor_path(service_id, instance, directory).unlink()
|
|
1191
|
-
return True
|
|
1192
|
-
except FileNotFoundError:
|
|
1193
|
-
return False
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
# --- build identity & well-known ------------------------------------------------
|
|
1197
|
-
|
|
1198
|
-
def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] = None) -> Dict[str, Any]:
|
|
1199
|
-
"""Commit (env FABRIC_BUILD_COMMIT, else git) or a sha256 digest of the package file."""
|
|
1200
|
-
commit = os.environ.get("FABRIC_BUILD_COMMIT")
|
|
1201
|
-
dirty: Optional[bool] = None
|
|
1202
|
-
if not commit and repo_root is not None:
|
|
1203
|
-
try:
|
|
1204
|
-
commit = subprocess.run(["git", "-C", str(repo_root), "rev-parse", "--short=12", "HEAD"],
|
|
1205
|
-
capture_output=True, text=True, timeout=5, check=True).stdout.strip()
|
|
1206
|
-
status = subprocess.run(["git", "-C", str(repo_root), "status", "--porcelain", "--untracked-files=no"],
|
|
1207
|
-
capture_output=True, text=True, timeout=5, check=True).stdout
|
|
1208
|
-
dirty = bool(status.strip())
|
|
1209
|
-
except (OSError, subprocess.SubprocessError):
|
|
1210
|
-
commit = None
|
|
1211
|
-
info: Dict[str, Any] = {"builtAt": now_iso()}
|
|
1212
|
-
if commit:
|
|
1213
|
-
info["commit"] = commit
|
|
1214
|
-
if dirty is not None:
|
|
1215
|
-
info["dirty"] = dirty
|
|
1216
|
-
elif package_file is not None and Path(package_file).is_file():
|
|
1217
|
-
info["digest"] = "sha256:" + hashlib.sha256(Path(package_file).read_bytes()).hexdigest()
|
|
1218
|
-
else:
|
|
1219
|
-
raise ServiceError("No build identity: set FABRIC_BUILD_COMMIT, pass repo_root or package_file.")
|
|
1220
|
-
return info
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
1224
|
-
_KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
def dashboard_extension_problems(value: Any) -> List[str]:
|
|
1228
|
-
"""What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json)."""
|
|
1229
|
-
if not isinstance(value, dict):
|
|
1230
|
-
return ["the declaration is not an object"]
|
|
1231
|
-
out: List[str] = []
|
|
1232
|
-
extra = sorted(set(value) - {"minWidth", "appearance", "themes", "kit"})
|
|
1233
|
-
if extra:
|
|
1234
|
-
out.append("unknown field(s): %s" % ", ".join(extra))
|
|
1235
|
-
width = value.get("minWidth")
|
|
1236
|
-
if isinstance(width, bool) or not isinstance(width, int):
|
|
1237
|
-
out.append("minWidth is required and must be a whole number of CSS pixels")
|
|
1238
|
-
elif not 360 <= width <= 1280:
|
|
1239
|
-
out.append("minWidth %d is outside 360…1280" % width)
|
|
1240
|
-
if "appearance" in value:
|
|
1241
|
-
a = value["appearance"]
|
|
1242
|
-
if not isinstance(a, list) or len(a) > 2 or len(set(map(str, a))) != len(a) or any(x not in ("scheme", "theme") for x in a):
|
|
1243
|
-
out.append("appearance is a list of distinct fields from scheme, theme")
|
|
1244
|
-
if "themes" in value:
|
|
1245
|
-
t = value["themes"]
|
|
1246
|
-
if not isinstance(t, list) or not 1 <= len(t) <= 32 or len(set(map(str, t))) != len(t) \
|
|
1247
|
-
or any(not isinstance(x, str) or not _THEME_ID.match(x) for x in t):
|
|
1248
|
-
out.append("themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$")
|
|
1249
|
-
if "kit" in value and not (isinstance(value["kit"], str) and _KIT_REF.match(value["kit"])):
|
|
1250
|
-
out.append("kit is name@semver, for example %s" % DASHBOARD_KIT)
|
|
1251
|
-
return out
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("scheme", "theme"),
|
|
1255
|
-
themes: Optional[Sequence[str]] = REFERENCE_THEMES,
|
|
1256
|
-
kit: Optional[str] = DASHBOARD_KIT) -> Dict[str, Any]:
|
|
1257
|
-
"""The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
|
|
1258
|
-
when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that."""
|
|
1259
|
-
value: Dict[str, Any] = {"minWidth": min_width}
|
|
1260
|
-
if appearance:
|
|
1261
|
-
value["appearance"] = list(appearance)
|
|
1262
|
-
if themes:
|
|
1263
|
-
value["themes"] = list(themes)
|
|
1264
|
-
if kit:
|
|
1265
|
-
value["kit"] = kit
|
|
1266
|
-
problems = dashboard_extension_problems(value)
|
|
1267
|
-
if problems:
|
|
1268
|
-
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
1269
|
-
return {DASHBOARD_EXTENSION_KEY: value}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
_PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
|
|
1273
|
-
_RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
1274
|
-
_MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
|
|
1275
|
-
ROUTES = ("subscription", "key")
|
|
1276
|
-
#: A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models.
|
|
1277
|
-
USAGE_ROUTES = ROUTES + ("platform-metered",)
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
def configuration_problems(doc: Any) -> List[str]:
|
|
1281
|
-
"""DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it."""
|
|
1282
|
-
if not isinstance(doc, dict):
|
|
1283
|
-
return ["configuration is an object"]
|
|
1284
|
-
problems = ["configuration has no member %s" % k for k in doc if k not in ("slots", "resolved")]
|
|
1285
|
-
slots = doc.get("slots")
|
|
1286
|
-
if not isinstance(slots, list) or len(slots) > 2 or len(set(map(str, slots))) != len(slots) or not all(s in ("runner", "model") for s in slots):
|
|
1287
|
-
problems.append("slots is a list of runner and model, each once")
|
|
1288
|
-
resolved = doc.get("resolved")
|
|
1289
|
-
if not isinstance(resolved, dict):
|
|
1290
|
-
return problems + ["resolved is an object"]
|
|
1291
|
-
for k, v in resolved.items():
|
|
1292
|
-
ok = {"from": lambda x: x in ("request", "agent", "binding", "estate"),
|
|
1293
|
-
"runnerProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
|
|
1294
|
-
"modelProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
|
|
1295
|
-
"runnerKind": lambda x: isinstance(x, str) and bool(_RUNNER_KIND.match(x)),
|
|
1296
|
-
"route": lambda x: x in ROUTES,
|
|
1297
|
-
"models": lambda x: isinstance(x, list) and len(x) <= 16 and all(isinstance(m, str) and _MODEL_ID.match(m) for m in x)}.get(k)
|
|
1298
|
-
if ok is None:
|
|
1299
|
-
problems.append("resolved has no member %s" % k)
|
|
1300
|
-
elif not ok(v):
|
|
1301
|
-
problems.append("resolved.%s is not valid" % k)
|
|
1302
|
-
return problems
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
1306
|
-
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
1307
|
-
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
1308
|
-
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
1309
|
-
extensions: Optional[Dict[str, Any]] = None,
|
|
1310
|
-
configuration: Optional[Dict[str, Any]] = None,
|
|
1311
|
-
protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
1312
|
-
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
1313
|
-
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
1314
|
-
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
1315
|
-
in it must be valid (dashboard_extension builds one)."""
|
|
1316
|
-
if status not in STATUSES:
|
|
1317
|
-
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
1318
|
-
degraded = list(degraded)
|
|
1319
|
-
if status == "ready" and degraded:
|
|
1320
|
-
status = "degraded"
|
|
1321
|
-
if "events" not in surfaces:
|
|
1322
|
-
raise ServiceError("surfaces.events is required.")
|
|
1323
|
-
doc: Dict[str, Any] = {
|
|
1324
|
-
"protocol": PROTOCOL,
|
|
1325
|
-
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
1326
|
-
"process": {"pid": pid or os.getpid(), "startedAt": started_at},
|
|
1327
|
-
"status": status,
|
|
1328
|
-
"degraded": degraded,
|
|
1329
|
-
"surfaces": surfaces,
|
|
1330
|
-
"update": {"available": update_available},
|
|
1331
|
-
}
|
|
1332
|
-
if summary:
|
|
1333
|
-
doc["summary"] = list(summary)[:6]
|
|
1334
|
-
if extensions:
|
|
1335
|
-
if DASHBOARD_EXTENSION_KEY in extensions:
|
|
1336
|
-
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
1337
|
-
if problems:
|
|
1338
|
-
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
1339
|
-
doc["extensions"] = dict(extensions)
|
|
1340
|
-
if configuration is not None:
|
|
1341
|
-
problems = configuration_problems(configuration)
|
|
1342
|
-
if problems:
|
|
1343
|
-
raise ServiceError("configuration: %s." % "; ".join(problems))
|
|
1344
|
-
doc["configuration"] = configuration
|
|
1345
|
-
if protocols is not None:
|
|
1346
|
-
# DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so
|
|
1347
|
-
# a service lists them only once its hosts are pinned at or after it.
|
|
1348
|
-
problems = protocols_findings(list(protocols))
|
|
1349
|
-
if problems:
|
|
1350
|
-
raise ServiceError("protocols: %s." % "; ".join(f["message"] for f in problems))
|
|
1351
|
-
doc["protocols"] = list(protocols)
|
|
1352
|
-
return doc
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
# --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data --------------------------------------
|
|
1356
|
-
|
|
1357
|
-
#: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
|
|
1358
|
-
#: holds it equal to the contract's register at the pin.
|
|
1359
|
-
PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
|
|
1360
|
-
"access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "coding-agents": ("1",), "enroll-token": ("1",), "entitlement": ("1",),
|
|
1361
|
-
"entitlement-key-set": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
|
|
1362
|
-
"fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
|
|
1363
|
-
"fabric-interop": ("0.1", "0.2"), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
|
|
1364
|
-
"fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
|
|
1365
|
-
"fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
|
|
1366
|
-
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pairing-message": ("1",), "pipeline": ("0.1",),
|
|
1367
|
-
"revocation-delta": ("1",), "session-handoff": ("1",), "share-registration": ("1",),
|
|
1368
|
-
"share-registration-receipt": ("1",), "support-quality-daily": ("1",), "window-lease": ("1",),
|
|
1369
|
-
}
|
|
1370
|
-
MAX_PROTOCOLS = 64
|
|
1371
|
-
_REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
def parse_revision(value: Any) -> Optional[Tuple[str, str]]:
|
|
1375
|
-
"""`<name>/<revision>` split, or None."""
|
|
1376
|
-
match = _REVISION.fullmatch(value) if isinstance(value, str) else None
|
|
1377
|
-
return (match.group(1), match.group(2)) if match else None
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
def accepted_revisions(name: str, newest: Optional[str] = None) -> List[str]:
|
|
1381
|
-
"""The revisions a receiver implementing ``name`` up to ``newest`` accepts: N and, when the register has one, N−1."""
|
|
1382
|
-
known = PROTOCOL_REVISIONS.get(name, ())
|
|
1383
|
-
if not known:
|
|
1384
|
-
return []
|
|
1385
|
-
top = len(known) - 1 if newest is None else (known.index(newest) if newest in known else -1)
|
|
1386
|
-
if top < 0:
|
|
1387
|
-
return []
|
|
1388
|
-
return ["%s/%s" % (name, r) for r in reversed(known[max(0, top - 1):top + 1])]
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
def negotiate_revision(received: Any, accepted: Sequence[Any]) -> Dict[str, Any]:
|
|
1392
|
-
"""A receiver's answer to a document naming ``received`` (DEC-0067, the contract's `negotiateRevision`):
|
|
1393
|
-
``{"ok": True}``; the `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted
|
|
1394
|
-
revisions of that protocol (all of them when it names none the receiver reads); or ``{"fault":
|
|
1395
|
-
"no-accepted-revisions"}``, the receiver's own, when it accepts no well-formed revision. Revisions compare exactly."""
|
|
1396
|
-
usable: List[str] = []
|
|
1397
|
-
for entry in accepted if isinstance(accepted, (list, tuple)) else []:
|
|
1398
|
-
if parse_revision(entry) and entry not in usable:
|
|
1399
|
-
usable.append(entry)
|
|
1400
|
-
usable = usable[:MAX_PROTOCOLS]
|
|
1401
|
-
if not usable:
|
|
1402
|
-
return {"fault": "no-accepted-revisions"}
|
|
1403
|
-
if isinstance(received, str) and received in usable:
|
|
1404
|
-
return {"ok": True}
|
|
1405
|
-
parsed = parse_revision(received)
|
|
1406
|
-
same = [e for e in usable if parsed and parse_revision(e)[0] == parsed[0]]
|
|
1407
|
-
return {"error": {"code": "schema-unsupported", "message": "this receiver does not read that protocol revision",
|
|
1408
|
-
"data": {"protocol": received[:80] if isinstance(received, str) else "", "accepted": same or usable}}}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
def protocols_findings(protocols: Any, prefix: str = "/protocols") -> List[Dict[str, str]]:
|
|
1412
|
-
"""FAC-SEM-099 (DEC-0067): 1 to 64 entries, each `<name>/<revision>` once; at most two revisions of one protocol (an
|
|
1413
|
-
unpublished one refused and not counted), and two of a contract protocol are N and N−1, consecutive in the register."""
|
|
1414
|
-
flag = lambda at, message: {"code": "FAC-SEM-099", "instancePath": at, "message": message} # noqa: E731
|
|
1415
|
-
if not isinstance(protocols, list):
|
|
1416
|
-
return [flag(prefix, "protocols is a list of protocol revisions")]
|
|
1417
|
-
if not 1 <= len(protocols) <= MAX_PROTOCOLS:
|
|
1418
|
-
return [flag(prefix, "protocols lists 1 to %d revisions" % MAX_PROTOCOLS)]
|
|
1419
|
-
findings: List[Dict[str, str]] = []
|
|
1420
|
-
by_name: Dict[str, List[int]] = {}
|
|
1421
|
-
seen: List[str] = []
|
|
1422
|
-
for i, entry in enumerate(protocols):
|
|
1423
|
-
at = "%s/%d" % (prefix, i)
|
|
1424
|
-
parsed = parse_revision(entry)
|
|
1425
|
-
if not parsed:
|
|
1426
|
-
findings.append(flag(at, "a protocol revision is <name>/<revision>, such as fabric-service/0.1"))
|
|
1427
|
-
continue
|
|
1428
|
-
if entry in seen:
|
|
1429
|
-
findings.append(flag(at, "each protocol revision once"))
|
|
1430
|
-
continue
|
|
1431
|
-
seen.append(entry)
|
|
1432
|
-
known = PROTOCOL_REVISIONS.get(parsed[0])
|
|
1433
|
-
if known and parsed[1] not in known:
|
|
1434
|
-
findings.append(flag(at, "%s has no published revision %s" % parsed))
|
|
1435
|
-
continue
|
|
1436
|
-
indices = by_name.setdefault(parsed[0], [])
|
|
1437
|
-
indices.append(known.index(parsed[1]) if known else -1)
|
|
1438
|
-
if len(indices) > 2:
|
|
1439
|
-
findings.append(flag(at, "at most 2 revisions of one protocol: N and N−1"))
|
|
1440
|
-
elif known and len(indices) == 2 and abs(indices[0] - indices[1]) != 1:
|
|
1441
|
-
findings.append(flag(at, "two revisions of one protocol are N and N−1, consecutive in the register"))
|
|
1442
|
-
return findings
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
_SCHEMA_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+", re.ASCII)
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
def _scalar(value: Any) -> bool:
|
|
1449
|
-
return value is None or isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= 500)
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
def feed_data_problems(data: Any) -> List[str]:
|
|
1453
|
-
"""DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101: no string in `value`, at either
|
|
1454
|
-
level, under a credential's name or of a credential's shape (named by its place, never its value)."""
|
|
1455
|
-
import fabric_secrets # noqa: E402 - beside this file; the contract's detector
|
|
1456
|
-
if not isinstance(data, dict) or set(data) != {"schema", "value"}:
|
|
1457
|
-
return ["data is {schema, value}"]
|
|
1458
|
-
problems: List[str] = []
|
|
1459
|
-
if not (isinstance(data["schema"], str) and _SCHEMA_URI.fullmatch(data["schema"])):
|
|
1460
|
-
problems.append("data.schema is the absolute URI of a payload schema the service publishes")
|
|
1461
|
-
value = data["value"]
|
|
1462
|
-
if not isinstance(value, dict) or len(value) > 32:
|
|
1463
|
-
return problems + ["data.value is an object of at most 32 members"]
|
|
1464
|
-
for key, member in value.items():
|
|
1465
|
-
if _scalar(member):
|
|
1466
|
-
continue
|
|
1467
|
-
if isinstance(member, list) and len(member) <= 32 and all(_scalar(m) for m in member):
|
|
1468
|
-
continue
|
|
1469
|
-
if isinstance(member, dict) and len(member) <= 32 and all(_scalar(m) for m in member.values()):
|
|
1470
|
-
continue
|
|
1471
|
-
problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members" % key)
|
|
1472
|
-
if problems:
|
|
1473
|
-
return problems
|
|
1474
|
-
for finding in feed_data_findings({"events": [{"data": data}]}):
|
|
1475
|
-
problems.append("FAC-SEM-101: %s %s" % (finding["instancePath"].replace("/events/0", ""), finding["message"]))
|
|
1476
|
-
return problems
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
def _pointer_token(name: str) -> str:
|
|
1480
|
-
return str(name).replace("~", "~0").replace("/", "~1")
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
def feed_data_findings(page: Any) -> List[Dict[str, str]]:
|
|
1484
|
-
"""FAC-SEM-101 over an events page (the contract's `feedDataFindings`): a list's items and an object's members sit
|
|
1485
|
-
under the outer name too; a number is a count, never a credential."""
|
|
1486
|
-
import fabric_secrets # noqa: E402
|
|
1487
|
-
findings: List[Dict[str, str]] = []
|
|
1488
|
-
|
|
1489
|
-
def check(named: bool, member: Any, at: str) -> None:
|
|
1490
|
-
if not isinstance(member, str):
|
|
1491
|
-
return
|
|
1492
|
-
if named:
|
|
1493
|
-
findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data puts no string under a credential's name"})
|
|
1494
|
-
elif fabric_secrets.secret_value(member) or fabric_secrets.secret_url(member):
|
|
1495
|
-
findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data carries no value shaped like a credential"})
|
|
1496
|
-
|
|
1497
|
-
events = page.get("events") if isinstance(page, dict) else None
|
|
1498
|
-
for i, event in enumerate(events if isinstance(events, list) else []):
|
|
1499
|
-
data = event.get("data") if isinstance(event, dict) else None
|
|
1500
|
-
value = data.get("value") if isinstance(data, dict) else None
|
|
1501
|
-
if not isinstance(value, dict):
|
|
1502
|
-
continue
|
|
1503
|
-
for key, member in value.items():
|
|
1504
|
-
at = "/events/%d/data/value/%s" % (i, _pointer_token(key))
|
|
1505
|
-
named = fabric_secrets.credential_name(key)
|
|
1506
|
-
check(named, member, at)
|
|
1507
|
-
if isinstance(member, list):
|
|
1508
|
-
for j, item in enumerate(member):
|
|
1509
|
-
check(named, item, "%s/%d" % (at, j))
|
|
1510
|
-
elif isinstance(member, dict):
|
|
1511
|
-
for inner, leaf in member.items():
|
|
1512
|
-
check(named or fabric_secrets.credential_name(inner), leaf, "%s/%s" % (at, _pointer_token(inner)))
|
|
1513
|
-
return findings
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
# --- events ------------------------------------------------------------------
|
|
1517
|
-
|
|
1518
|
-
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
1519
|
-
link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
|
|
1520
|
-
span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
1521
|
-
"""One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
|
|
1522
|
-
fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
|
|
1523
|
-
|
|
1524
|
-
An event about traced work carries its trace as a pair, `trace_id` and `span_id`
|
|
1525
|
-
(fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
|
|
1526
|
-
if level not in LEVELS:
|
|
1527
|
-
raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
|
|
1528
|
-
if not _KIND.match(kind):
|
|
1529
|
-
raise ServiceError("kind %r must be dotted lowercase." % kind)
|
|
1530
|
-
text = " ".join(str(text).split())
|
|
1531
|
-
if not text:
|
|
1532
|
-
raise ServiceError("An event needs a sentence.")
|
|
1533
|
-
if link is not None and (not link.startswith("/") or link.startswith("//")):
|
|
1534
|
-
raise ServiceError("link must be a path on this service.")
|
|
1535
|
-
if (trace_id is None) != (span_id is None):
|
|
1536
|
-
raise ServiceError("An event carries traceId and spanId together, or neither.")
|
|
1537
|
-
if trace_id is not None and not (_TRACE_ID.match(trace_id) and _SPAN_ID.match(str(span_id))):
|
|
1538
|
-
raise ServiceError("traceId is 32 and spanId 16 lowercase hex characters, not all zeros.")
|
|
1539
|
-
event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
|
|
1540
|
-
if subject:
|
|
1541
|
-
event["subject"] = subject
|
|
1542
|
-
if link:
|
|
1543
|
-
event["link"] = link
|
|
1544
|
-
if notify:
|
|
1545
|
-
event["notify"] = True
|
|
1546
|
-
if trace_id is not None:
|
|
1547
|
-
event["traceId"], event["spanId"] = trace_id, span_id
|
|
1548
|
-
if data is not None:
|
|
1549
|
-
problems = feed_data_problems(data)
|
|
1550
|
-
if problems:
|
|
1551
|
-
raise ServiceError("Event data: %s." % "; ".join(problems))
|
|
1552
|
-
event["data"] = {"schema": data["schema"], "value": data["value"]}
|
|
1553
|
-
return event
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
def parse_limit(raw: Optional[str]) -> int:
|
|
1557
|
-
try:
|
|
1558
|
-
value = int(raw) if raw not in (None, "") else EVENTS_DEFAULT_LIMIT
|
|
1559
|
-
except ValueError:
|
|
1560
|
-
raise ServiceError("limit must be an integer.") from None
|
|
1561
|
-
return max(1, min(EVENTS_MAX_LIMIT, value))
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
def events_page(fetch: Callable[[Optional[str], int], Iterable[Dict[str, Any]]],
|
|
1565
|
-
after: Optional[str], limit: int) -> Dict[str, Any]:
|
|
1566
|
-
"""A cursor page over the log the service already keeps.
|
|
1567
|
-
|
|
1568
|
-
`fetch(after, limit)` returns events with ids greater than `after` in
|
|
1569
|
-
ascending order (or the newest `limit`, ascending, when `after` is None).
|
|
1570
|
-
"""
|
|
1571
|
-
events = list(fetch(after, limit))[:limit]
|
|
1572
|
-
cursor = events[-1]["id"] if events else (after or None)
|
|
1573
|
-
return {"events": events, "cursor": cursor}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
class JsonlEventLog:
|
|
1577
|
-
"""A minimal append-only log for services that keep none yet. Ids are integers as strings."""
|
|
1578
|
-
|
|
1579
|
-
def __init__(self, path: Path, keep: int = 5000):
|
|
1580
|
-
self.path = Path(path)
|
|
1581
|
-
self.keep = keep
|
|
1582
|
-
|
|
1583
|
-
def _all(self) -> List[Dict[str, Any]]:
|
|
1584
|
-
if not self.path.exists():
|
|
1585
|
-
return []
|
|
1586
|
-
out = []
|
|
1587
|
-
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
1588
|
-
try:
|
|
1589
|
-
out.append(json.loads(line))
|
|
1590
|
-
except ValueError:
|
|
1591
|
-
continue
|
|
1592
|
-
return out
|
|
1593
|
-
|
|
1594
|
-
def append(self, kind: str, level: str, text: str, **extra: Any) -> Dict[str, Any]:
|
|
1595
|
-
rows = self._all()
|
|
1596
|
-
next_id = int(rows[-1]["id"]) + 1 if rows else 1
|
|
1597
|
-
event = make_event(next_id, now_iso(), kind, level, text, **extra)
|
|
1598
|
-
rows.append(event)
|
|
1599
|
-
rows = rows[-self.keep:]
|
|
1600
|
-
atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
|
|
1601
|
-
return event
|
|
1602
|
-
|
|
1603
|
-
def fetch(self, after: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
|
1604
|
-
rows = self._all()
|
|
1605
|
-
if after is None:
|
|
1606
|
-
return rows[-limit:]
|
|
1607
|
-
try:
|
|
1608
|
-
floor = int(after)
|
|
1609
|
-
except ValueError:
|
|
1610
|
-
raise ServiceError("cursor %r is not from this service." % after) from None
|
|
1611
|
-
return [r for r in rows if int(r["id"]) > floor][:limit]
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
# --- usage report (DEC-0021) -------------------------------------------------------
|
|
1615
|
-
|
|
1616
|
-
USAGE_PATH = "/fabric/v1/usage"
|
|
1617
|
-
USAGE_DAYS = 31
|
|
1618
|
-
COST_BASES = ("provider", "price-list", "unknown")
|
|
1619
|
-
_PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
|
|
1623
|
-
cache_read_tokens: int = 0, cache_write_tokens: int = 0,
|
|
1624
|
-
cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
|
|
1625
|
-
at: Optional[str] = None, route: Optional[str] = None,
|
|
1626
|
-
project_id: Optional[str] = None, task_run_id: Optional[str] = None,
|
|
1627
|
-
tokens_measured: bool = True) -> Dict[str, Any]:
|
|
1628
|
-
"""One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
1629
|
-
|
|
1630
|
-
`tokens_measured=False` marks a call whose token counts are a lower bound or unknown (the provider returned none,
|
|
1631
|
-
the stream was cut): the report counts it in `unmeasuredCalls` and its token totals are a lower bound (DEC-0091).
|
|
1632
|
-
|
|
1633
|
-
`project_id` (a URI, kept in its normal form, DEC-0076) and `task_run_id` (the service's own run id) attribute the
|
|
1634
|
-
call to the project the service ran it for (DEC-0065); they never name a caller, session or person.
|
|
1635
|
-
|
|
1636
|
-
`cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
|
|
1637
|
-
never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
|
|
1638
|
-
(computed from a published price list); it is `unknown` when there is no cost."""
|
|
1639
|
-
if not _PROVIDER.match(provider or ""):
|
|
1640
|
-
raise ServiceError("provider must be a lowercase name such as anthropic or openrouter.")
|
|
1641
|
-
model = str(model or "").strip()
|
|
1642
|
-
if not model or len(model) > 128:
|
|
1643
|
-
raise ServiceError("model must be 1 to 128 characters.")
|
|
1644
|
-
counts = {"inputTokens": input_tokens, "outputTokens": output_tokens,
|
|
1645
|
-
"cacheReadTokens": cache_read_tokens, "cacheWriteTokens": cache_write_tokens}
|
|
1646
|
-
for name, value in counts.items():
|
|
1647
|
-
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
1648
|
-
raise ServiceError("%s must be a non-negative integer." % name)
|
|
1649
|
-
if cost_usd is not None and (isinstance(cost_usd, bool) or not isinstance(cost_usd, (int, float)) or cost_usd < 0 or cost_usd != cost_usd):
|
|
1650
|
-
raise ServiceError("cost_usd must be a non-negative number or None.")
|
|
1651
|
-
basis = cost_basis or ("unknown" if cost_usd is None else "provider")
|
|
1652
|
-
if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
|
|
1653
|
-
raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
|
|
1654
|
-
if route is not None and route not in USAGE_ROUTES:
|
|
1655
|
-
raise ServiceError("route is subscription, key or platform-metered (DEC-0046, DEC-0061).")
|
|
1656
|
-
receipt = {"at": at or now_iso(), "provider": provider, "model": model, **counts,
|
|
1657
|
-
"costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
|
|
1658
|
-
if tokens_measured is not True:
|
|
1659
|
-
if tokens_measured is not False:
|
|
1660
|
-
raise ServiceError("tokens_measured is True or False.")
|
|
1661
|
-
receipt["tokensMeasured"] = False
|
|
1662
|
-
if route is not None:
|
|
1663
|
-
receipt["route"] = route # the operator's own login in its vendor's runner, an issued key, or the platform's metered models
|
|
1664
|
-
if task_run_id is not None and project_id is None:
|
|
1665
|
-
raise ServiceError("a task run belongs to a project: give project_id with task_run_id.")
|
|
1666
|
-
if project_id is not None:
|
|
1667
|
-
import fabric_identifiers # noqa: E402 - beside this file
|
|
1668
|
-
if not (isinstance(project_id, str) and _URI.fullmatch(project_id)):
|
|
1669
|
-
raise ServiceError("project_id is the URI of the project the service ran the call for.")
|
|
1670
|
-
receipt["projectId"] = fabric_identifiers.normal_identifier(project_id)
|
|
1671
|
-
if task_run_id is not None:
|
|
1672
|
-
if not (isinstance(task_run_id, str) and _TASK_RUN_ID.fullmatch(task_run_id)):
|
|
1673
|
-
raise ServiceError("task_run_id is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.")
|
|
1674
|
-
receipt["taskRunId"] = task_run_id
|
|
1675
|
-
return receipt
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S+", re.ASCII)
|
|
1679
|
-
_TASK_RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", re.ASCII)
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[Dict[str, Any]]:
|
|
1683
|
-
"""DEC-0065's `byProject`: the window's calls by the project the service ran them for, each project once (by its
|
|
1684
|
-
normal form, DEC-0076) and each task run once within it. A call for no project is in no row."""
|
|
1685
|
-
projects: Dict[str, Dict[str, Any]] = {}
|
|
1686
|
-
for r in receipts:
|
|
1687
|
-
if not (first <= str(r.get("at", ""))[:10] <= last) or not r.get("projectId"):
|
|
1688
|
-
continue
|
|
1689
|
-
row = projects.setdefault(r["projectId"], {"projectId": r["projectId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0, "_runs": {}})
|
|
1690
|
-
row["calls"] += 1
|
|
1691
|
-
if r.get("costUsd") is None:
|
|
1692
|
-
row["unpricedCalls"] += 1
|
|
1693
|
-
else:
|
|
1694
|
-
row["_cost"] += float(r["costUsd"])
|
|
1695
|
-
if r.get("taskRunId"):
|
|
1696
|
-
run = row["_runs"].setdefault(r["taskRunId"], {"taskRunId": r["taskRunId"], "calls": 0, "unpricedCalls": 0, "_cost": 0.0})
|
|
1697
|
-
run["calls"] += 1
|
|
1698
|
-
if r.get("costUsd") is None:
|
|
1699
|
-
run["unpricedCalls"] += 1
|
|
1700
|
-
else:
|
|
1701
|
-
run["_cost"] += float(r["costUsd"])
|
|
1702
|
-
out = []
|
|
1703
|
-
for key in sorted(projects):
|
|
1704
|
-
row = projects[key]
|
|
1705
|
-
runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
|
|
1706
|
-
row = _close_totals(row)
|
|
1707
|
-
if runs:
|
|
1708
|
-
row["taskRuns"] = runs
|
|
1709
|
-
out.append(row)
|
|
1710
|
-
return out
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
|
|
1714
|
-
row["calls"] += 1
|
|
1715
|
-
if receipt.get("tokensMeasured") is False:
|
|
1716
|
-
row["unmeasuredCalls"] = row.get("unmeasuredCalls", 0) + 1 # written only when above 0: absent is 0 (DEC-0091)
|
|
1717
|
-
for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
1718
|
-
row[name] += int(receipt.get(name) or 0)
|
|
1719
|
-
if receipt.get("costUsd") is None:
|
|
1720
|
-
row["unpricedCalls"] += 1
|
|
1721
|
-
else:
|
|
1722
|
-
row["_cost"] += float(receipt["costUsd"])
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
1726
|
-
cost = row.pop("_cost")
|
|
1727
|
-
row["costUsd"] = None if row["calls"] and row["unpricedCalls"] == row["calls"] else round(cost, 6)
|
|
1728
|
-
return row
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
# --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` -----------------------------
|
|
1732
|
-
|
|
1733
|
-
_USAGE_WINDOW_DAYS = 31
|
|
1734
|
-
_DAY_MS = 86_400_000
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
def _usage_count(value: Any) -> int:
|
|
1738
|
-
"""The contract's `count`: Number.isSafeInteger and not negative, so 5.0 (which JavaScript reads as 5) counts."""
|
|
1739
|
-
import fabric_estate # noqa: E402
|
|
1740
|
-
return int(value) if not isinstance(value, bool) and fabric_estate._safe_integer(value) and value >= 0 else 0
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
def _usage_money(value: Any) -> Optional[float]:
|
|
1744
|
-
"""A JSON number as JavaScript holds it (an integer beyond a double is Infinity), or None for anything else."""
|
|
1745
|
-
import fabric_estate # noqa: E402
|
|
1746
|
-
return fabric_estate._double(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else None
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
def _usage_cost(value: Any) -> float:
|
|
1750
|
-
"""The contract's `cost`: a known, finite, non-negative cost, or 0."""
|
|
1751
|
-
money = _usage_money(value)
|
|
1752
|
-
return money if money is not None and math.isfinite(money) and money >= 0 else 0.0
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
def _exceeds(a: float, b: float) -> bool:
|
|
1756
|
-
return a - b > 0.000001 + 1e-9 * max(abs(a), abs(b))
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
def _usd(value: float) -> str:
|
|
1760
|
-
rounded = round(value * 1e6) / 1e6
|
|
1761
|
-
return str(int(rounded)) if rounded == int(rounded) else repr(rounded)
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
def _utc_date_ms(value: Any) -> float:
|
|
1765
|
-
match = re.fullmatch(r"([0-9]{4})-([0-9]{2})-([0-9]{2})", value, re.ASCII) if isinstance(value, str) else None
|
|
1766
|
-
if not match:
|
|
1767
|
-
return float("nan")
|
|
1768
|
-
try:
|
|
1769
|
-
day = _dt.date(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
1770
|
-
except ValueError:
|
|
1771
|
-
return float("nan")
|
|
1772
|
-
return float((day - _dt.date(1970, 1, 1)).days * _DAY_MS)
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
def _usage_list(value: Any) -> List[Dict[str, Any]]:
|
|
1776
|
-
return [x for x in value if isinstance(x, dict)] if isinstance(value, list) else []
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
def _priced_row(row: Dict[str, Any], at: str) -> List[Dict[str, str]]:
|
|
1780
|
-
calls, unpriced = _usage_count(row.get("calls")), _usage_count(row.get("unpricedCalls"))
|
|
1781
|
-
flag = lambda path, message: [{"code": "FAC-SEM-025", "instancePath": path, "message": message}] # noqa: E731
|
|
1782
|
-
if unpriced > calls:
|
|
1783
|
-
return flag(at + "/unpricedCalls", "more unpriced calls than calls")
|
|
1784
|
-
if _usage_count(row.get("unmeasuredCalls")) > calls: # DEC-0091: unknown token counts are some of the calls
|
|
1785
|
-
return flag(at + "/unmeasuredCalls", "more calls with unknown tokens than calls")
|
|
1786
|
-
if calls > 0 and unpriced == calls and row.get("costUsd", _MISSING_COST) is not None:
|
|
1787
|
-
return flag(at + "/costUsd", "every call is unpriced, so the cost is unknown (null), not a number")
|
|
1788
|
-
if unpriced < calls and row.get("costUsd", _MISSING_COST) is None:
|
|
1789
|
-
return flag(at + "/costUsd", "priced calls carry a cost; null is only for a row with no priced call")
|
|
1790
|
-
return []
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
_MISSING_COST = object()
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
def usage_findings(report: Any) -> List[Dict[str, str]]:
|
|
1797
|
-
"""FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic of days and
|
|
1798
|
-
models, the window (every date exists, not after `generatedAt`'s UTC date, at most 30 days before it), and the
|
|
1799
|
-
project attribution (each project once by its normal form, each task run once; calls, priced calls and known costs
|
|
1800
|
-
bounded). Malformed input answers findings or none, never an exception."""
|
|
1801
|
-
import fabric_identifiers # noqa: E402
|
|
1802
|
-
import fabric_estate # noqa: E402 - the strict RFC 3339 reader
|
|
1803
|
-
value = report if isinstance(report, dict) else {}
|
|
1804
|
-
findings: List[Dict[str, str]] = []
|
|
1805
|
-
flag = lambda path, message: findings.append({"code": "FAC-SEM-025", "instancePath": path, "message": message}) # noqa: E731
|
|
1806
|
-
days = _usage_list(value.get("days"))
|
|
1807
|
-
# The arithmetic.
|
|
1808
|
-
previous = ""
|
|
1809
|
-
for i, day in enumerate(days):
|
|
1810
|
-
at = "/days/%d" % i
|
|
1811
|
-
date = fabric_estate._show("" if day.get("date") is None else day["date"]) # the contract's show(day.date ?? "")
|
|
1812
|
-
if date <= previous:
|
|
1813
|
-
flag(at + "/date", "days must run forward without a repeat (%s after %s)" % (date, previous))
|
|
1814
|
-
previous = date
|
|
1815
|
-
findings.extend(_priced_row(day, at))
|
|
1816
|
-
models = _usage_list(day.get("byModel"))
|
|
1817
|
-
for j, m in enumerate(models):
|
|
1818
|
-
findings.extend(_priced_row(m, "%s/byModel/%d" % (at, j)))
|
|
1819
|
-
# The contract reads every count as `toNumber(x ?? 0)` and writes it with `show`: a string count is read as a
|
|
1820
|
-
# number, and what is no number (an object, "abc") is NaN, which equals no sum, so it is a finding.
|
|
1821
|
-
number = lambda row, f: fabric_estate._js_to_number(row.get(f)) # noqa: E731 - None and absent read as 0
|
|
1822
|
-
if not models:
|
|
1823
|
-
spent = any(number(day, f) > 0 for f in ("calls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens")) \
|
|
1824
|
-
or number(day, "costUsd") > 0
|
|
1825
|
-
if spent:
|
|
1826
|
-
flag(at + "/byModel", "a day with calls, tokens or cost names the models they belong to")
|
|
1827
|
-
continue
|
|
1828
|
-
for field in ("calls", "unpricedCalls", "unmeasuredCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
1829
|
-
total = 0.0
|
|
1830
|
-
for m in models:
|
|
1831
|
-
total += number(m, field) # in order, in doubles, as JavaScript adds
|
|
1832
|
-
if number(day, field) != total:
|
|
1833
|
-
shown = fabric_estate._show(0 if day.get(field) is None else day[field])
|
|
1834
|
-
flag("%s/%s" % (at, field), "the day's %s is not the sum of its models (%s ≠ %s)" % (field, shown, fabric_estate._js_string(total)))
|
|
1835
|
-
known = 0.0
|
|
1836
|
-
for m in models:
|
|
1837
|
-
money = _usage_money(m.get("costUsd"))
|
|
1838
|
-
known += money if money is not None else 0.0
|
|
1839
|
-
day_cost = _usage_money(day.get("costUsd"))
|
|
1840
|
-
# The contract's !close(day, known): Infinity or NaN is never close, so it is a finding.
|
|
1841
|
-
if day_cost is not None and not abs(day_cost - known) <= 0.000001 + 1e-9 * max(abs(day_cost), abs(known)):
|
|
1842
|
-
flag(at + "/costUsd", "the day's cost is not the sum of its models' known costs")
|
|
1843
|
-
# The window.
|
|
1844
|
-
if days:
|
|
1845
|
-
generated = fabric_estate.estate_time(value.get("generatedAt"))
|
|
1846
|
-
if generated != generated:
|
|
1847
|
-
flag("/generatedAt", "generatedAt is an RFC 3339 date-time with an offset, so the window can be read")
|
|
1848
|
-
else:
|
|
1849
|
-
today = (generated // _DAY_MS) * _DAY_MS
|
|
1850
|
-
oldest = today - (_USAGE_WINDOW_DAYS - 1) * _DAY_MS
|
|
1851
|
-
for i, day in enumerate(days):
|
|
1852
|
-
date = _utc_date_ms(day.get("date"))
|
|
1853
|
-
if date != date:
|
|
1854
|
-
flag("/days/%d/date" % i, "a day's date exists in its month")
|
|
1855
|
-
elif date > today:
|
|
1856
|
-
flag("/days/%d/date" % i, "a day is not after the UTC date of generatedAt")
|
|
1857
|
-
elif date < oldest:
|
|
1858
|
-
flag("/days/%d/date" % i, "a day is within the last %d UTC days of generatedAt" % _USAGE_WINDOW_DAYS)
|
|
1859
|
-
# The projects.
|
|
1860
|
-
if isinstance(value.get("byProject"), list):
|
|
1861
|
-
seen: Dict[str, str] = {}
|
|
1862
|
-
project_calls, project_cost = 0, 0.0
|
|
1863
|
-
for p, project in enumerate(value["byProject"]):
|
|
1864
|
-
if not isinstance(project, dict):
|
|
1865
|
-
continue
|
|
1866
|
-
at = "/byProject/%d" % p
|
|
1867
|
-
normal = fabric_identifiers.normal_identifier(project.get("projectId"))
|
|
1868
|
-
if normal is not None:
|
|
1869
|
-
if normal in seen:
|
|
1870
|
-
flag(at + "/projectId", "each project is one row: %s names the project %s already names" % (project["projectId"], seen[normal]))
|
|
1871
|
-
else:
|
|
1872
|
-
seen[normal] = project["projectId"]
|
|
1873
|
-
project_calls += _usage_count(project.get("calls"))
|
|
1874
|
-
project_cost += _usage_cost(project.get("costUsd"))
|
|
1875
|
-
findings.extend(_priced_row(project, at))
|
|
1876
|
-
runs: List[str] = []
|
|
1877
|
-
run_calls, run_cost, run_priced = 0, 0.0, 0
|
|
1878
|
-
for r, run in enumerate(_usage_list(project.get("taskRuns"))):
|
|
1879
|
-
if isinstance(run.get("taskRunId"), str):
|
|
1880
|
-
if run["taskRunId"] in runs:
|
|
1881
|
-
flag("%s/taskRuns/%d/taskRunId" % (at, r), "each task run is one row within its project")
|
|
1882
|
-
runs.append(run["taskRunId"])
|
|
1883
|
-
run_calls += _usage_count(run.get("calls"))
|
|
1884
|
-
run_cost += _usage_cost(run.get("costUsd"))
|
|
1885
|
-
run_priced += max(0, _usage_count(run.get("calls")) - _usage_count(run.get("unpricedCalls")))
|
|
1886
|
-
findings.extend(_priced_row(run, "%s/taskRuns/%d" % (at, r)))
|
|
1887
|
-
project_priced = max(0, _usage_count(project.get("calls")) - _usage_count(project.get("unpricedCalls")))
|
|
1888
|
-
known_cost = _usage_cost(project.get("costUsd")) == _usage_money(project.get("costUsd")) # as JavaScript holds it
|
|
1889
|
-
if run_calls > _usage_count(project.get("calls")):
|
|
1890
|
-
flag(at + "/taskRuns", "a project's task runs hold more calls than the project (%d > %d)" % (run_calls, _usage_count(project.get("calls"))))
|
|
1891
|
-
elif run_priced > project_priced:
|
|
1892
|
-
flag(at + "/taskRuns", "a project's task runs hold more priced calls than the project (%d > %d)" % (run_priced, project_priced))
|
|
1893
|
-
elif known_cost and _exceeds(run_cost, _usage_cost(project.get("costUsd"))):
|
|
1894
|
-
flag(at + "/taskRuns", "a project's task runs cost more than the project (%s > %s)" % (_usd(run_cost), _usd(_usage_cost(project.get("costUsd")))))
|
|
1895
|
-
day_calls = sum(_usage_count(d.get("calls")) for d in days)
|
|
1896
|
-
if project_calls > day_calls:
|
|
1897
|
-
flag("/byProject", "the projects hold more calls than the days (%d > %d)" % (project_calls, day_calls))
|
|
1898
|
-
day_cost_total = sum(_usage_cost(d.get("costUsd")) for d in days)
|
|
1899
|
-
if _exceeds(project_cost, day_cost_total):
|
|
1900
|
-
flag("/byProject", "the projects cost more than the days (%s > %s)" % (_usd(project_cost), _usd(day_cost_total)))
|
|
1901
|
-
return findings
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
# --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default -----------------------------
|
|
1905
|
-
|
|
1906
|
-
OPENROUTER_CATEGORIES = ("cli-agent", "ide-extension", "cloud-agent", "programming-app", "native-app-builder",
|
|
1907
|
-
"creative-writing", "video-gen", "image-gen", "audio-gen", "writing-assistant", "general-chat",
|
|
1908
|
-
"personal-agent", "legal", "roleplay", "game")
|
|
1909
|
-
_ATTR_PRODUCT = re.compile(r"[a-z][a-z0-9-]{0,62}", re.ASCII)
|
|
1910
|
-
_ATTR_TITLE = re.compile(r"[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?", re.ASCII)
|
|
1911
|
-
_ATTRIBUTION_HEADERS = ("http-referer", "referer", "x-openrouter-title", "x-title", "x-openrouter-categories")
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
def attribution_setting_findings(value: Any) -> List[Dict[str, str]]:
|
|
1915
|
-
"""FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories."""
|
|
1916
|
-
if not isinstance(value, dict):
|
|
1917
|
-
return [{"code": "FAC-SEM-098", "instancePath": "", "message": "an attribution setting is an object"}]
|
|
1918
|
-
findings: List[Dict[str, str]] = []
|
|
1919
|
-
flag = lambda at, message: findings.append({"code": "FAC-SEM-098", "instancePath": at, "message": message}) # noqa: E731
|
|
1920
|
-
if not (isinstance(value.get("product"), str) and _ATTR_PRODUCT.fullmatch(value["product"])):
|
|
1921
|
-
flag("/product", "the product is a lower-case slug, the last segment of its product page")
|
|
1922
|
-
if not (isinstance(value.get("title"), str) and _ATTR_TITLE.fullmatch(value["title"])):
|
|
1923
|
-
flag("/title", "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end")
|
|
1924
|
-
if "categories" in value:
|
|
1925
|
-
categories = value["categories"]
|
|
1926
|
-
if not isinstance(categories, list):
|
|
1927
|
-
flag("/categories", "categories are a list")
|
|
1928
|
-
else:
|
|
1929
|
-
if len(categories) > 2:
|
|
1930
|
-
flag("/categories", "at most 2 categories per request")
|
|
1931
|
-
seen: List[str] = []
|
|
1932
|
-
for i, category in enumerate(categories):
|
|
1933
|
-
if not (isinstance(category, str) and category in OPENROUTER_CATEGORIES):
|
|
1934
|
-
flag("/categories/%d" % i, "a category is one OpenRouter recognizes; it drops others without an error")
|
|
1935
|
-
elif category in seen:
|
|
1936
|
-
flag("/categories/%d" % i, "each category once")
|
|
1937
|
-
else:
|
|
1938
|
-
seen.append(category)
|
|
1939
|
-
return findings
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
def _is_openrouter(url: Any) -> bool:
|
|
1943
|
-
"""Whether a URL's WHATWG reading is https, host openrouter.ai, the default port and no credentials (`new URL`):
|
|
1944
|
-
leading and trailing spaces and controls trimmed, tabs and newlines removed, backslashes read as slashes, the
|
|
1945
|
-
slashes after the scheme optional, the host case-folded, port 443 the default."""
|
|
1946
|
-
if not isinstance(url, str):
|
|
1947
|
-
return False
|
|
1948
|
-
text = url.strip("".join(chr(c) for c in range(0x21))).replace("\t", "").replace("\n", "").replace("\r", "")
|
|
1949
|
-
if not text[:6].lower() == "https:":
|
|
1950
|
-
return False
|
|
1951
|
-
rest = text[6:].lstrip("/\\")
|
|
1952
|
-
end = len(rest)
|
|
1953
|
-
for stop in "/\\?#":
|
|
1954
|
-
found = rest.find(stop)
|
|
1955
|
-
if found != -1:
|
|
1956
|
-
end = min(end, found)
|
|
1957
|
-
authority = rest[:end]
|
|
1958
|
-
if "@" in authority:
|
|
1959
|
-
userinfo, authority = authority.rsplit("@", 1)
|
|
1960
|
-
if userinfo not in ("", ":"):
|
|
1961
|
-
return False
|
|
1962
|
-
host, _, port = authority.partition(":")
|
|
1963
|
-
if port and not (port.isdigit() and int(port) == 443):
|
|
1964
|
-
return False
|
|
1965
|
-
return host.lower() == "openrouter.ai"
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
def attribution_headers(setting: Any, request: Any) -> Dict[str, Any]:
|
|
1969
|
-
"""The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
|
|
1970
|
-
`no-setting` (attribution is off by default), `invalid-setting` (FAC-SEM-098), `not-openrouter` (only
|
|
1971
|
-
`https://openrouter.ai`), `forwarded` (only traffic the product originates), `user-set` (a request that already
|
|
1972
|
-
carries an attribution header keeps the user's own). Only `HTTP-Referer`, `X-OpenRouter-Title` and
|
|
1973
|
-
`X-OpenRouter-Categories`; it never reads, moves or returns a key."""
|
|
1974
|
-
req = request if isinstance(request, dict) else {}
|
|
1975
|
-
if setting is None:
|
|
1976
|
-
return {"headers": {}, "none": "no-setting"}
|
|
1977
|
-
if attribution_setting_findings(setting):
|
|
1978
|
-
return {"headers": {}, "none": "invalid-setting"}
|
|
1979
|
-
if not _is_openrouter(req.get("url")):
|
|
1980
|
-
return {"headers": {}, "none": "not-openrouter"}
|
|
1981
|
-
if req.get("originated") is not True:
|
|
1982
|
-
return {"headers": {}, "none": "forwarded"}
|
|
1983
|
-
present = [str(name).lower() for name in req["headers"]] if isinstance(req.get("headers"), dict) else []
|
|
1984
|
-
if any(name in _ATTRIBUTION_HEADERS for name in present):
|
|
1985
|
-
return {"headers": {}, "none": "user-set"}
|
|
1986
|
-
headers = {"HTTP-Referer": "https://passioncode.ai/%s/" % setting["product"], "X-OpenRouter-Title": setting["title"]}
|
|
1987
|
-
if setting.get("categories"):
|
|
1988
|
-
headers["X-OpenRouter-Categories"] = ",".join(setting["categories"])
|
|
1989
|
-
return {"headers": headers}
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
|
|
1993
|
-
now: Optional[_dt.datetime] = None,
|
|
1994
|
-
budget: Optional[Dict[str, Any]] = None,
|
|
1995
|
-
budgets: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
1996
|
-
"""The `service-usage.schema.json` answer: the last 31 UTC days, oldest first, per provider
|
|
1997
|
-
and model. Days without calls are omitted. A row whose calls are all unpriced has
|
|
1998
|
-
`costUsd: None`; totals are the sums of the model rows (FAC-SEM-025).
|
|
1999
|
-
|
|
2000
|
-
``budgets`` (DEC-0027) lists every spending limit the service applies, enforced or not. A
|
|
2001
|
-
machine limit with a calendar ``period`` and no ``spentUsd`` is counted here from the receipts,
|
|
2002
|
-
the way ``budget`` is, so the two never disagree; a per-order limit (``per_job``, ``approval``)
|
|
2003
|
-
gets ``spentUsd: None``; every other limit states its own spend. A report whose limits break
|
|
2004
|
-
FAC-SEM-031 is refused (``usage_limit_problems``)."""
|
|
2005
|
-
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
2006
|
-
receipts = list(receipts)
|
|
2007
|
-
first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
|
|
2008
|
-
last = now.strftime("%Y-%m-%d")
|
|
2009
|
-
days: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
2010
|
-
bases: Dict[Tuple[str, str, str], set] = {}
|
|
2011
|
-
for r in receipts:
|
|
2012
|
-
date = str(r.get("at", ""))[:10]
|
|
2013
|
-
if not (first <= date <= last):
|
|
2014
|
-
continue
|
|
2015
|
-
key = (r["provider"], r["model"], r.get("route") or "")
|
|
2016
|
-
row = days.setdefault(date, {}).setdefault("%s\u0000%s\u0000%s" % key, {
|
|
2017
|
-
"provider": r["provider"], "model": r["model"], "calls": 0, "unpricedCalls": 0,
|
|
2018
|
-
"inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0})
|
|
2019
|
-
if r.get("route"):
|
|
2020
|
-
row["route"] = r["route"] # DEC-0046: rows of one model on two routes stay apart
|
|
2021
|
-
_add_totals(row, r)
|
|
2022
|
-
bases.setdefault((date,) + key, set()).add(r.get("costBasis") or "unknown")
|
|
2023
|
-
out_days = []
|
|
2024
|
-
for date in sorted(days):
|
|
2025
|
-
models = []
|
|
2026
|
-
for row in days[date].values():
|
|
2027
|
-
seen = bases[(date, row["provider"], row["model"], row.get("route") or "")]
|
|
2028
|
-
row["costBasis"] = next(iter(seen)) if len(seen) == 1 else "mixed"
|
|
2029
|
-
models.append(_close_totals(row))
|
|
2030
|
-
models.sort(key=lambda m: (m["provider"], m["model"], m.get("route") or ""))
|
|
2031
|
-
day = {"date": date, "calls": 0, "unpricedCalls": 0, "inputTokens": 0, "outputTokens": 0,
|
|
2032
|
-
"cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0}
|
|
2033
|
-
for m in models:
|
|
2034
|
-
for name in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
2035
|
-
day[name] += m[name]
|
|
2036
|
-
if m.get("unmeasuredCalls"):
|
|
2037
|
-
day["unmeasuredCalls"] = day.get("unmeasuredCalls", 0) + m["unmeasuredCalls"]
|
|
2038
|
-
day["_cost"] += m["costUsd"] or 0.0
|
|
2039
|
-
day = _close_totals(day)
|
|
2040
|
-
day["byModel"] = models
|
|
2041
|
-
out_days.append(day)
|
|
2042
|
-
report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
|
|
2043
|
-
"generatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "currency": "USD", "days": out_days}
|
|
2044
|
-
projects = _by_project(receipts, first, last)
|
|
2045
|
-
if projects:
|
|
2046
|
-
report["byProject"] = projects
|
|
2047
|
-
if budget is not None:
|
|
2048
|
-
if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
|
|
2049
|
-
raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
|
|
2050
|
-
report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]),
|
|
2051
|
-
"spentUsd": _spent_in(out_days, budget["period"], last)}
|
|
2052
|
-
if budgets is not None:
|
|
2053
|
-
limits = []
|
|
2054
|
-
for given in budgets:
|
|
2055
|
-
if not isinstance(given, dict):
|
|
2056
|
-
raise ServiceError("each limit in budgets is an object")
|
|
2057
|
-
limit = dict(given)
|
|
2058
|
-
if "spentUsd" not in limit:
|
|
2059
|
-
if limit.get("kind") in PER_ORDER_KINDS:
|
|
2060
|
-
limit["spentUsd"] = None
|
|
2061
|
-
elif limit.get("scope") == "machine" and limit.get("period") in ("day", "month"):
|
|
2062
|
-
limit["spentUsd"] = _spent_in(out_days, limit["period"], last)
|
|
2063
|
-
elif limit.get("scope") == "machine" and _number(limit.get("windowSeconds")):
|
|
2064
|
-
limit["spentUsd"] = _spent_since(receipts, now - _dt.timedelta(seconds=limit["windowSeconds"]), now)
|
|
2065
|
-
if isinstance(limit.get("limitUsd"), int) and not isinstance(limit.get("limitUsd"), bool):
|
|
2066
|
-
limit["limitUsd"] = float(limit["limitUsd"])
|
|
2067
|
-
limits.append(limit)
|
|
2068
|
-
report["budgets"] = limits
|
|
2069
|
-
problems = usage_limit_problems(report)
|
|
2070
|
-
if problems:
|
|
2071
|
-
raise ServiceError("the report's limits are not coherent: " + "; ".join(problems))
|
|
2072
|
-
return report
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
def _spent_since(receipts: List[Dict[str, Any]], start: _dt.datetime, end: _dt.datetime) -> Optional[float]:
|
|
2076
|
-
"""Spend in a rolling window, from the receipts' own times; None when every call in it is unpriced."""
|
|
2077
|
-
lo, hi = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
2078
|
-
inside = [r for r in receipts if lo < str(r.get("at", "")) <= hi]
|
|
2079
|
-
if inside and all(r.get("costUsd") is None for r in inside):
|
|
2080
|
-
return None
|
|
2081
|
-
return round(sum(r.get("costUsd") or 0.0 for r in inside), 6)
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
def _spent_in(out_days: List[Dict[str, Any]], period: str, last: str) -> Optional[float]:
|
|
2085
|
-
"""Spend in the current UTC day or month; None when every call in it is unpriced."""
|
|
2086
|
-
prefix = last if period == "day" else last[:7]
|
|
2087
|
-
window = [d for d in out_days if d["date"].startswith(prefix)]
|
|
2088
|
-
unknown = bool(window) and all(d["costUsd"] is None for d in window)
|
|
2089
|
-
return None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
# --- every spending limit (DEC-0027, FAC-SEM-031) ---------------------------------
|
|
2093
|
-
|
|
2094
|
-
LIMIT_SCOPES = ("machine", "project", "pool", "job")
|
|
2095
|
-
PER_ORDER_KINDS = ("per_job", "approval")
|
|
2096
|
-
_LIMIT_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,63}$")
|
|
2097
|
-
_LIMIT_KIND = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
2098
|
-
_LIMIT_FIELDS = ("id", "scope", "subject", "kind", "period", "windowSeconds", "since", "limitUsd", "spentUsd", "enforced", "tripped")
|
|
2099
|
-
_TIMESTAMP = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))")
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
def _is_timestamp(value: Any) -> bool:
|
|
2103
|
-
"""A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset."""
|
|
2104
|
-
m = _TIMESTAMP.fullmatch(value) if isinstance(value, str) else None
|
|
2105
|
-
if not m:
|
|
2106
|
-
return False
|
|
2107
|
-
year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
|
|
2108
|
-
try:
|
|
2109
|
-
_dt.date(year, month, day)
|
|
2110
|
-
except ValueError:
|
|
2111
|
-
return False
|
|
2112
|
-
offset_ok = m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
|
|
2113
|
-
return hour <= 23 and minute <= 59 and second <= 60 and offset_ok
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
def _number(value: Any) -> bool:
|
|
2117
|
-
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
def usage_limit_problems(report: Dict[str, Any]) -> List[str]:
|
|
2121
|
-
"""The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031: unique
|
|
2122
|
-
ids, a subject exactly for project and pool scope, one window that agrees with the kind, per-order limits with
|
|
2123
|
-
no window and no spend, only a relative limit with a null `limitUsd`, a tripped limit enforced, and a legacy
|
|
2124
|
-
`budget` that repeats one enforced machine limit. A breach (spent above the limit) is not a problem: a report
|
|
2125
|
-
truthfully shows one, and a host shows it first. An unknown kind is never refused."""
|
|
2126
|
-
problems: List[str] = []
|
|
2127
|
-
limits = report.get("budgets")
|
|
2128
|
-
if limits is None:
|
|
2129
|
-
return problems
|
|
2130
|
-
if not isinstance(limits, list) or len(limits) > 64:
|
|
2131
|
-
return ["budgets is a list of at most 64 limits"]
|
|
2132
|
-
seen = set()
|
|
2133
|
-
for i, limit in enumerate(limits):
|
|
2134
|
-
at = "/budgets/%d" % i
|
|
2135
|
-
if not isinstance(limit, dict):
|
|
2136
|
-
problems.append("%s is not an object" % at)
|
|
2137
|
-
continue
|
|
2138
|
-
for key in limit:
|
|
2139
|
-
if key not in _LIMIT_FIELDS:
|
|
2140
|
-
problems.append("%s/%s is not a limit field" % (at, key))
|
|
2141
|
-
for key in ("id", "scope", "kind", "limitUsd", "spentUsd", "enforced"):
|
|
2142
|
-
if key not in limit:
|
|
2143
|
-
problems.append("%s is missing %s" % (at, key))
|
|
2144
|
-
if "id" in limit and not (isinstance(limit["id"], str) and _LIMIT_ID.fullmatch(limit["id"])):
|
|
2145
|
-
problems.append("%s/id must match %s" % (at, _LIMIT_ID.pattern))
|
|
2146
|
-
if "scope" in limit and limit["scope"] not in LIMIT_SCOPES:
|
|
2147
|
-
problems.append("%s/scope must be one of %s" % (at, ", ".join(LIMIT_SCOPES)))
|
|
2148
|
-
if "subject" in limit and not (isinstance(limit["subject"], str) and 1 <= len(limit["subject"]) <= 128):
|
|
2149
|
-
problems.append("%s/subject is 1 to 128 characters" % at)
|
|
2150
|
-
if "kind" in limit and not (isinstance(limit["kind"], str) and _LIMIT_KIND.fullmatch(limit["kind"])):
|
|
2151
|
-
problems.append("%s/kind must match %s" % (at, _LIMIT_KIND.pattern))
|
|
2152
|
-
if "period" in limit and limit["period"] not in ("day", "month"):
|
|
2153
|
-
problems.append("%s/period is day or month" % at)
|
|
2154
|
-
if "windowSeconds" in limit and not (isinstance(limit["windowSeconds"], int) and not isinstance(limit["windowSeconds"], bool)
|
|
2155
|
-
and limit["windowSeconds"] >= 1):
|
|
2156
|
-
problems.append("%s/windowSeconds is a whole number of seconds, at least 1" % at)
|
|
2157
|
-
if "since" in limit and not _is_timestamp(limit["since"]):
|
|
2158
|
-
problems.append("%s/since is a UTC timestamp" % at)
|
|
2159
|
-
if "limitUsd" in limit and not (limit["limitUsd"] is None or (_number(limit["limitUsd"]) and limit["limitUsd"] > 0)):
|
|
2160
|
-
problems.append("%s/limitUsd is a dollar amount above zero, or null" % at)
|
|
2161
|
-
if "spentUsd" in limit and not (limit["spentUsd"] is None or (_number(limit["spentUsd"]) and limit["spentUsd"] >= 0)):
|
|
2162
|
-
problems.append("%s/spentUsd is a dollar amount, zero or more, or null" % at)
|
|
2163
|
-
for key in ("enforced", "tripped"):
|
|
2164
|
-
if key in limit and not isinstance(limit[key], bool):
|
|
2165
|
-
problems.append("%s/%s is true or false" % (at, key))
|
|
2166
|
-
|
|
2167
|
-
# FAC-SEM-031
|
|
2168
|
-
ident = str(limit.get("id", ""))
|
|
2169
|
-
if ident in seen:
|
|
2170
|
-
problems.append("FAC-SEM-031 %s/id: limit id %s appears twice" % (at, ident))
|
|
2171
|
-
seen.add(ident)
|
|
2172
|
-
scope = limit.get("scope")
|
|
2173
|
-
named = scope in ("project", "pool")
|
|
2174
|
-
if named and "subject" not in limit:
|
|
2175
|
-
problems.append("FAC-SEM-031 %s/subject: a %s limit names its %s in subject" % (at, scope, scope))
|
|
2176
|
-
if not named and "subject" in limit:
|
|
2177
|
-
problems.append("FAC-SEM-031 %s/subject: a %s limit has no subject" % (at, scope))
|
|
2178
|
-
windows = [f for f in ("period", "windowSeconds", "since") if f in limit]
|
|
2179
|
-
if len(windows) > 1:
|
|
2180
|
-
problems.append("FAC-SEM-031 %s/%s: a limit has one window, not %s" % (at, windows[1], " and ".join(windows)))
|
|
2181
|
-
kind = str(limit.get("kind", ""))
|
|
2182
|
-
seconds = limit.get("windowSeconds") if _number(limit.get("windowSeconds")) else None
|
|
2183
|
-
if kind in PER_ORDER_KINDS:
|
|
2184
|
-
if windows:
|
|
2185
|
-
problems.append("FAC-SEM-031 %s/%s: %s bounds one order and has no window" % (at, windows[0], kind))
|
|
2186
|
-
if limit.get("spentUsd", 0) is not None:
|
|
2187
|
-
problems.append("FAC-SEM-031 %s/spentUsd: %s bounds one order, so nothing is spent against it (null)" % (at, kind))
|
|
2188
|
-
elif kind == "daily":
|
|
2189
|
-
if not (limit.get("period") == "day" or seconds == 86400):
|
|
2190
|
-
problems.append("FAC-SEM-031 %s/period: a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)" % at)
|
|
2191
|
-
elif kind == "monthly":
|
|
2192
|
-
if not (limit.get("period") == "month" or (seconds is not None and 28 * 86400 <= seconds <= 31 * 86400)):
|
|
2193
|
-
problems.append("FAC-SEM-031 %s/period: a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)" % at)
|
|
2194
|
-
elif kind == "velocity":
|
|
2195
|
-
if seconds is None:
|
|
2196
|
-
problems.append("FAC-SEM-031 %s/windowSeconds: a velocity limit counts a rolling window (windowSeconds)" % at)
|
|
2197
|
-
elif kind in ("pool", "emergency"):
|
|
2198
|
-
if not windows:
|
|
2199
|
-
problems.append("FAC-SEM-031 %s/period: a %s limit names the window it counts (period, windowSeconds or since)" % (at, kind))
|
|
2200
|
-
known = kind in PER_ORDER_KINDS or kind in ("daily", "monthly", "pool", "emergency")
|
|
2201
|
-
if known and "limitUsd" in limit and limit["limitUsd"] is None:
|
|
2202
|
-
problems.append("FAC-SEM-031 %s/limitUsd: a %s limit is a dollar amount; only a relative limit (velocity) may leave it null" % (at, kind))
|
|
2203
|
-
if limit.get("tripped") is True and limit.get("enforced") is not True:
|
|
2204
|
-
problems.append("FAC-SEM-031 %s/tripped: a limit that is not enforced cannot have stopped work" % at)
|
|
2205
|
-
|
|
2206
|
-
budget = report.get("budget")
|
|
2207
|
-
if isinstance(budget, dict) and limits:
|
|
2208
|
-
kind = "daily" if budget.get("period") == "day" else "monthly"
|
|
2209
|
-
|
|
2210
|
-
def same(x: Any, y: Any) -> bool:
|
|
2211
|
-
if x is None or y is None:
|
|
2212
|
-
return x is y
|
|
2213
|
-
return _number(x) and _number(y) and abs(x - y) <= 0.000001
|
|
2214
|
-
|
|
2215
|
-
twin = any(isinstance(l, dict) and l.get("scope") == "machine" and l.get("enforced") is True and l.get("kind") == kind
|
|
2216
|
-
and l.get("period") == budget.get("period") and same(l.get("limitUsd"), budget.get("limitUsd"))
|
|
2217
|
-
and same(l.get("spentUsd"), budget.get("spentUsd")) for l in limits)
|
|
2218
|
-
if not twin:
|
|
2219
|
-
problems.append("FAC-SEM-031 /budget: budget repeats one enforced machine %s limit with period %s in budgets[], so old "
|
|
2220
|
-
"and new readers agree" % (kind, budget.get("period")))
|
|
2221
|
-
return problems
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
# --- the feed client (DEC-0025, FAC-SEM-036) --------------------------------------
|
|
2225
|
-
|
|
2226
|
-
def token_header(auth: Any, token: str) -> Tuple[str, str]:
|
|
2227
|
-
"""The one header a client sends the service token in, as the descriptor's ``auth`` declares it: ``header``
|
|
2228
|
-
defaults to Authorization and ``scheme`` to Bearer; ``none`` carries the raw token. Never assume
|
|
2229
|
-
``Authorization: Bearer`` (a feed client that did was refused on every poll and dropped every notify event)."""
|
|
2230
|
-
declared = auth if isinstance(auth, dict) else {}
|
|
2231
|
-
name = declared.get("header") if isinstance(declared.get("header"), str) else "Authorization"
|
|
2232
|
-
scheme = declared.get("scheme") if isinstance(declared.get("scheme"), str) else "Bearer"
|
|
2233
|
-
return name, token if scheme == "none" else "Bearer " + token
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
# --- settings backups (DEC-0025, FAC-SEM-035) -------------------------------------
|
|
2237
|
-
|
|
2238
|
-
SETTINGS_BACKUP_FORMAT = "fabric-settings-backup/1"
|
|
2239
|
-
SETTINGS_BACKUP_KEEP = 14
|
|
2240
|
-
SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
2241
|
-
_STALE_TEMP_SECONDS = 60 * 60
|
|
2242
|
-
_SAFE_INTEGER = 2 ** 53 - 1
|
|
2243
|
-
_TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
2244
|
-
_SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$")
|
|
2245
|
-
_COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
|
|
2246
|
-
_SECRET_COLUMN = re.compile(r"^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$")
|
|
2247
|
-
_REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
2248
|
-
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
2249
|
-
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
2250
|
-
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S*")
|
|
2251
|
-
_MAX_CELL_CHARS = 1048576
|
|
2252
|
-
_MAX_ROW_VALUES = 512
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
def _canonical_check(value: Any, at: str = "") -> None:
|
|
2256
|
-
if value is None or isinstance(value, bool):
|
|
2257
|
-
return
|
|
2258
|
-
if isinstance(value, str):
|
|
2259
|
-
# CT-25 (DEC-0052): Python cannot encode a lone surrogate as UTF-8, so no two languages agree on its bytes.
|
|
2260
|
-
if _SURROGATE.search(value):
|
|
2261
|
-
raise ServiceError("%s: a lone surrogate has no canonical form" % (at or "/"))
|
|
2262
|
-
return
|
|
2263
|
-
if isinstance(value, int):
|
|
2264
|
-
if abs(value) > _SAFE_INTEGER:
|
|
2265
|
-
raise ServiceError("%s: %d is outside ±(2^53 − 1) and has no canonical form; store it as a string" % (at or "/", value))
|
|
2266
|
-
return
|
|
2267
|
-
if isinstance(value, float):
|
|
2268
|
-
raise ServiceError("%s: a fraction has no canonical form; a settings backup stores it as its decimal string" % (at or "/"))
|
|
2269
|
-
if isinstance(value, list):
|
|
2270
|
-
for i, item in enumerate(value):
|
|
2271
|
-
_canonical_check(item, "%s/%d" % (at, i))
|
|
2272
|
-
return
|
|
2273
|
-
if isinstance(value, dict):
|
|
2274
|
-
for key, item in value.items():
|
|
2275
|
-
if not isinstance(key, str):
|
|
2276
|
-
raise ServiceError("%s: object keys are strings" % (at or "/"))
|
|
2277
|
-
_canonical_check(key, "%s (a key)" % (at or "/"))
|
|
2278
|
-
_canonical_check(item, "%s/%s" % (at, key))
|
|
2279
|
-
return
|
|
2280
|
-
raise ServiceError("%s: %s is not a JSON value" % (at or "/", type(value).__name__))
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
def settings_canonical_json(value: Any) -> str:
|
|
2284
|
-
"""The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, ", " and ": ",
|
|
2285
|
-
strings escaped as JSON requires and nothing else, integers in decimal — Python's
|
|
2286
|
-
``json.dumps(value, sort_keys=True, ensure_ascii=False)``. Refuses a fraction, an integer outside ±(2^53 − 1)
|
|
2287
|
-
and anything that is not JSON."""
|
|
2288
|
-
_canonical_check(value)
|
|
2289
|
-
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
def settings_backup_digest(tables: Any) -> str:
|
|
2293
|
-
"""The ``sha256`` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of ``tables``."""
|
|
2294
|
-
return hashlib.sha256(settings_canonical_json(tables).encode("utf-8")).hexdigest()
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
def settings_backup_problems(doc: Any) -> List[str]:
|
|
2298
|
-
"""Why a reader refuses this settings backup: its shape (``settings-backup.schema.json``) and FAC-SEM-035 —
|
|
2299
|
-
the checksum is the digest of the tables, counts name exactly the tables and their rows, every row is as wide as
|
|
2300
|
-
its columns, and every value has a canonical form. Empty means intact."""
|
|
2301
|
-
if not isinstance(doc, dict):
|
|
2302
|
-
return ["a settings backup is a JSON object"]
|
|
2303
|
-
problems: List[str] = []
|
|
2304
|
-
if doc.get("format") != SETTINGS_BACKUP_FORMAT:
|
|
2305
|
-
problems.append("format must be %s, not %r: a reader refuses a format it does not know" % (SETTINGS_BACKUP_FORMAT, doc.get("format")))
|
|
2306
|
-
for key in ("format", "service", "created_at", "reason", "sha256", "counts", "tables"):
|
|
2307
|
-
if key not in doc:
|
|
2308
|
-
problems.append("missing %s" % key)
|
|
2309
|
-
for key in doc:
|
|
2310
|
-
if key not in ("format", "service", "created_at", "reason", "sha256", "counts", "tables", "extensions"):
|
|
2311
|
-
problems.append("%s is not a settings backup field" % key)
|
|
2312
|
-
if "extensions" in doc:
|
|
2313
|
-
if not isinstance(doc["extensions"], dict):
|
|
2314
|
-
problems.append("extensions is an object")
|
|
2315
|
-
else:
|
|
2316
|
-
for key in doc["extensions"]:
|
|
2317
|
-
if not _URI.fullmatch(key):
|
|
2318
|
-
problems.append("extensions key %r is not a URI" % key)
|
|
2319
|
-
service = doc.get("service")
|
|
2320
|
-
if "service" in doc:
|
|
2321
|
-
if not isinstance(service, dict) or set(service) != {"id", "instance", "version", "schema"}:
|
|
2322
|
-
problems.append("service is {id, instance, version, schema}")
|
|
2323
|
-
else:
|
|
2324
|
-
if not _ID.fullmatch(str(service["id"])):
|
|
2325
|
-
problems.append("service.id must match %s" % _ID.pattern)
|
|
2326
|
-
if not _INSTANCE.fullmatch(str(service["instance"])):
|
|
2327
|
-
problems.append("service.instance must match %s" % _INSTANCE.pattern)
|
|
2328
|
-
if not (isinstance(service["version"], str) and len(service["version"]) <= 64):
|
|
2329
|
-
problems.append("service.version is a string of at most 64 characters")
|
|
2330
|
-
schema = service["schema"]
|
|
2331
|
-
if not ((isinstance(schema, int) and not isinstance(schema, bool) and schema >= 0)
|
|
2332
|
-
or (isinstance(schema, str) and 1 <= len(schema) <= 64)):
|
|
2333
|
-
problems.append("service.schema is a whole number or a short string")
|
|
2334
|
-
if "created_at" in doc and not _is_timestamp(doc["created_at"]):
|
|
2335
|
-
problems.append("created_at is a UTC timestamp")
|
|
2336
|
-
if "reason" in doc and not (isinstance(doc["reason"], str) and _REASON.fullmatch(doc["reason"])):
|
|
2337
|
-
problems.append("reason must match %s" % _REASON.pattern)
|
|
2338
|
-
if "sha256" in doc and not (isinstance(doc["sha256"], str) and _SHA256.fullmatch(doc["sha256"])):
|
|
2339
|
-
problems.append("sha256 is 64 lowercase hex digits")
|
|
2340
|
-
tables = doc.get("tables") if isinstance(doc.get("tables"), dict) else {}
|
|
2341
|
-
counts = doc.get("counts") if isinstance(doc.get("counts"), dict) else {}
|
|
2342
|
-
if "tables" in doc and not isinstance(doc.get("tables"), dict):
|
|
2343
|
-
problems.append("tables is an object")
|
|
2344
|
-
if "counts" in doc and not isinstance(doc.get("counts"), dict):
|
|
2345
|
-
problems.append("counts is an object")
|
|
2346
|
-
for name in list(tables) + [n for n in counts if n not in tables]:
|
|
2347
|
-
if not _TABLE.fullmatch(name) or _SECRET_TABLE.fullmatch(name):
|
|
2348
|
-
problems.append("table %s is never backed up: it can only hold connections, tokens, secrets, credentials, "
|
|
2349
|
-
"passwords, login codes or sessions (or its name is not a table name)" % name)
|
|
2350
|
-
for name, count in counts.items():
|
|
2351
|
-
if not (isinstance(count, int) and not isinstance(count, bool) and count >= 0):
|
|
2352
|
-
problems.append("counts.%s is a whole number" % name)
|
|
2353
|
-
canonical = True
|
|
2354
|
-
for name, table in tables.items():
|
|
2355
|
-
if not isinstance(table, dict) or set(table) != {"columns", "rows"}:
|
|
2356
|
-
problems.append("tables.%s is {columns, rows}" % name)
|
|
2357
|
-
continue
|
|
2358
|
-
columns, rows = table["columns"], table["rows"]
|
|
2359
|
-
if not (isinstance(columns, list) and 1 <= len(columns) <= 512 and len(set(map(str, columns))) == len(columns)):
|
|
2360
|
-
problems.append("tables.%s.columns is 1 to 512 distinct names" % name)
|
|
2361
|
-
columns = columns if isinstance(columns, list) else []
|
|
2362
|
-
for column in columns:
|
|
2363
|
-
if not (isinstance(column, str) and _COLUMN.fullmatch(column)) or _SECRET_COLUMN.fullmatch(str(column)):
|
|
2364
|
-
problems.append("column %s.%s is refused: its name says it holds a credential value (name the secret, "
|
|
2365
|
-
"never store it: secret_name, token_slot)" % (name, column))
|
|
2366
|
-
if not isinstance(rows, list):
|
|
2367
|
-
problems.append("tables.%s.rows is a list" % name)
|
|
2368
|
-
continue
|
|
2369
|
-
width = len(columns)
|
|
2370
|
-
for i, row in enumerate(rows):
|
|
2371
|
-
at = "/tables/%s/rows/%d" % (name, i)
|
|
2372
|
-
if not isinstance(row, list):
|
|
2373
|
-
problems.append("%s is a list of values" % at)
|
|
2374
|
-
continue
|
|
2375
|
-
if len(row) != width:
|
|
2376
|
-
problems.append("FAC-SEM-035 %s: row has %d values for %d columns" % (at, len(row), width))
|
|
2377
|
-
if len(row) > _MAX_ROW_VALUES:
|
|
2378
|
-
problems.append("%s holds more than %d values" % (at, _MAX_ROW_VALUES))
|
|
2379
|
-
for j, cell in enumerate(row):
|
|
2380
|
-
if isinstance(cell, float) or (isinstance(cell, int) and not isinstance(cell, bool) and abs(cell) > _SAFE_INTEGER):
|
|
2381
|
-
canonical = False
|
|
2382
|
-
problems.append("FAC-SEM-035 %s/%d: a fraction or an unsafe integer has no canonical form; store it as a string" % (at, j))
|
|
2383
|
-
elif isinstance(cell, str) and _SURROGATE.search(cell):
|
|
2384
|
-
canonical = False
|
|
2385
|
-
problems.append("FAC-SEM-035 %s/%d: a string with a lone surrogate cannot be encoded as UTF-8" % (at, j))
|
|
2386
|
-
elif isinstance(cell, str) and len(cell) > _MAX_CELL_CHARS:
|
|
2387
|
-
problems.append("%s/%d is longer than %d characters" % (at, j, _MAX_CELL_CHARS))
|
|
2388
|
-
elif not (cell is None or isinstance(cell, (bool, int, str))):
|
|
2389
|
-
canonical = False
|
|
2390
|
-
problems.append("%s/%d is not a JSON scalar" % (at, j))
|
|
2391
|
-
if counts.get(name) != len(rows):
|
|
2392
|
-
problems.append("FAC-SEM-035 /counts/%s: counts says %s for %d rows" % (name, counts.get(name, "nothing"), len(rows)))
|
|
2393
|
-
for name in counts:
|
|
2394
|
-
if name not in tables:
|
|
2395
|
-
problems.append("FAC-SEM-035 /counts/%s: counts names %s, which the backup does not carry" % (name, name))
|
|
2396
|
-
if canonical and isinstance(doc.get("tables"), dict):
|
|
2397
|
-
try:
|
|
2398
|
-
digest: Optional[str] = settings_backup_digest(tables)
|
|
2399
|
-
except (ServiceError, UnicodeError, TypeError, ValueError) as exc: # a name no canonical form can carry
|
|
2400
|
-
digest = None
|
|
2401
|
-
problems.append("FAC-SEM-035 /tables: the tables have no canonical form (%s)" % exc)
|
|
2402
|
-
if digest is not None and doc.get("sha256") != digest:
|
|
2403
|
-
problems.append("FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it")
|
|
2404
|
-
return problems
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
def settings_backup_root(platform: str = sys.platform) -> Path:
|
|
2408
|
-
"""Where every service keeps its settings backups — outside its data directory (DEC-0025)."""
|
|
2409
|
-
if platform == "darwin":
|
|
2410
|
-
return Path.home() / "Library/Application Support/PassionCode"
|
|
2411
|
-
if platform == "win32":
|
|
2412
|
-
appdata = os.environ.get("APPDATA")
|
|
2413
|
-
return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "PassionCode"
|
|
2414
|
-
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "PassionCode"
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
def settings_backup_dir(service_id: str, instance: str = "default", root: Optional[Path] = None) -> Path:
|
|
2418
|
-
_require_id(service_id)
|
|
2419
|
-
if not (isinstance(instance, str) and _INSTANCE.fullmatch(instance)):
|
|
2420
|
-
raise ServiceError("instance must match %s" % _INSTANCE.pattern)
|
|
2421
|
-
return (root or settings_backup_root()) / "settings-backups" / service_id / instance
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
def make_settings_backup(*, service_id: str, instance: str = "default", version: str, schema: Any,
|
|
2425
|
-
tables: Dict[str, Dict[str, Any]], reason: str, now: Optional[_dt.datetime] = None) -> Dict[str, Any]:
|
|
2426
|
-
"""A snapshot of the operator's decisions and bindings: the tables on the service's allow-list, each
|
|
2427
|
-
``{"columns": [...], "rows": [[...], ...]}`` of JSON scalars (a fraction as its decimal string). Refuses a file a
|
|
2428
|
-
reader would refuse."""
|
|
2429
|
-
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
2430
|
-
doc = {"format": SETTINGS_BACKUP_FORMAT,
|
|
2431
|
-
"service": {"id": service_id, "instance": instance, "version": version, "schema": schema},
|
|
2432
|
-
"created_at": now.astimezone(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "reason": reason,
|
|
2433
|
-
"sha256": "", "counts": {name: len(table.get("rows") or []) for name, table in tables.items()}, "tables": tables}
|
|
2434
|
-
try:
|
|
2435
|
-
doc["sha256"] = settings_backup_digest(tables)
|
|
2436
|
-
except ServiceError as exc:
|
|
2437
|
-
raise ServiceError("this snapshot has no canonical form: %s" % exc) from None
|
|
2438
|
-
problems = settings_backup_problems(doc)
|
|
2439
|
-
if problems:
|
|
2440
|
-
raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
|
|
2441
|
-
return doc
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
def _backup_name(doc: Dict[str, Any]) -> str:
|
|
2445
|
-
stamp = doc["created_at"].replace("-", "").replace(":", "")
|
|
2446
|
-
head, _, frac = stamp.rstrip("Z").partition(".")
|
|
2447
|
-
return "settings-%s%sZ.json" % (head, (frac + "000000")[:6])
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
def settings_backups(directory: Path) -> List[Path]:
|
|
2451
|
-
"""The snapshot files, newest first (their names sort in time order)."""
|
|
2452
|
-
directory = Path(directory)
|
|
2453
|
-
if not directory.is_dir():
|
|
2454
|
-
return []
|
|
2455
|
-
return sorted((p for p in directory.iterdir() if re.fullmatch(r"settings-[0-9]{8}T[0-9]{12}Z\.json", p.name)), reverse=True)
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
def _no_constant(name: str) -> Any:
|
|
2459
|
-
raise ValueError("%s is not JSON" % name)
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
def read_settings_backup(path: Path) -> Dict[str, Any]:
|
|
2463
|
-
"""A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035)."""
|
|
2464
|
-
try:
|
|
2465
|
-
doc = json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=_no_constant)
|
|
2466
|
-
except (OSError, ValueError) as exc:
|
|
2467
|
-
raise ServiceError("settings backup %s cannot be read: %s" % (path, exc)) from None
|
|
2468
|
-
problems = settings_backup_problems(doc)
|
|
2469
|
-
if problems:
|
|
2470
|
-
raise ServiceError("settings backup %s is refused: %s" % (path, "; ".join(problems)))
|
|
2471
|
-
return doc
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
def write_settings_backup(doc: Dict[str, Any], directory: Path, *, keep: int = SETTINGS_BACKUP_KEEP) -> Optional[Path]:
|
|
2475
|
-
"""Write a snapshot made by ``make_settings_backup``: atomically, 0600 in a 0700 directory, named by its time.
|
|
2476
|
-
A temporary file an interrupted write left is removed first; a snapshot whose checksum equals the newest file's
|
|
2477
|
-
is not written again (None). After a write, all but the ``keep`` newest are removed (at least 14)."""
|
|
2478
|
-
problems = settings_backup_problems(doc)
|
|
2479
|
-
if problems:
|
|
2480
|
-
raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
|
|
2481
|
-
directory = Path(directory)
|
|
2482
|
-
directory.mkdir(parents=True, exist_ok=True)
|
|
2483
|
-
ensure_private_dir(directory)
|
|
2484
|
-
stale = time.time() - _STALE_TEMP_SECONDS
|
|
2485
|
-
for left in directory.iterdir():
|
|
2486
|
-
# Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
|
|
2487
|
-
try:
|
|
2488
|
-
if left.name.startswith(".settings-") and left.stat().st_mtime < stale:
|
|
2489
|
-
left.unlink()
|
|
2490
|
-
except OSError:
|
|
2491
|
-
pass
|
|
2492
|
-
existing = settings_backups(directory)
|
|
2493
|
-
if existing:
|
|
2494
|
-
try:
|
|
2495
|
-
if read_settings_backup(existing[0])["sha256"] == doc["sha256"]:
|
|
2496
|
-
return None
|
|
2497
|
-
except Exception: # noqa: BLE001 - a damaged newest file is no reason to skip a good snapshot
|
|
2498
|
-
pass
|
|
2499
|
-
path = directory / _backup_name(doc)
|
|
2500
|
-
atomic_write(path, json.dumps(doc, ensure_ascii=False, indent=2).encode("utf-8") + b"\n", 0o600)
|
|
2501
|
-
# The newest by name, but never the one just written: a clock that stepped back must not delete it.
|
|
2502
|
-
for old in [p for p in settings_backups(directory) if p != path][max(keep, SETTINGS_BACKUP_KEEP) - 1:]:
|
|
2503
|
-
old.unlink()
|
|
2504
|
-
return path
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
def backup_due(directory: Path, now: Optional[_dt.datetime] = None) -> bool:
|
|
2508
|
-
"""True when there is no snapshot or the newest is older than 24 hours: take one at start or on the daily tick."""
|
|
2509
|
-
newest = settings_backups(directory)
|
|
2510
|
-
if not newest:
|
|
2511
|
-
return True
|
|
2512
|
-
try:
|
|
2513
|
-
stamp = _dt.datetime.strptime(newest[0].name[len("settings-"):-len("Z.json")], "%Y%m%dT%H%M%S%f").replace(tzinfo=_dt.timezone.utc)
|
|
2514
|
-
except ValueError:
|
|
2515
|
-
return True # a name no calendar has: take a fresh snapshot rather than trust it
|
|
2516
|
-
return ((now or _dt.datetime.now(_dt.timezone.utc)) - stamp).total_seconds() > SETTINGS_BACKUP_MAX_AGE_SECONDS
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
def newest_intact_backup(directory: Path) -> Tuple[Optional[Path], Optional[Dict[str, Any]], List[Tuple[Path, str]]]:
|
|
2520
|
-
"""The source of an automatic restore: the newest snapshot that is intact and has content. Damaged ones are
|
|
2521
|
-
skipped and returned with the reason, for the restore report."""
|
|
2522
|
-
skipped: List[Tuple[Path, str]] = []
|
|
2523
|
-
for path in settings_backups(directory):
|
|
2524
|
-
try:
|
|
2525
|
-
doc = read_settings_backup(path)
|
|
2526
|
-
except Exception as exc: # noqa: BLE001 - one damaged file never stops the scan
|
|
2527
|
-
skipped.append((path, str(exc)))
|
|
2528
|
-
continue
|
|
2529
|
-
if any(doc["counts"].values()):
|
|
2530
|
-
return path, doc, skipped
|
|
2531
|
-
return None, None, skipped
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
2535
|
-
"""What restoring ``doc`` would do — it adds and never overwrites (DEC-0025). ``current`` describes the database
|
|
2536
|
-
now, per table: ``columns`` (all of them), ``key`` (the primary key columns), ``required`` (columns without a
|
|
2537
|
-
default) and ``existing`` (the primary keys present, as tuples). Per table: the rows to insert (dicts over the
|
|
2538
|
-
columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
|
|
2539
|
-
to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
|
|
2540
|
-
``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
|
|
2541
|
-
row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's act."""
|
|
2542
|
-
problems = settings_backup_problems(doc)
|
|
2543
|
-
if problems:
|
|
2544
|
-
raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
|
|
2545
|
-
plan: Dict[str, Any] = {"source": {"created_at": doc["created_at"], "sha256": doc["sha256"]}, "tables": {}, "unknownTables": []}
|
|
2546
|
-
for name, table in doc["tables"].items():
|
|
2547
|
-
now_table = current.get(name)
|
|
2548
|
-
if now_table is None:
|
|
2549
|
-
plan["unknownTables"].append(name)
|
|
2550
|
-
continue
|
|
2551
|
-
columns = list(now_table.get("columns") or [])
|
|
2552
|
-
key = list(now_table.get("key") or [])
|
|
2553
|
-
required = list(now_table.get("required") or [])
|
|
2554
|
-
existing = {tuple(k) for k in now_table.get("existing") or []}
|
|
2555
|
-
shared = [c for c in table["columns"] if c in columns]
|
|
2556
|
-
missing = [c for c in required if c not in table["columns"]]
|
|
2557
|
-
entry = {"insert": [], "present": 0, "droppedColumns": [c for c in table["columns"] if c not in columns],
|
|
2558
|
-
"defaultedColumns": [c for c in columns if c not in table["columns"]], "missingRequired": missing, "refused": []}
|
|
2559
|
-
planned = set()
|
|
2560
|
-
for row in table["rows"]:
|
|
2561
|
-
values = dict(zip(table["columns"], row))
|
|
2562
|
-
ident = [values.get(c) for c in key]
|
|
2563
|
-
if any(c not in table["columns"] for c in key):
|
|
2564
|
-
entry["refused"].append({"key": ident, "reason": "the primary key is not in the snapshot"})
|
|
2565
|
-
elif tuple(ident) in planned:
|
|
2566
|
-
entry["refused"].append({"key": ident, "reason": "the snapshot holds this key twice"})
|
|
2567
|
-
elif tuple(ident) in existing:
|
|
2568
|
-
entry["present"] += 1
|
|
2569
|
-
elif missing:
|
|
2570
|
-
entry["refused"].append({"key": ident, "reason": "required column %s is not in the snapshot" % ", ".join(missing)})
|
|
2571
|
-
else:
|
|
2572
|
-
planned.add(tuple(ident))
|
|
2573
|
-
entry["insert"].append({c: values[c] for c in shared})
|
|
2574
|
-
plan["tables"][name] = entry
|
|
2575
|
-
return plan
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
class JsonlUsageLedger:
|
|
2579
|
-
"""Usage receipts for services that keep none yet: append-only JSON lines, mode 0600, pruned
|
|
2580
|
-
to the reported window so the file stays bounded (LC-12). One writer process — the service,
|
|
2581
|
-
which holds its instance lock; the lock here orders that process's threads."""
|
|
2582
|
-
|
|
2583
|
-
def __init__(self, path: Path):
|
|
2584
|
-
self.path = Path(path)
|
|
2585
|
-
self._lock = threading.Lock()
|
|
2586
|
-
|
|
2587
|
-
def record(self, receipt: Dict[str, Any]) -> None:
|
|
2588
|
-
line = json.dumps(receipt, separators=(",", ":")) + "\n"
|
|
2589
|
-
with self._lock:
|
|
2590
|
-
ensure_private_dir(self.path.parent)
|
|
2591
|
-
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND | _O_BINARY, 0o600)
|
|
2592
|
-
try:
|
|
2593
|
-
size = os.fstat(fd).st_size
|
|
2594
|
-
if size and _read_at(fd, 1, size - 1) != b"\n":
|
|
2595
|
-
line = "\n" + line # a killed writer left a torn line: never glue a receipt to it
|
|
2596
|
-
os.write(fd, line.encode("utf-8"))
|
|
2597
|
-
finally:
|
|
2598
|
-
os.close(fd)
|
|
2599
|
-
|
|
2600
|
-
def receipts(self) -> List[Dict[str, Any]]:
|
|
2601
|
-
if not self.path.exists():
|
|
2602
|
-
return []
|
|
2603
|
-
out = []
|
|
2604
|
-
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
2605
|
-
try:
|
|
2606
|
-
out.append(json.loads(line))
|
|
2607
|
-
except ValueError:
|
|
2608
|
-
continue # a torn last line from a killed writer is skipped, not fatal
|
|
2609
|
-
return out
|
|
2610
|
-
|
|
2611
|
-
def prune(self, now: Optional[_dt.datetime] = None) -> int:
|
|
2612
|
-
"""Drop receipts older than the reported window; returns how many were dropped."""
|
|
2613
|
-
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
2614
|
-
first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
|
|
2615
|
-
with self._lock:
|
|
2616
|
-
every = self.receipts()
|
|
2617
|
-
kept = [r for r in every if str(r.get("at", ""))[:10] >= first]
|
|
2618
|
-
dropped = len(every) - len(kept)
|
|
2619
|
-
if dropped:
|
|
2620
|
-
atomic_write(self.path, "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in kept).encode("utf-8"))
|
|
2621
|
-
return dropped
|
|
2622
|
-
|
|
2623
|
-
def report(self, *, service_id: str, instance: str = "default", now: Optional[_dt.datetime] = None,
|
|
2624
|
-
budget: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
2625
|
-
return usage_report(self.receipts(), service_id=service_id, instance=instance, now=now, budget=budget)
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
# --- logs (LC-12) ---------------------------------------------------------------
|
|
2629
|
-
|
|
2630
|
-
class RotatingLog:
|
|
2631
|
-
"""One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a
|
|
2632
|
-
0700 directory. ``service.jsonl`` -> ``.1`` ... ``.<backups>``; the oldest falls off.
|
|
2633
|
-
|
|
2634
|
-
Never pass a token, a cookie, a login code or a request body that may carry one."""
|
|
2635
|
-
|
|
2636
|
-
def __init__(self, path: Path, max_bytes: int = LOG_MAX_BYTES, backups: int = LOG_BACKUPS):
|
|
2637
|
-
if max_bytes < 256 or backups < 1:
|
|
2638
|
-
raise ServiceError("A rotating log needs max_bytes >= 256 and at least one backup.")
|
|
2639
|
-
self.path = Path(path)
|
|
2640
|
-
self.max_bytes = max_bytes
|
|
2641
|
-
self.backups = backups
|
|
2642
|
-
self._lock = threading.Lock()
|
|
2643
|
-
|
|
2644
|
-
def _line(self, record: Dict[str, Any]) -> bytes:
|
|
2645
|
-
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
2646
|
-
if len(data) <= self.max_bytes:
|
|
2647
|
-
return data
|
|
2648
|
-
record = dict(record, truncated=True)
|
|
2649
|
-
message = str(record.get("message", ""))
|
|
2650
|
-
while message:
|
|
2651
|
-
message = message[: len(message) // 2]
|
|
2652
|
-
record["message"] = message
|
|
2653
|
-
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
2654
|
-
if len(data) <= self.max_bytes:
|
|
2655
|
-
return data
|
|
2656
|
-
return (json.dumps({"at": record.get("at"), "level": record.get("level"), "message": "",
|
|
2657
|
-
"truncated": True}) + "\n").encode()
|
|
2658
|
-
|
|
2659
|
-
def write(self, level: str, message: str, **fields: Any) -> None:
|
|
2660
|
-
record: Dict[str, Any] = {"at": now_iso(), "level": str(level), "message": str(message)}
|
|
2661
|
-
record.update(fields)
|
|
2662
|
-
data = self._line(record)
|
|
2663
|
-
with self._lock:
|
|
2664
|
-
ensure_private_dir(self.path.parent)
|
|
2665
|
-
try:
|
|
2666
|
-
size = self.path.stat().st_size
|
|
2667
|
-
except FileNotFoundError:
|
|
2668
|
-
size = 0
|
|
2669
|
-
if size and size + len(data) > self.max_bytes:
|
|
2670
|
-
self._rotate()
|
|
2671
|
-
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
|
|
2672
|
-
try:
|
|
2673
|
-
os.write(fd, data)
|
|
2674
|
-
finally:
|
|
2675
|
-
os.close(fd)
|
|
2676
|
-
|
|
2677
|
-
def _rotate(self) -> None:
|
|
2678
|
-
for index in range(self.backups - 1, 0, -1):
|
|
2679
|
-
older = Path("%s.%d" % (self.path, index))
|
|
2680
|
-
if older.exists():
|
|
2681
|
-
os.replace(older, "%s.%d" % (self.path, index + 1))
|
|
2682
|
-
os.replace(self.path, "%s.1" % self.path)
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
def cap_stdout_log(path: Path, max_bytes: int = LOG_MAX_BYTES) -> bool:
|
|
2686
|
-
"""Call once at start: a launchd stdout file over ``max_bytes`` is copied to ``<path>.1`` and
|
|
2687
|
-
truncated in place. launchd holds the file open for append, so a rename would leave it
|
|
2688
|
-
writing to the old name; truncation keeps its descriptor valid. Returns True when capped."""
|
|
2689
|
-
path = Path(path)
|
|
2690
|
-
try:
|
|
2691
|
-
size = path.stat().st_size
|
|
2692
|
-
except FileNotFoundError:
|
|
2693
|
-
return False
|
|
2694
|
-
if size <= max_bytes:
|
|
2695
|
-
return False
|
|
2696
|
-
backup = Path("%s.1" % path)
|
|
2697
|
-
shutil.copyfile(path, backup)
|
|
2698
|
-
os.chmod(backup, 0o600)
|
|
2699
|
-
with open(path, "r+b") as handle:
|
|
2700
|
-
handle.truncate(0)
|
|
2701
|
-
return True
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
# --- shutdown (LC-01) -------------------------------------------------------------
|
|
2705
|
-
|
|
2706
|
-
class Stopping(ServiceError):
|
|
2707
|
-
"""The service is stopping and takes no new work."""
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
class Drain:
|
|
2711
|
-
"""SIGTERM/SIGINT: stop taking new work, drain in-flight work until ``deadline``, then hand
|
|
2712
|
-
over with ``on_stop(drained)``. A hand-over that hangs is cut by a hard exit
|
|
2713
|
-
(``EXIT_HARD_STOP``) ``grace`` seconds later, so quit reaches exit inside the lifecycle bound.
|
|
2714
|
-
|
|
2715
|
-
drain = fs.Drain()
|
|
2716
|
-
drain.install(lambda drained: server.shutdown(),
|
|
2717
|
-
on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
|
|
2718
|
-
with drain.work(): # raises fs.Stopping once a stop has begun
|
|
2719
|
-
...
|
|
2720
|
-
"""
|
|
2721
|
-
|
|
2722
|
-
def __init__(self, deadline: float = DRAIN_SECONDS, grace: float = DRAIN_GRACE_SECONDS):
|
|
2723
|
-
self.deadline = deadline
|
|
2724
|
-
self.grace = grace
|
|
2725
|
-
self._cond = threading.Condition()
|
|
2726
|
-
self._inflight = 0
|
|
2727
|
-
self._stopping = False
|
|
2728
|
-
|
|
2729
|
-
@property
|
|
2730
|
-
def stopping(self) -> bool:
|
|
2731
|
-
return self._stopping
|
|
2732
|
-
|
|
2733
|
-
@property
|
|
2734
|
-
def inflight(self) -> int:
|
|
2735
|
-
return self._inflight
|
|
2736
|
-
|
|
2737
|
-
@contextlib.contextmanager
|
|
2738
|
-
def work(self):
|
|
2739
|
-
with self._cond:
|
|
2740
|
-
if self._stopping:
|
|
2741
|
-
raise Stopping("The service is stopping and takes no new work.")
|
|
2742
|
-
self._inflight += 1
|
|
2743
|
-
try:
|
|
2744
|
-
yield
|
|
2745
|
-
finally:
|
|
2746
|
-
with self._cond:
|
|
2747
|
-
self._inflight -= 1
|
|
2748
|
-
self._cond.notify_all()
|
|
2749
|
-
|
|
2750
|
-
def request_stop(self) -> bool:
|
|
2751
|
-
"""Refuse new work from now on; False when a stop had already begun."""
|
|
2752
|
-
with self._cond:
|
|
2753
|
-
if self._stopping:
|
|
2754
|
-
return False
|
|
2755
|
-
self._stopping = True
|
|
2756
|
-
return True
|
|
2757
|
-
|
|
2758
|
-
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
2759
|
-
"""Wait for in-flight work to finish; True when it did inside ``timeout``."""
|
|
2760
|
-
end = None if timeout is None else time.monotonic() + timeout
|
|
2761
|
-
with self._cond:
|
|
2762
|
-
while self._inflight > 0:
|
|
2763
|
-
left = None if end is None else end - time.monotonic()
|
|
2764
|
-
if left is not None and left <= 0:
|
|
2765
|
-
return False
|
|
2766
|
-
self._cond.wait(left)
|
|
2767
|
-
return True
|
|
2768
|
-
|
|
2769
|
-
def install(self, on_stop: Callable[[bool], None], *, on_stopping: Optional[Callable[[], None]] = None,
|
|
2770
|
-
signals: Sequence[int] = (signal.SIGTERM, signal.SIGINT)) -> "Drain":
|
|
2771
|
-
"""Install the handlers (main thread only). A second signal changes nothing: the hard-exit
|
|
2772
|
-
timer already holds the deadline."""
|
|
2773
|
-
def handler(_signum: int, _frame: Any) -> None:
|
|
2774
|
-
if self.request_stop():
|
|
2775
|
-
threading.Thread(target=self._stop, args=(on_stopping, on_stop), daemon=True).start()
|
|
2776
|
-
|
|
2777
|
-
for sig in signals:
|
|
2778
|
-
signal.signal(sig, handler)
|
|
2779
|
-
return self
|
|
2780
|
-
|
|
2781
|
-
def _stop(self, on_stopping: Optional[Callable[[], None]], on_stop: Callable[[bool], None]) -> None:
|
|
2782
|
-
hard = threading.Timer(self.deadline + self.grace, os._exit, args=(EXIT_HARD_STOP,))
|
|
2783
|
-
hard.daemon = True
|
|
2784
|
-
hard.start()
|
|
2785
|
-
if on_stopping is not None:
|
|
2786
|
-
try:
|
|
2787
|
-
on_stopping()
|
|
2788
|
-
except Exception: # a failing stop notice must not stop the drain
|
|
2789
|
-
traceback.print_exc()
|
|
2790
|
-
on_stop(self.wait(self.deadline))
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
# --- operator login ----------------------------------------------------------
|
|
2794
|
-
|
|
2795
|
-
class LoginCodes:
|
|
2796
|
-
"""Single-use login codes (<=120 s), recorded as used BEFORE they are honoured, and operator
|
|
2797
|
-
sessions with the DEC-0039 lifetime: a session ends 12 h after its last use, 7 days after the code
|
|
2798
|
-
was redeemed, on logout, and when the descriptor token rotates. The server enforces all four: the
|
|
2799
|
-
cookie carries a signed claim (session id, sign-in time, token revision) and the code store keeps a
|
|
2800
|
-
record per session (last use, logout). Rotating the key with revoke_all() ends every session."""
|
|
2801
|
-
|
|
2802
|
-
def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
|
|
2803
|
-
store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None,
|
|
2804
|
-
token: "Optional[str | Callable[[], str]]" = None,
|
|
2805
|
-
idle_seconds: int = OPERATOR_SESSION_IDLE_SECONDS,
|
|
2806
|
-
max_seconds: int = OPERATOR_SESSION_MAX_SECONDS,
|
|
2807
|
-
clock: Callable[[], float] = time.time):
|
|
2808
|
-
# A local service keeps codes, sessions and the key in files. An online one (DEC-0019) usually has
|
|
2809
|
-
# no durable disk: pass state_dir=None with store=MemoryCodeStore() and key= bytes from a platform
|
|
2810
|
-
# secret. A memory store forgets codes AND sessions on a restart, so a deploy signs the operator
|
|
2811
|
-
# out (the host signs in again on the page's 401); pass a durable store to keep sessions.
|
|
2812
|
-
# token= is the descriptor token, or a callable returning the current one: a session begun under
|
|
2813
|
-
# one token is refused once it rotates. A service MAY shorten either bound, never lengthen one.
|
|
2814
|
-
self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
|
|
2815
|
-
self.idle_seconds = _session_bound(idle_seconds, OPERATOR_SESSION_IDLE_SECONDS)
|
|
2816
|
-
self.max_seconds = _session_bound(max_seconds, OPERATOR_SESSION_MAX_SECONDS)
|
|
2817
|
-
# One lock around every load-modify-save, so a concurrent refresh never saves over a logout or a
|
|
2818
|
-
# burned code (a threading server checks sessions on several threads; the instance lock keeps
|
|
2819
|
-
# other processes out of the state directory).
|
|
2820
|
-
self._lock = threading.RLock()
|
|
2821
|
-
self._store = store
|
|
2822
|
-
self._fixed_key = key
|
|
2823
|
-
self._token = token
|
|
2824
|
-
self._clock = clock
|
|
2825
|
-
if state_dir is not None:
|
|
2826
|
-
self.dir = ensure_private_dir(Path(state_dir))
|
|
2827
|
-
self._key_path = self.dir / "session.key"
|
|
2828
|
-
self._codes_path = self.dir / "login-codes.json"
|
|
2829
|
-
elif store is None or key is None:
|
|
2830
|
-
raise ServiceError("LoginCodes without a state directory needs store= and key=.")
|
|
2831
|
-
if key is not None and len(key) < 32:
|
|
2832
|
-
raise ServiceError("the session key must be at least 32 bytes.")
|
|
2833
|
-
|
|
2834
|
-
def _key(self) -> bytes:
|
|
2835
|
-
if self._fixed_key is not None:
|
|
2836
|
-
return bytes(self._fixed_key)
|
|
2837
|
-
if not self._key_path.exists():
|
|
2838
|
-
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
2839
|
-
return self._key_path.read_bytes()
|
|
2840
|
-
|
|
2841
|
-
def _load(self) -> Dict[str, Any]:
|
|
2842
|
-
if self._store is not None:
|
|
2843
|
-
return self._store.load()
|
|
2844
|
-
try:
|
|
2845
|
-
return json.loads(self._codes_path.read_text())
|
|
2846
|
-
except (OSError, ValueError):
|
|
2847
|
-
return {}
|
|
2848
|
-
|
|
2849
|
-
def _save(self, codes: Dict[str, Any]) -> None:
|
|
2850
|
-
horizon = self._clock() - 3600
|
|
2851
|
-
codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
|
|
2852
|
-
if self._store is not None:
|
|
2853
|
-
self._store.save(codes)
|
|
2854
|
-
return
|
|
2855
|
-
atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
|
|
2856
|
-
|
|
2857
|
-
def issue(self) -> Dict[str, str]:
|
|
2858
|
-
with self._lock:
|
|
2859
|
-
code = secrets.token_urlsafe(24)
|
|
2860
|
-
expires = self._clock() + self.ttl
|
|
2861
|
-
codes = self._load()
|
|
2862
|
-
codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
|
|
2863
|
-
self._save(codes)
|
|
2864
|
-
at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
|
|
2865
|
-
return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
|
|
2866
|
-
|
|
2867
|
-
def redeem(self, code: Optional[str]) -> Optional[str]:
|
|
2868
|
-
"""Return a session cookie value, or None. The code is burned even when it has expired."""
|
|
2869
|
-
with self._lock:
|
|
2870
|
-
if not code or not _CODE.match(code):
|
|
2871
|
-
return None
|
|
2872
|
-
digest = hashlib.sha256(code.encode()).hexdigest()
|
|
2873
|
-
codes = self._load()
|
|
2874
|
-
entry = codes.get(digest)
|
|
2875
|
-
if not entry or entry.get("used"):
|
|
2876
|
-
return None
|
|
2877
|
-
entry["used"] = True
|
|
2878
|
-
self._save(codes)
|
|
2879
|
-
now = self._clock()
|
|
2880
|
-
if entry.get("expires", 0) < now:
|
|
2881
|
-
return None
|
|
2882
|
-
session_id = secrets.token_urlsafe(18)
|
|
2883
|
-
signed_in = int(now)
|
|
2884
|
-
codes[self._record_key(session_id)] = {"expires": signed_in + self.max_seconds,
|
|
2885
|
-
"signedInAt": signed_in, "lastUsedAt": now}
|
|
2886
|
-
self._save(codes)
|
|
2887
|
-
claim = "v2.%s.%d.%s" % (session_id, signed_in, self._token_revision())
|
|
2888
|
-
return claim + "." + self._mac(claim)
|
|
2889
|
-
|
|
2890
|
-
def session_valid(self, cookie_value: Optional[str]) -> bool:
|
|
2891
|
-
"""True while the session is live; a use within the bounds refreshes the idle bound."""
|
|
2892
|
-
with self._lock:
|
|
2893
|
-
parsed = self._parse(cookie_value)
|
|
2894
|
-
if parsed is None:
|
|
2895
|
-
return False
|
|
2896
|
-
session_id, signed_in = parsed
|
|
2897
|
-
now = self._clock()
|
|
2898
|
-
if now >= signed_in + self.max_seconds:
|
|
2899
|
-
return False
|
|
2900
|
-
codes = self._load()
|
|
2901
|
-
record = codes.get(self._record_key(session_id))
|
|
2902
|
-
if not record or record.get("signedInAt") != signed_in or record.get("loggedOutAt") is not None:
|
|
2903
|
-
return False
|
|
2904
|
-
last = float(record.get("lastUsedAt", 0))
|
|
2905
|
-
if now >= last + self.idle_seconds:
|
|
2906
|
-
return False
|
|
2907
|
-
if now - last >= min(60, max(1, self.idle_seconds // 10)):
|
|
2908
|
-
record["lastUsedAt"] = now
|
|
2909
|
-
self._save(codes)
|
|
2910
|
-
return True
|
|
2911
|
-
|
|
2912
|
-
def logout(self, cookie_value: Optional[str]) -> bool:
|
|
2913
|
-
"""End this session on the server: every copy of the cookie is refused from now on. Clear the
|
|
2914
|
-
browser's copy with expired_session_cookie_header(). False when there was no live record."""
|
|
2915
|
-
with self._lock:
|
|
2916
|
-
parsed = self._parse(cookie_value)
|
|
2917
|
-
if parsed is None:
|
|
2918
|
-
return False
|
|
2919
|
-
codes = self._load()
|
|
2920
|
-
record = codes.get(self._record_key(parsed[0]))
|
|
2921
|
-
if not record or record.get("signedInAt") != parsed[1] or record.get("loggedOutAt") is not None:
|
|
2922
|
-
return False
|
|
2923
|
-
record["loggedOutAt"] = self._clock()
|
|
2924
|
-
self._save(codes)
|
|
2925
|
-
return True
|
|
2926
|
-
|
|
2927
|
-
def _parse(self, cookie_value: Optional[str]) -> Optional[Tuple[str, int]]:
|
|
2928
|
-
"""The signed claim of a cookie under the current key and token, or None."""
|
|
2929
|
-
if not isinstance(cookie_value, str) or not cookie_value.isascii():
|
|
2930
|
-
return None
|
|
2931
|
-
parts = cookie_value.split(".")
|
|
2932
|
-
if len(parts) != 5 or parts[0] != "v2" or not parts[1] or not parts[2].isdigit():
|
|
2933
|
-
return None
|
|
2934
|
-
claim, mac = ".".join(parts[:4]), parts[4]
|
|
2935
|
-
if not hmac.compare_digest(mac, self._mac(claim)):
|
|
2936
|
-
return None
|
|
2937
|
-
if not hmac.compare_digest(parts[3], self._token_revision()):
|
|
2938
|
-
return None
|
|
2939
|
-
return parts[1], int(parts[2])
|
|
2940
|
-
|
|
2941
|
-
def _mac(self, claim: str) -> str:
|
|
2942
|
-
return hmac.new(self._key(), claim.encode(), hashlib.sha256).hexdigest()
|
|
2943
|
-
|
|
2944
|
-
def _token_revision(self) -> str:
|
|
2945
|
-
token = self._token() if callable(self._token) else self._token
|
|
2946
|
-
if not token:
|
|
2947
|
-
return "-"
|
|
2948
|
-
return hmac.new(self._key(), b"token-revision\0" + token.encode(), hashlib.sha256).hexdigest()[:16]
|
|
2949
|
-
|
|
2950
|
-
@staticmethod
|
|
2951
|
-
def _record_key(session_id: str) -> str:
|
|
2952
|
-
return "s:" + hashlib.sha256(session_id.encode()).hexdigest()
|
|
2953
|
-
|
|
2954
|
-
def revoke_all(self) -> None:
|
|
2955
|
-
if self._fixed_key is not None:
|
|
2956
|
-
raise ServiceError("a platform-held session key is rotated on the platform, not here.")
|
|
2957
|
-
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
def _session_bound(value: Any, longest: int) -> int:
|
|
2961
|
-
"""A session bound in seconds: a finite number, never longer than the contract's (DEC-0039)."""
|
|
2962
|
-
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value in (float("inf"), float("-inf")):
|
|
2963
|
-
raise ServiceError("a session bound must be a finite number of seconds.")
|
|
2964
|
-
return min(int(value), longest)
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
class MemoryCodeStore:
|
|
2968
|
-
"""DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
|
|
2969
|
-
|
|
2970
|
-
def __init__(self) -> None:
|
|
2971
|
-
self._codes: Dict[str, Any] = {}
|
|
2972
|
-
|
|
2973
|
-
def load(self) -> Dict[str, Any]:
|
|
2974
|
-
return json.loads(json.dumps(self._codes))
|
|
2975
|
-
|
|
2976
|
-
def save(self, codes: Dict[str, Any]) -> None:
|
|
2977
|
-
self._codes = json.loads(json.dumps(codes))
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
SESSION_COOKIE = "fabric_session"
|
|
2981
|
-
REMOTE_SESSION_COOKIE = "__Host-fabric_session"
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
def session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
|
|
2985
|
-
"""The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner."""
|
|
2986
|
-
return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
def remote_session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
|
|
2990
|
-
"""DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
|
|
2991
|
-
return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
def expired_session_cookie_header(remote: bool = False) -> str:
|
|
2995
|
-
"""The header that clears the browser's session cookie, sent with a logout."""
|
|
2996
|
-
return (remote_session_cookie_header if remote else session_cookie_header)("", 0)
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
|
|
3000
|
-
sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
|
|
3001
|
-
"""DEC-0019: the request guard of an online service; returns the refusal sentence or None.
|
|
3002
|
-
``forwarded_proto`` is the platform-set scheme where TLS ends before the process; pass None
|
|
3003
|
-
when the process terminates TLS itself."""
|
|
3004
|
-
own = urllib.parse.urlsplit(origin)
|
|
3005
|
-
own_host = own.netloc.lower()
|
|
3006
|
-
if str(host or "").lower() != own_host:
|
|
3007
|
-
return "Host %s is not this service." % host
|
|
3008
|
-
if forwarded_proto is not None and str(forwarded_proto).split(",")[0].strip() != "https":
|
|
3009
|
-
return "This service answers over https only."
|
|
3010
|
-
if request_origin is not None and request_origin != "%s://%s" % (own.scheme, own_host):
|
|
3011
|
-
return "Origin %s is not this service." % request_origin
|
|
3012
|
-
if sec_fetch_site == "cross-site":
|
|
3013
|
-
return "Cross-site requests are refused."
|
|
3014
|
-
return None
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
def well_known_allowed(placement: str, authorization: Optional[str], token: str, scheme: str = "Bearer") -> bool:
|
|
3018
|
-
"""DEC-0019: a local service answers anyone (the loopback guard already ran); a remote one only
|
|
3019
|
-
the bearer of the service token. False is a 401 with an EMPTY body."""
|
|
3020
|
-
return placement != "remote" or token_matches(authorization, token, scheme)
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
def remote_token_file(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
3024
|
-
return (directory or services_dir()).parent / "tokens" / ("%s.%s.token" % (service_id, instance))
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
|
|
3028
|
-
summary: Optional[str] = None, doctor: Optional[List[str]] = None,
|
|
3029
|
-
directory: Optional[Path] = None, installed_by: Any = None,
|
|
3030
|
-
runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
|
|
3031
|
-
"""DEC-0019, installer side: register an online service on THIS computer — the token file
|
|
3032
|
-
(0600, never printed) and the descriptor. The same token is set on the platform as a secret.
|
|
3033
|
-
|
|
3034
|
-
DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
|
|
3035
|
-
register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
|
|
3036
|
-
string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
|
|
3037
|
-
credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
|
|
3038
|
-
if not token or len(token.strip()) < 16:
|
|
3039
|
-
raise ServiceError("the service token must be at least 16 characters.")
|
|
3040
|
-
root = directory or services_dir()
|
|
3041
|
-
token_file = remote_token_file(service_id, instance, root)
|
|
3042
|
-
descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
|
|
3043
|
-
"placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
|
|
3044
|
-
"lifecycle": {"manager": "none"}, "installedAt": now_iso(),
|
|
3045
|
-
"installedBy": installed_by if installed_by is not None else
|
|
3046
|
-
{"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
|
|
3047
|
-
if summary:
|
|
3048
|
-
descriptor["summary"] = summary
|
|
3049
|
-
if runner is not None:
|
|
3050
|
-
descriptor["runner"] = dict(runner)
|
|
3051
|
-
if peer is not None:
|
|
3052
|
-
descriptor["peer"] = dict(peer)
|
|
3053
|
-
if doctor:
|
|
3054
|
-
descriptor["commands"] = {"doctor": list(doctor)}
|
|
3055
|
-
problems = validate_descriptor(descriptor)
|
|
3056
|
-
if problems:
|
|
3057
|
-
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
3058
|
-
ensure_private_dir(token_file.parent)
|
|
3059
|
-
atomic_write(token_file, token.strip().encode(), 0o600)
|
|
3060
|
-
return write_descriptor(descriptor, root)
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
|
|
3064
|
-
for part in (cookie_header or "").split(";"):
|
|
3065
|
-
key, _, value = part.strip().partition("=")
|
|
3066
|
-
if key == name:
|
|
3067
|
-
return value
|
|
3068
|
-
return None
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
# --- launchd -----------------------------------------------------------------
|
|
3072
|
-
|
|
3073
|
-
# --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
|
|
3074
|
-
|
|
3075
|
-
def _no_marker_argument(argv: Sequence[str]) -> None:
|
|
3076
|
-
"""The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it."""
|
|
3077
|
-
if _marker_arguments(list(argv)):
|
|
3078
|
-
raise ServiceError("%s is written by the supervisor file's writer; leave it out of the program arguments" % SUPERVISOR_ARG)
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
|
|
3082
|
-
"""No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
|
|
3083
|
-
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
3084
|
-
for arg in argv:
|
|
3085
|
-
if fabric_secrets.secret_argument(arg):
|
|
3086
|
-
raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
|
|
3087
|
-
for name, value in (environment or {}).items():
|
|
3088
|
-
if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
|
|
3089
|
-
raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
def _plain(value: str, what: str) -> str:
|
|
3093
|
-
"""A supervisor file is line- and XML-structured: no control character in anything written into it."""
|
|
3094
|
-
if re.search(r"[\x00-\x1f\x7f]", value):
|
|
3095
|
-
raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
|
|
3096
|
-
return value
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
def _systemd_quote(value: str) -> str:
|
|
3100
|
-
_plain(value, "an argument")
|
|
3101
|
-
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
|
|
3102
|
-
return '"%s"' % escaped
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
3106
|
-
environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
|
|
3107
|
-
stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
|
|
3108
|
-
"""A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
|
|
3109
|
-
WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
|
|
3110
|
-
if not _UNIT.match(unit):
|
|
3111
|
-
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
3112
|
-
argv = [str(a) for a in program_arguments]
|
|
3113
|
-
if not argv or not argv[0].startswith("/"):
|
|
3114
|
-
raise ServiceError("a systemd unit starts an absolute executable")
|
|
3115
|
-
if stop_timeout <= DRAIN_SECONDS:
|
|
3116
|
-
raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
|
|
3117
|
-
env = dict(environment or {})
|
|
3118
|
-
_no_secret_in_supervisor_file(argv, env)
|
|
3119
|
-
_no_marker_argument(argv)
|
|
3120
|
-
env[SUPERVISOR_ENV] = "systemd" # the unit's writer writes the marker, whatever the caller passed
|
|
3121
|
-
# A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
|
|
3122
|
-
cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
|
|
3123
|
-
"the working directory")
|
|
3124
|
-
if not cwd.startswith("/"):
|
|
3125
|
-
raise ServiceError("a systemd unit's working directory is absolute")
|
|
3126
|
-
lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
|
|
3127
|
-
"[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
|
|
3128
|
-
# Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
3129
|
-
"WorkingDirectory=%s" % cwd.replace("%", "%%")]
|
|
3130
|
-
for name in env:
|
|
3131
|
-
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
|
3132
|
-
raise ServiceError("environment name %r is not a variable name" % name)
|
|
3133
|
-
lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
|
|
3134
|
-
lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
|
|
3135
|
-
"[Install]", "WantedBy=default.target", ""]
|
|
3136
|
-
return "\n".join(lines)
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
def _systemd_dir() -> Path:
|
|
3140
|
-
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
def _check(result: Any, what: str) -> None:
|
|
3144
|
-
if getattr(result, "returncode", 1) != 0:
|
|
3145
|
-
raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
3149
|
-
system: str = sys.platform, force_enable: bool = False) -> Path:
|
|
3150
|
-
"""Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
3151
|
-
|
|
3152
|
-
The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
|
|
3153
|
-
the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
|
|
3154
|
-
if not system.startswith("linux"):
|
|
3155
|
-
raise ServiceError("systemd is the supervisor on Linux only")
|
|
3156
|
-
if not _UNIT.match(unit):
|
|
3157
|
-
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
3158
|
-
folder = unit_dir or _systemd_dir()
|
|
3159
|
-
folder.mkdir(parents=True, exist_ok=True)
|
|
3160
|
-
path = folder / unit
|
|
3161
|
-
first = not path.exists()
|
|
3162
|
-
state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
|
|
3163
|
-
"stdout", "") or "").strip()
|
|
3164
|
-
atomic_write(path, text.encode("utf-8"), 0o644)
|
|
3165
|
-
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
3166
|
-
if state == "disabled" and not force_enable:
|
|
3167
|
-
return path
|
|
3168
|
-
_check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
|
|
3169
|
-
_check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
|
|
3170
|
-
return path
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
3174
|
-
system: str = sys.platform) -> None:
|
|
3175
|
-
"""Stop it and keep it off across logins (`disable --now`), then remove the unit."""
|
|
3176
|
-
if not system.startswith("linux"):
|
|
3177
|
-
raise ServiceError("systemd is the supervisor on Linux only")
|
|
3178
|
-
run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
|
|
3179
|
-
path = (unit_dir or _systemd_dir()) / unit
|
|
3180
|
-
if path.exists():
|
|
3181
|
-
path.unlink()
|
|
3182
|
-
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
def _xml(text: str) -> str:
|
|
3186
|
-
return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
|
|
3190
|
-
user: Optional[str] = None) -> str:
|
|
3191
|
-
"""A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
|
|
3192
|
-
run only when the user is logged on, created without administrator rights."""
|
|
3193
|
-
if not _TASK.match(task):
|
|
3194
|
-
raise ServiceError("task must match %s" % _TASK.pattern)
|
|
3195
|
-
argv = [str(a) for a in program_arguments]
|
|
3196
|
-
if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
|
|
3197
|
-
raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
|
|
3198
|
-
for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
|
|
3199
|
-
(description or task, "the description")]:
|
|
3200
|
-
_plain(value, what)
|
|
3201
|
-
_no_secret_in_supervisor_file(argv, None)
|
|
3202
|
-
_no_marker_argument(argv)
|
|
3203
|
-
# A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
|
|
3204
|
-
cut = argv.index("--") if "--" in argv else len(argv)
|
|
3205
|
-
argv = argv[:cut] + [SUPERVISOR_ARG, "task-scheduler"] + argv[cut:]
|
|
3206
|
-
who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
|
|
3207
|
-
if not who:
|
|
3208
|
-
raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
|
|
3209
|
-
_plain(who, "the user")
|
|
3210
|
-
body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
3211
|
-
<RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
|
|
3212
|
-
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
|
|
3213
|
-
<Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
3214
|
-
<Settings>
|
|
3215
|
-
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
3216
|
-
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
3217
|
-
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
3218
|
-
<StartWhenAvailable>true</StartWhenAvailable>
|
|
3219
|
-
<Enabled>true</Enabled>
|
|
3220
|
-
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
3221
|
-
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
3222
|
-
</Settings>
|
|
3223
|
-
<Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
|
|
3224
|
-
</Task>
|
|
3225
|
-
""".format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
|
|
3226
|
-
arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
|
|
3227
|
-
return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
_TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
|
|
3234
|
-
force_enable: bool = False) -> None:
|
|
3235
|
-
"""Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
|
|
3236
|
-
definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
|
|
3237
|
-
if system != "win32":
|
|
3238
|
-
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
3239
|
-
query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
|
|
3240
|
-
existed = getattr(query, "returncode", 1) == 0
|
|
3241
|
-
keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
|
|
3242
|
-
fd, name = tempfile.mkstemp(suffix=".xml")
|
|
3243
|
-
try:
|
|
3244
|
-
with os.fdopen(fd, "wb") as handle:
|
|
3245
|
-
handle.write(xml_text.encode("utf-16"))
|
|
3246
|
-
_check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
|
|
3247
|
-
finally:
|
|
3248
|
-
os.unlink(name)
|
|
3249
|
-
if keep_off:
|
|
3250
|
-
_check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
|
|
3251
|
-
return
|
|
3252
|
-
if existed:
|
|
3253
|
-
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
3254
|
-
_check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
|
|
3258
|
-
"""End the running task, then delete it."""
|
|
3259
|
-
if system != "win32":
|
|
3260
|
-
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
3261
|
-
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
3262
|
-
_check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
3266
|
-
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
3267
|
-
exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
|
|
3268
|
-
"""RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
|
|
3269
|
-
|
|
3270
|
-
``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
|
|
3271
|
-
SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
|
|
3272
|
-
instead of exiting into a respawn loop."""
|
|
3273
|
-
env = dict(environment or {})
|
|
3274
|
-
for key in env:
|
|
3275
|
-
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
3276
|
-
raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
|
|
3277
|
-
env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
|
3278
|
-
_no_marker_argument([str(a) for a in program_arguments])
|
|
3279
|
-
env[SUPERVISOR_ENV] = "launchd"
|
|
3280
|
-
plist = {
|
|
3281
|
-
"Label": label,
|
|
3282
|
-
"ProgramArguments": list(program_arguments),
|
|
3283
|
-
"WorkingDirectory": str(working_directory),
|
|
3284
|
-
"RunAtLoad": True,
|
|
3285
|
-
"KeepAlive": True,
|
|
3286
|
-
"ThrottleInterval": 10,
|
|
3287
|
-
"ExitTimeOut": exit_timeout,
|
|
3288
|
-
# Standard, never Background: a host probes the service and agents call it while the Mac
|
|
3289
|
-
# is busy; Background (and Nice/LowPriorityIO) lets macOS starve it for tens of seconds
|
|
3290
|
-
# under load, and every host then reports an outage that never happened.
|
|
3291
|
-
"ProcessType": "Standard",
|
|
3292
|
-
"StandardOutPath": str(stdout_path),
|
|
3293
|
-
"StandardErrorPath": str(stdout_path),
|
|
3294
|
-
"EnvironmentVariables": env,
|
|
3295
|
-
}
|
|
3296
|
-
return plistlib.dumps(plist)
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
def _launchctl(*args: str, timeout: int = 30) -> subprocess.CompletedProcess:
|
|
3300
|
-
return subprocess.run(["launchctl", *args], capture_output=True, text=True, timeout=timeout)
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
def _domain() -> str:
|
|
3304
|
-
if sys.platform == "win32":
|
|
3305
|
-
# DEC-0032: launchd is macOS's supervisor; Windows has Task Scheduler, and no uid.
|
|
3306
|
-
raise ServiceError("launchd exists only on macOS; this system's supervisor is Task Scheduler.")
|
|
3307
|
-
return "gui/%d" % os.getuid()
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
def launchd_loaded(label: str) -> bool:
|
|
3311
|
-
return _launchctl("print", "%s/%s" % (_domain(), label)).returncode == 0
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, Any]]:
|
|
3315
|
-
try:
|
|
3316
|
-
with urllib.request.urlopen(origin + "/.well-known/fabric-service", timeout=timeout) as response:
|
|
3317
|
-
return json.loads(response.read().decode())
|
|
3318
|
-
except (urllib.error.URLError, OSError, ValueError):
|
|
3319
|
-
return None
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*$')
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
def launchd_override(label: str) -> Optional[str]:
|
|
3326
|
-
"""The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
|
|
3327
|
-
none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
|
|
3328
|
-
|
|
3329
|
-
Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
|
|
3330
|
-
older releases ``=> true|false`` (true means disabled)."""
|
|
3331
|
-
result = _launchctl("print-disabled", _domain())
|
|
3332
|
-
if result.returncode != 0:
|
|
3333
|
-
return "unknown"
|
|
3334
|
-
for line in result.stdout.splitlines():
|
|
3335
|
-
match = _OVERRIDE_ROW.match(line)
|
|
3336
|
-
if match and match.group(1) == label:
|
|
3337
|
-
return "disabled" if match.group(2) in ("disabled", "true") else "enabled"
|
|
3338
|
-
return None
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
3342
|
-
service_id: str, instance: str = "default", timeout: float = 40.0,
|
|
3343
|
-
force_enable: bool = False) -> Dict[str, Any]:
|
|
3344
|
-
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
3345
|
-
poll the well-known document until it answers with this identity.
|
|
3346
|
-
|
|
3347
|
-
The operator's intent wins (LC-14): only a first install (no override recorded) enables the
|
|
3348
|
-
label. A label the operator disabled stays disabled and unloaded — the plist is still
|
|
3349
|
-
rewritten, so the new release is what starts once they turn it back on — and the answer is
|
|
3350
|
-
``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
|
|
3351
|
-
``force_enable`` is for an operator who asked for the service to be switched on."""
|
|
3352
|
-
plist_path = Path(plist_path)
|
|
3353
|
-
atomic_write(plist_path, plist_bytes, 0o644)
|
|
3354
|
-
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
3355
|
-
if lint.returncode != 0:
|
|
3356
|
-
raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
|
|
3357
|
-
target = "%s/%s" % (_domain(), label)
|
|
3358
|
-
override = launchd_override(label)
|
|
3359
|
-
if force_enable or override is None:
|
|
3360
|
-
_launchctl("enable", target)
|
|
3361
|
-
elif override == "disabled":
|
|
3362
|
-
return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
|
|
3363
|
-
_launchctl("bootout", target)
|
|
3364
|
-
deadline = time.time() + 15
|
|
3365
|
-
while launchd_loaded(label) and time.time() < deadline:
|
|
3366
|
-
time.sleep(0.25)
|
|
3367
|
-
last = ""
|
|
3368
|
-
for _ in range(5):
|
|
3369
|
-
result = _launchctl("bootstrap", _domain(), str(plist_path))
|
|
3370
|
-
if result.returncode == 0:
|
|
3371
|
-
break
|
|
3372
|
-
last = (result.stderr or result.stdout).strip()
|
|
3373
|
-
time.sleep(1.0)
|
|
3374
|
-
else:
|
|
3375
|
-
raise ServiceError("launchctl bootstrap failed: %s" % last)
|
|
3376
|
-
deadline = time.time() + timeout
|
|
3377
|
-
while time.time() < deadline:
|
|
3378
|
-
doc = fetch_well_known(origin)
|
|
3379
|
-
if doc and doc.get("service", {}).get("id") == service_id and doc.get("service", {}).get("instance") == instance:
|
|
3380
|
-
return doc
|
|
3381
|
-
time.sleep(0.5)
|
|
3382
|
-
raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
|
|
3386
|
-
service_id: Optional[str] = None) -> Dict[str, Any]:
|
|
3387
|
-
"""Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
|
|
3388
|
-
the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
|
|
3389
|
-
override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
|
|
3390
|
-
unless ``purge`` (then ``service_id`` names whose data, logs and cache go). A job still
|
|
3391
|
-
loaded at ``timeout`` raises, leaving the plist and data in place."""
|
|
3392
|
-
if purge and not service_id:
|
|
3393
|
-
raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
|
|
3394
|
-
target = "%s/%s" % (_domain(), label)
|
|
3395
|
-
_launchctl("bootout", target)
|
|
3396
|
-
deadline = time.monotonic() + timeout
|
|
3397
|
-
while launchd_loaded(label):
|
|
3398
|
-
if time.monotonic() >= deadline:
|
|
3399
|
-
raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
|
|
3400
|
-
time.sleep(0.1)
|
|
3401
|
-
try:
|
|
3402
|
-
Path(plist_path).unlink()
|
|
3403
|
-
plist_removed = True
|
|
3404
|
-
except FileNotFoundError:
|
|
3405
|
-
plist_removed = False
|
|
3406
|
-
_launchctl("enable", target)
|
|
3407
|
-
purged: List[str] = []
|
|
3408
|
-
if purge:
|
|
3409
|
-
for directory in service_dirs(str(service_id)).values():
|
|
3410
|
-
if directory.is_symlink():
|
|
3411
|
-
directory.unlink()
|
|
3412
|
-
elif directory.exists():
|
|
3413
|
-
shutil.rmtree(directory)
|
|
3414
|
-
else:
|
|
3415
|
-
continue
|
|
3416
|
-
purged.append(str(directory))
|
|
3417
|
-
return {"unloaded": True, "plistRemoved": plist_removed, "override": "enabled", "purged": purged}
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path] = None) -> List[Path]:
|
|
3421
|
-
"""Keep the release the job runs and the one before it (LC-11 rollback, LC-15); remove
|
|
3422
|
-
older release directories, newest first by modification time. ``current`` (a release or a
|
|
3423
|
-
symlink to one) is never removed, even after a rollback to an older release. Symlinks and
|
|
3424
|
-
files in ``releases_dir`` are left alone. Returns what was removed."""
|
|
3425
|
-
if keep < 2:
|
|
3426
|
-
raise ServiceError("keep at least the current release and the one before it (keep >= 2).")
|
|
3427
|
-
root = Path(releases_dir)
|
|
3428
|
-
if not root.is_dir():
|
|
3429
|
-
return []
|
|
3430
|
-
releases = [p for p in root.iterdir() if p.is_dir() and not p.is_symlink()]
|
|
3431
|
-
releases.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
3432
|
-
keepers: List[Path] = []
|
|
3433
|
-
if current is not None:
|
|
3434
|
-
resolved = Path(current).resolve()
|
|
3435
|
-
keepers.extend(p for p in releases if p.resolve() == resolved)
|
|
3436
|
-
for release in releases:
|
|
3437
|
-
if len(keepers) >= keep:
|
|
3438
|
-
break
|
|
3439
|
-
if release not in keepers:
|
|
3440
|
-
keepers.append(release)
|
|
3441
|
-
removed = [p for p in releases if p not in keepers]
|
|
3442
|
-
for release in removed:
|
|
3443
|
-
shutil.rmtree(release)
|
|
3444
|
-
return removed
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
# --- helpers for input validation -----------------------------------------------
|
|
3448
|
-
|
|
3449
|
-
def _require_id(service_id: str) -> None:
|
|
3450
|
-
if not (isinstance(service_id, str) and _ID.fullmatch(service_id)):
|
|
3451
|
-
raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
__all__ = [name for name in dir() if not name.startswith("_")]
|
|
11
|
+
import os # noqa: F401 - callers reach these through the facade
|
|
12
|
+
import sys # noqa: F401 - callers reach these through the facade
|
|
13
|
+
from pathlib import Path # noqa: F401
|
|
14
|
+
|
|
15
|
+
import sys as _sys # noqa: E402
|
|
16
|
+
from pathlib import Path as _Path # noqa: E402
|
|
17
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
18
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
19
|
+
# Private names 0.39.1 code reached through this module; they live in the core now (FAA-65).
|
|
20
|
+
from fabric_jsvalue import is_number as _number # noqa: E402,F401
|
|
21
|
+
from fabric_time import DAY_MS as _DAY_MS, utc_date_ms as _utc_date_ms # noqa: E402,F401
|
|
22
|
+
from fabric_base import ( # noqa: F401
|
|
23
|
+
PROTOCOL,
|
|
24
|
+
EXTENSION_KEY,
|
|
25
|
+
SUPERVISOR_ENV,
|
|
26
|
+
DRAIN_SECONDS,
|
|
27
|
+
_ID,
|
|
28
|
+
_INSTANCE,
|
|
29
|
+
_LOCAL_PATH,
|
|
30
|
+
_UNIT,
|
|
31
|
+
_TASK,
|
|
32
|
+
WINDOWS,
|
|
33
|
+
_O_BINARY,
|
|
34
|
+
ServiceError,
|
|
35
|
+
now_iso,
|
|
36
|
+
expand,
|
|
37
|
+
_require_id,
|
|
38
|
+
)
|
|
39
|
+
from fabric_platform import ( # noqa: F401
|
|
40
|
+
WINDOWS_TRUSTED,
|
|
41
|
+
token_file_problem,
|
|
42
|
+
SYSTEM_SID,
|
|
43
|
+
ADMINISTRATORS_SID,
|
|
44
|
+
_SID_TEXT,
|
|
45
|
+
_sid,
|
|
46
|
+
windows_token_acl_problem,
|
|
47
|
+
_SDDL_ACE_TYPES,
|
|
48
|
+
_SDDL_RIGHTS,
|
|
49
|
+
_sddl_ace_strings,
|
|
50
|
+
_sddl_mask,
|
|
51
|
+
acl_from_sddl,
|
|
52
|
+
_win_protect,
|
|
53
|
+
_create_first_pipe,
|
|
54
|
+
_close_handle,
|
|
55
|
+
_pipe_exists,
|
|
56
|
+
_win_token_problem,
|
|
57
|
+
)
|
|
58
|
+
import fabric_platform as _fabric_platform # noqa: E402
|
|
59
|
+
for _name in ('ctypes', 'wintypes', '_advapi32', '_kernel32', '_PVOID', '_REPARSE_POINT', '_win_error', '_sid_text', '_canonical_sid', 'windows_user_sid', '_sddl', 'windows_acl', '_INVALID_HANDLE', '_CANONICAL'): # bound on Windows only, as before the split
|
|
60
|
+
if hasattr(_fabric_platform, _name):
|
|
61
|
+
globals()[_name] = getattr(_fabric_platform, _name)
|
|
62
|
+
|
|
63
|
+
from fabric_paths import ( # noqa: F401
|
|
64
|
+
services_dir,
|
|
65
|
+
service_dirs,
|
|
66
|
+
_read_at,
|
|
67
|
+
_local_app_data,
|
|
68
|
+
ensure_private_dir,
|
|
69
|
+
atomic_write,
|
|
70
|
+
)
|
|
71
|
+
from fabric_protocols import ( # noqa: F401
|
|
72
|
+
PROTOCOL_REVISIONS,
|
|
73
|
+
MAX_PROTOCOLS,
|
|
74
|
+
_REVISION,
|
|
75
|
+
parse_revision,
|
|
76
|
+
accepted_revisions,
|
|
77
|
+
negotiate_revision,
|
|
78
|
+
protocols_findings,
|
|
79
|
+
_SCHEMA_URI,
|
|
80
|
+
_scalar,
|
|
81
|
+
feed_data_problems,
|
|
82
|
+
_pointer_token,
|
|
83
|
+
feed_data_findings,
|
|
84
|
+
)
|
|
85
|
+
from fabric_lock import ( # noqa: F401
|
|
86
|
+
EXIT_ALREADY_RUNNING,
|
|
87
|
+
LOCK_WAIT_SUPERVISED_SECONDS,
|
|
88
|
+
LOCK_BACKOFF_FIRST_SECONDS,
|
|
89
|
+
LOCK_BACKOFF_MAX_SECONDS,
|
|
90
|
+
AlreadyRunning,
|
|
91
|
+
InstanceLock,
|
|
92
|
+
lock_name,
|
|
93
|
+
_hold_lock_name,
|
|
94
|
+
_drop_lock_name,
|
|
95
|
+
lock_name_held,
|
|
96
|
+
_read_pid,
|
|
97
|
+
SUPERVISORS,
|
|
98
|
+
SUPERVISOR_ARG,
|
|
99
|
+
_marker_arguments,
|
|
100
|
+
supervisor_marker,
|
|
101
|
+
lock_wait_budget_seconds,
|
|
102
|
+
marker_conflict_message,
|
|
103
|
+
supervised,
|
|
104
|
+
supervised_by_launchd,
|
|
105
|
+
add_supervisor_argument,
|
|
106
|
+
hold_single_instance,
|
|
107
|
+
)
|
|
108
|
+
from fabric_usage import ( # noqa: F401
|
|
109
|
+
ROUTES,
|
|
110
|
+
USAGE_ROUTES,
|
|
111
|
+
USAGE_PATH,
|
|
112
|
+
USAGE_DAYS,
|
|
113
|
+
COST_BASES,
|
|
114
|
+
_PROVIDER,
|
|
115
|
+
make_usage_receipt,
|
|
116
|
+
_TASK_RUN_ID,
|
|
117
|
+
_by_project,
|
|
118
|
+
_add_totals,
|
|
119
|
+
_close_totals,
|
|
120
|
+
_USAGE_WINDOW_DAYS,
|
|
121
|
+
_usage_count,
|
|
122
|
+
_usage_money,
|
|
123
|
+
_usage_cost,
|
|
124
|
+
_exceeds,
|
|
125
|
+
_usd,
|
|
126
|
+
_usage_list,
|
|
127
|
+
_priced_row,
|
|
128
|
+
_MISSING_COST,
|
|
129
|
+
usage_findings,
|
|
130
|
+
OPENROUTER_CATEGORIES,
|
|
131
|
+
_ATTR_PRODUCT,
|
|
132
|
+
_ATTR_TITLE,
|
|
133
|
+
_ATTRIBUTION_HEADERS,
|
|
134
|
+
attribution_setting_findings,
|
|
135
|
+
_is_openrouter,
|
|
136
|
+
attribution_headers,
|
|
137
|
+
usage_report,
|
|
138
|
+
_spent_since,
|
|
139
|
+
_spent_in,
|
|
140
|
+
LIMIT_SCOPES,
|
|
141
|
+
PER_ORDER_KINDS,
|
|
142
|
+
_LIMIT_ID,
|
|
143
|
+
_LIMIT_KIND,
|
|
144
|
+
_LIMIT_FIELDS,
|
|
145
|
+
_TIMESTAMP,
|
|
146
|
+
_is_timestamp,
|
|
147
|
+
usage_limit_problems,
|
|
148
|
+
JsonlUsageLedger,
|
|
149
|
+
)
|
|
150
|
+
from fabric_backups import ( # noqa: F401
|
|
151
|
+
SETTINGS_BACKUP_FORMAT,
|
|
152
|
+
SETTINGS_BACKUP_KEEP,
|
|
153
|
+
SETTINGS_BACKUP_MAX_AGE_SECONDS,
|
|
154
|
+
_STALE_TEMP_SECONDS,
|
|
155
|
+
_SAFE_INTEGER,
|
|
156
|
+
_TABLE,
|
|
157
|
+
_SECRET_TABLE,
|
|
158
|
+
_COLUMN,
|
|
159
|
+
_SECRET_COLUMN,
|
|
160
|
+
_REASON,
|
|
161
|
+
_SHA256,
|
|
162
|
+
_SURROGATE,
|
|
163
|
+
_URI,
|
|
164
|
+
_MAX_CELL_CHARS,
|
|
165
|
+
_MAX_ROW_VALUES,
|
|
166
|
+
_canonical_check,
|
|
167
|
+
settings_canonical_json,
|
|
168
|
+
settings_backup_digest,
|
|
169
|
+
settings_backup_problems,
|
|
170
|
+
settings_backup_root,
|
|
171
|
+
settings_backup_dir,
|
|
172
|
+
make_settings_backup,
|
|
173
|
+
_backup_name,
|
|
174
|
+
settings_backups,
|
|
175
|
+
_no_constant,
|
|
176
|
+
read_settings_backup,
|
|
177
|
+
write_settings_backup,
|
|
178
|
+
backup_due,
|
|
179
|
+
newest_intact_backup,
|
|
180
|
+
restore_plan,
|
|
181
|
+
)
|
|
182
|
+
from fabric_descriptor import ( # noqa: F401
|
|
183
|
+
DASHBOARD_EXTENSION_KEY,
|
|
184
|
+
DASHBOARD_KIT,
|
|
185
|
+
REFERENCE_THEMES,
|
|
186
|
+
STATUSES,
|
|
187
|
+
_ORIGIN,
|
|
188
|
+
_REMOTE_ORIGIN,
|
|
189
|
+
_RESERVED_HOST,
|
|
190
|
+
PLACEMENTS,
|
|
191
|
+
MANAGERS,
|
|
192
|
+
_NOT_LAUNCHD,
|
|
193
|
+
_AUTH_HEADER,
|
|
194
|
+
LoopbackHTTPServer,
|
|
195
|
+
descriptor_path,
|
|
196
|
+
read_descriptors,
|
|
197
|
+
validate_descriptor,
|
|
198
|
+
PEER_DATA_CLASSES,
|
|
199
|
+
CREDENTIAL_KINDS,
|
|
200
|
+
SUBSCRIPTION_RUNNERS,
|
|
201
|
+
_PEER_ENDPOINT,
|
|
202
|
+
_RESIDENCY,
|
|
203
|
+
_RUNNER_KIND_NAME,
|
|
204
|
+
_pointer,
|
|
205
|
+
_text,
|
|
206
|
+
descriptor_member_problems,
|
|
207
|
+
peer_refusal,
|
|
208
|
+
app_descriptor_action,
|
|
209
|
+
_MISSING_APP,
|
|
210
|
+
app_descriptor_stale,
|
|
211
|
+
app_present,
|
|
212
|
+
FABRIC_SERVICE,
|
|
213
|
+
fabric_button_presence,
|
|
214
|
+
placement_of,
|
|
215
|
+
remote_origin_problems,
|
|
216
|
+
port_of,
|
|
217
|
+
write_descriptor,
|
|
218
|
+
remove_descriptor,
|
|
219
|
+
build_info,
|
|
220
|
+
_THEME_ID,
|
|
221
|
+
_KIT_REF,
|
|
222
|
+
dashboard_extension_problems,
|
|
223
|
+
dashboard_extension,
|
|
224
|
+
_PROFILE_ID,
|
|
225
|
+
_RUNNER_KIND,
|
|
226
|
+
_MODEL_ID,
|
|
227
|
+
configuration_problems,
|
|
228
|
+
build_well_known,
|
|
229
|
+
)
|
|
230
|
+
from fabric_auth import ( # noqa: F401
|
|
231
|
+
LOGIN_CODE_TTL_SECONDS,
|
|
232
|
+
OPERATOR_SESSION_IDLE_SECONDS,
|
|
233
|
+
OPERATOR_SESSION_MAX_SECONDS,
|
|
234
|
+
_CODE,
|
|
235
|
+
ensure_token,
|
|
236
|
+
read_token,
|
|
237
|
+
token_matches,
|
|
238
|
+
check_request,
|
|
239
|
+
token_header,
|
|
240
|
+
LoginCodes,
|
|
241
|
+
_session_bound,
|
|
242
|
+
MemoryCodeStore,
|
|
243
|
+
SESSION_COOKIE,
|
|
244
|
+
REMOTE_SESSION_COOKIE,
|
|
245
|
+
session_cookie_header,
|
|
246
|
+
remote_session_cookie_header,
|
|
247
|
+
expired_session_cookie_header,
|
|
248
|
+
check_remote_request,
|
|
249
|
+
well_known_allowed,
|
|
250
|
+
remote_token_file,
|
|
251
|
+
register_remote,
|
|
252
|
+
cookie_value,
|
|
253
|
+
)
|
|
254
|
+
from fabric_events import ( # noqa: F401
|
|
255
|
+
EVENTS_DEFAULT_LIMIT,
|
|
256
|
+
EVENTS_MAX_LIMIT,
|
|
257
|
+
LEVELS,
|
|
258
|
+
_KIND,
|
|
259
|
+
_TRACE_ID,
|
|
260
|
+
_SPAN_ID,
|
|
261
|
+
make_event,
|
|
262
|
+
parse_limit,
|
|
263
|
+
events_page,
|
|
264
|
+
JsonlEventLog,
|
|
265
|
+
)
|
|
266
|
+
from fabric_logs import ( # noqa: F401
|
|
267
|
+
DRAIN_GRACE_SECONDS,
|
|
268
|
+
EXIT_HARD_STOP,
|
|
269
|
+
LOG_MAX_BYTES,
|
|
270
|
+
LOG_BACKUPS,
|
|
271
|
+
RotatingLog,
|
|
272
|
+
cap_stdout_log,
|
|
273
|
+
Stopping,
|
|
274
|
+
Drain,
|
|
275
|
+
)
|
|
276
|
+
from fabric_supervisors import ( # noqa: F401
|
|
277
|
+
DEFAULT_EXIT_TIMEOUT,
|
|
278
|
+
_no_marker_argument,
|
|
279
|
+
_no_secret_in_supervisor_file,
|
|
280
|
+
_plain,
|
|
281
|
+
_systemd_quote,
|
|
282
|
+
systemd_unit,
|
|
283
|
+
_systemd_dir,
|
|
284
|
+
_check,
|
|
285
|
+
systemd_install,
|
|
286
|
+
systemd_uninstall,
|
|
287
|
+
_xml,
|
|
288
|
+
task_xml,
|
|
289
|
+
_TASK_DISABLED,
|
|
290
|
+
task_install,
|
|
291
|
+
task_uninstall,
|
|
292
|
+
launchd_plist,
|
|
293
|
+
_launchctl,
|
|
294
|
+
_domain,
|
|
295
|
+
launchd_loaded,
|
|
296
|
+
fetch_well_known,
|
|
297
|
+
_OVERRIDE_ROW,
|
|
298
|
+
launchd_override,
|
|
299
|
+
launchd_install,
|
|
300
|
+
launchd_uninstall,
|
|
301
|
+
prune_releases,
|
|
302
|
+
)
|