@passioncode-ai/fabric-agent-adapter 0.17.0 → 0.19.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 +108 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +13 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +9 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +70 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +782 -25
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +843 -15
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -18,13 +18,16 @@ import hashlib
|
|
|
18
18
|
import hmac
|
|
19
19
|
import http.server
|
|
20
20
|
import json
|
|
21
|
+
import ntpath
|
|
21
22
|
import os
|
|
22
|
-
from pathlib import Path
|
|
23
|
+
from pathlib import Path, PurePath
|
|
23
24
|
import plistlib
|
|
25
|
+
import posixpath
|
|
24
26
|
import re
|
|
25
27
|
import secrets
|
|
26
28
|
import shutil
|
|
27
29
|
import signal
|
|
30
|
+
import socket
|
|
28
31
|
import socketserver
|
|
29
32
|
import stat
|
|
30
33
|
import subprocess
|
|
@@ -87,6 +90,7 @@ _SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
|
87
90
|
_LOCAL_PATH = re.compile(r"^(~[\\/]|/(?!/)|[A-Za-z]:[\\/])")
|
|
88
91
|
# DEC-0032: the supervisor per system and the field that names its job.
|
|
89
92
|
MANAGERS = ("launchd", "systemd", "task-scheduler", "none")
|
|
93
|
+
_AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
|
|
90
94
|
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
|
|
91
95
|
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
|
|
92
96
|
WINDOWS = os.name == "nt"
|
|
@@ -185,15 +189,15 @@ def atomic_write(path: Path, data: bytes, mode: int = 0o600) -> None:
|
|
|
185
189
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
186
190
|
fd, tmp = tempfile.mkstemp(prefix="." + path.name + ".", dir=str(path.parent))
|
|
187
191
|
try:
|
|
192
|
+
if WINDOWS:
|
|
193
|
+
# The file's ACL is its privacy (DEC-0032): set explicitly and protected — before the bytes go in — so
|
|
194
|
+
# it does not depend on what the folder hands down. A rename keeps it.
|
|
195
|
+
_win_protect(Path(tmp), folder=False)
|
|
188
196
|
with os.fdopen(fd, "wb") as handle:
|
|
189
197
|
handle.write(data)
|
|
190
198
|
handle.flush()
|
|
191
199
|
os.fsync(handle.fileno())
|
|
192
|
-
if WINDOWS:
|
|
193
|
-
# The file's ACL is its privacy (DEC-0032): set explicitly and protected, so it does
|
|
194
|
-
# not depend on what the folder hands down. A rename keeps it.
|
|
195
|
-
_win_protect(Path(tmp), folder=False)
|
|
196
|
-
else:
|
|
200
|
+
if not WINDOWS:
|
|
197
201
|
os.chmod(tmp, mode)
|
|
198
202
|
os.replace(tmp, path)
|
|
199
203
|
except BaseException:
|
|
@@ -217,6 +221,7 @@ class InstanceLock:
|
|
|
217
221
|
def __init__(self, data_dir: Path):
|
|
218
222
|
self.path = Path(data_dir) / "service.lock"
|
|
219
223
|
self._fd: Optional[int] = None
|
|
224
|
+
self._name: Any = None
|
|
220
225
|
|
|
221
226
|
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
222
227
|
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
@@ -242,6 +247,11 @@ class InstanceLock:
|
|
|
242
247
|
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
243
248
|
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
244
249
|
raise
|
|
250
|
+
try:
|
|
251
|
+
self._name = _hold_lock_name(self.path)
|
|
252
|
+
except BaseException:
|
|
253
|
+
os.close(fd)
|
|
254
|
+
raise
|
|
245
255
|
os.ftruncate(fd, 0)
|
|
246
256
|
os.write(fd, str(os.getpid()).encode())
|
|
247
257
|
os.fsync(fd)
|
|
@@ -252,6 +262,70 @@ class InstanceLock:
|
|
|
252
262
|
if self._fd is not None:
|
|
253
263
|
os.close(self._fd)
|
|
254
264
|
self._fd = None
|
|
265
|
+
if self._name is not None:
|
|
266
|
+
_drop_lock_name(self._name)
|
|
267
|
+
self._name = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# The Node kit cannot flock on Linux or lock a byte range on Windows, so it holds a name instead: an
|
|
271
|
+
# abstract unix socket on Linux, a named pipe on Windows — exclusive, no file, gone with the process.
|
|
272
|
+
# This kit holds the same name beside its file lock, so the two kits exclude each other everywhere
|
|
273
|
+
# and the probe sees either one (`lock_name_held`).
|
|
274
|
+
|
|
275
|
+
def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
|
|
276
|
+
"""The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
|
|
277
|
+
if platform.startswith("linux"):
|
|
278
|
+
digest = hashlib.sha256(posixpath.abspath(str(lock_path)).encode()).hexdigest()[:32]
|
|
279
|
+
return "\0fabric-service-" + digest
|
|
280
|
+
if platform == "win32":
|
|
281
|
+
# Windows paths compare without case: C:\Users and c:\users are one lock.
|
|
282
|
+
digest = hashlib.sha256(ntpath.normcase(ntpath.abspath(str(lock_path))).encode()).hexdigest()[:32]
|
|
283
|
+
return "\\\\.\\pipe\\fabric-service-" + digest
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _hold_lock_name(lock_path: Path) -> Any:
|
|
288
|
+
name = lock_name(lock_path)
|
|
289
|
+
if name is None:
|
|
290
|
+
return None
|
|
291
|
+
if WINDOWS:
|
|
292
|
+
handle = _create_first_pipe(name)
|
|
293
|
+
if handle is None:
|
|
294
|
+
raise AlreadyRunning(_read_pid(lock_path), lock_path)
|
|
295
|
+
return handle
|
|
296
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
297
|
+
try:
|
|
298
|
+
sock.bind(name)
|
|
299
|
+
except OSError as exc:
|
|
300
|
+
sock.close()
|
|
301
|
+
if exc.errno == errno.EADDRINUSE:
|
|
302
|
+
raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
|
|
303
|
+
raise
|
|
304
|
+
# Held by its descriptor, like the file lock: a socket object would close when collected.
|
|
305
|
+
return sock.detach()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _drop_lock_name(held: Any) -> None:
|
|
309
|
+
if WINDOWS:
|
|
310
|
+
_close_handle(held)
|
|
311
|
+
else:
|
|
312
|
+
os.close(held)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def lock_name_held(lock_path: Path) -> Optional[bool]:
|
|
316
|
+
"""True when some process holds the lock's name (a running service of either kit); None where there is no name
|
|
317
|
+
to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
|
|
318
|
+
probe cannot make a service that is starting at that moment fail."""
|
|
319
|
+
name = lock_name(lock_path)
|
|
320
|
+
if name is None:
|
|
321
|
+
return None
|
|
322
|
+
if WINDOWS:
|
|
323
|
+
return _pipe_exists(name)
|
|
324
|
+
try:
|
|
325
|
+
with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
|
|
326
|
+
return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
|
|
327
|
+
except OSError:
|
|
328
|
+
return None
|
|
255
329
|
|
|
256
330
|
|
|
257
331
|
def _read_pid(path: Path) -> Optional[int]:
|
|
@@ -262,6 +336,15 @@ def _read_pid(path: Path) -> Optional[int]:
|
|
|
262
336
|
return None
|
|
263
337
|
|
|
264
338
|
|
|
339
|
+
SUPERVISORS = ("launchd", "systemd", "task-scheduler")
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def supervised() -> bool:
|
|
343
|
+
"""True when a supervisor that restarts on exit started this process and said so in
|
|
344
|
+
``FABRIC_SERVICE_SUPERVISOR`` (``launchd_plist`` and ``systemd_unit`` set it)."""
|
|
345
|
+
return os.environ.get(SUPERVISOR_ENV) in SUPERVISORS
|
|
346
|
+
|
|
347
|
+
|
|
265
348
|
def supervised_by_launchd() -> bool:
|
|
266
349
|
"""True when launchd started this process from a plist written by ``launchd_plist``.
|
|
267
350
|
|
|
@@ -281,7 +364,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
281
364
|
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
282
365
|
``wait_seconds`` overrides the choice (0 = never wait)."""
|
|
283
366
|
if wait_seconds is None:
|
|
284
|
-
wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if
|
|
367
|
+
wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised() else 0.0
|
|
285
368
|
deadline = clock() + max(0.0, wait_seconds)
|
|
286
369
|
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
287
370
|
announced = False
|
|
@@ -440,6 +523,35 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
|
440
523
|
finally:
|
|
441
524
|
_kernel32.LocalFree(sd)
|
|
442
525
|
|
|
526
|
+
_kernel32.CreateNamedPipeW.argtypes = [wintypes.LPCWSTR] + [wintypes.DWORD] * 6 + [_PVOID]
|
|
527
|
+
_kernel32.CreateNamedPipeW.restype = wintypes.HANDLE
|
|
528
|
+
_INVALID_HANDLE = wintypes.HANDLE(-1).value
|
|
529
|
+
|
|
530
|
+
def _create_first_pipe(name: str) -> Any:
|
|
531
|
+
"""The pipe's first instance, or None when another process holds it (ERROR_ACCESS_DENIED)."""
|
|
532
|
+
# PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE; PIPE_REJECT_REMOTE_CLIENTS; unlimited instances,
|
|
533
|
+
# as libuv creates them: then a second first-instance attempt, ours or the Node kit's, is refused with
|
|
534
|
+
# ERROR_ACCESS_DENIED (Node: EADDRINUSE), never ERROR_PIPE_BUSY.
|
|
535
|
+
handle = _kernel32.CreateNamedPipeW(name, 0x1 | 0x00080000, 0x8, 255, 0, 0, 0, None)
|
|
536
|
+
if handle == _INVALID_HANDLE:
|
|
537
|
+
code = ctypes.get_last_error()
|
|
538
|
+
if code in (5, 231): # ERROR_ACCESS_DENIED, ERROR_PIPE_BUSY: another process holds it
|
|
539
|
+
return None
|
|
540
|
+
raise _win_error("CreateNamedPipeW", code)
|
|
541
|
+
return handle
|
|
542
|
+
|
|
543
|
+
def _close_handle(handle: Any) -> None:
|
|
544
|
+
_kernel32.CloseHandle(handle)
|
|
545
|
+
|
|
546
|
+
_kernel32.WaitNamedPipeW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD]
|
|
547
|
+
_kernel32.WaitNamedPipeW.restype = wintypes.BOOL
|
|
548
|
+
|
|
549
|
+
def _pipe_exists(name: str) -> bool:
|
|
550
|
+
"""Some process holds the pipe: waiting for it does not fail with ERROR_FILE_NOT_FOUND. Nothing connects."""
|
|
551
|
+
if _kernel32.WaitNamedPipeW(name, 1):
|
|
552
|
+
return True
|
|
553
|
+
return ctypes.get_last_error() != 2
|
|
554
|
+
|
|
443
555
|
def _win_token_problem(path: Path, info: os.stat_result) -> Optional[str]:
|
|
444
556
|
if getattr(info, "st_file_attributes", 0) & _REPARSE_POINT or not stat.S_ISREG(info.st_mode):
|
|
445
557
|
return "Token file %s is not a regular file (a link or junction); refusing it." % path
|
|
@@ -450,6 +562,15 @@ if WINDOWS: # pragma: no cover - exercised by the Windows CI job
|
|
|
450
562
|
owner, granting = windows_acl(path)
|
|
451
563
|
return token_acl_problem(path, owner, granting, windows_user_sid())
|
|
452
564
|
else:
|
|
565
|
+
def _create_first_pipe(name: str) -> Any: # noqa: ARG001
|
|
566
|
+
raise ServiceError("named pipes exist only on Windows")
|
|
567
|
+
|
|
568
|
+
def _close_handle(handle: Any) -> None: # noqa: ARG001
|
|
569
|
+
raise ServiceError("handles exist only on Windows")
|
|
570
|
+
|
|
571
|
+
def _pipe_exists(name: str) -> bool: # noqa: ARG001
|
|
572
|
+
raise ServiceError("named pipes exist only on Windows")
|
|
573
|
+
|
|
453
574
|
def _win_protect(path: Path, folder: bool) -> None: # noqa: ARG001 - POSIX uses modes
|
|
454
575
|
raise ServiceError("Windows ACLs exist only on Windows")
|
|
455
576
|
|
|
@@ -589,6 +710,10 @@ def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
|
589
710
|
problems.append("paths must be local paths, never a network share: %r" % value)
|
|
590
711
|
header = auth.get("header", "Authorization")
|
|
591
712
|
scheme = auth.get("scheme", "Bearer")
|
|
713
|
+
if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
|
|
714
|
+
problems.append("auth.header must match %s" % _AUTH_HEADER.pattern)
|
|
715
|
+
if scheme not in ("Bearer", "none"):
|
|
716
|
+
problems.append("auth.scheme is Bearer or none")
|
|
592
717
|
if header != "Authorization" and scheme != "none":
|
|
593
718
|
problems.append("a custom auth header carries the raw token: scheme must be none")
|
|
594
719
|
life = descriptor.get("lifecycle") or {}
|
|
@@ -982,11 +1107,19 @@ def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
982
1107
|
|
|
983
1108
|
def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
|
|
984
1109
|
now: Optional[_dt.datetime] = None,
|
|
985
|
-
budget: Optional[Dict[str, Any]] = None
|
|
1110
|
+
budget: Optional[Dict[str, Any]] = None,
|
|
1111
|
+
budgets: Optional[Sequence[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
986
1112
|
"""The `service-usage.schema.json` answer: the last 31 UTC days, oldest first, per provider
|
|
987
1113
|
and model. Days without calls are omitted. A row whose calls are all unpriced has
|
|
988
|
-
`costUsd: None`; totals are the sums of the model rows (FAC-SEM-025).
|
|
1114
|
+
`costUsd: None`; totals are the sums of the model rows (FAC-SEM-025).
|
|
1115
|
+
|
|
1116
|
+
``budgets`` (DEC-0027) lists every spending limit the service applies, enforced or not. A
|
|
1117
|
+
machine limit with a calendar ``period`` and no ``spentUsd`` is counted here from the receipts,
|
|
1118
|
+
the way ``budget`` is, so the two never disagree; a per-order limit (``per_job``, ``approval``)
|
|
1119
|
+
gets ``spentUsd: None``; every other limit states its own spend. A report whose limits break
|
|
1120
|
+
FAC-SEM-031 is refused (``usage_limit_problems``)."""
|
|
989
1121
|
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
1122
|
+
receipts = list(receipts)
|
|
990
1123
|
first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
|
|
991
1124
|
last = now.strftime("%Y-%m-%d")
|
|
992
1125
|
days: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
@@ -1025,14 +1158,528 @@ def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instanc
|
|
|
1025
1158
|
if budget is not None:
|
|
1026
1159
|
if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
|
|
1027
1160
|
raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1161
|
+
report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]),
|
|
1162
|
+
"spentUsd": _spent_in(out_days, budget["period"], last)}
|
|
1163
|
+
if budgets is not None:
|
|
1164
|
+
limits = []
|
|
1165
|
+
for given in budgets:
|
|
1166
|
+
if not isinstance(given, dict):
|
|
1167
|
+
raise ServiceError("each limit in budgets is an object")
|
|
1168
|
+
limit = dict(given)
|
|
1169
|
+
if "spentUsd" not in limit:
|
|
1170
|
+
if limit.get("kind") in PER_ORDER_KINDS:
|
|
1171
|
+
limit["spentUsd"] = None
|
|
1172
|
+
elif limit.get("scope") == "machine" and limit.get("period") in ("day", "month"):
|
|
1173
|
+
limit["spentUsd"] = _spent_in(out_days, limit["period"], last)
|
|
1174
|
+
elif limit.get("scope") == "machine" and _number(limit.get("windowSeconds")):
|
|
1175
|
+
limit["spentUsd"] = _spent_since(receipts, now - _dt.timedelta(seconds=limit["windowSeconds"]), now)
|
|
1176
|
+
if isinstance(limit.get("limitUsd"), int) and not isinstance(limit.get("limitUsd"), bool):
|
|
1177
|
+
limit["limitUsd"] = float(limit["limitUsd"])
|
|
1178
|
+
limits.append(limit)
|
|
1179
|
+
report["budgets"] = limits
|
|
1180
|
+
problems = usage_limit_problems(report)
|
|
1181
|
+
if problems:
|
|
1182
|
+
raise ServiceError("the report's limits are not coherent: " + "; ".join(problems))
|
|
1033
1183
|
return report
|
|
1034
1184
|
|
|
1035
1185
|
|
|
1186
|
+
def _spent_since(receipts: List[Dict[str, Any]], start: _dt.datetime, end: _dt.datetime) -> Optional[float]:
|
|
1187
|
+
"""Spend in a rolling window, from the receipts' own times; None when every call in it is unpriced."""
|
|
1188
|
+
lo, hi = start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1189
|
+
inside = [r for r in receipts if lo < str(r.get("at", "")) <= hi]
|
|
1190
|
+
if inside and all(r.get("costUsd") is None for r in inside):
|
|
1191
|
+
return None
|
|
1192
|
+
return round(sum(r.get("costUsd") or 0.0 for r in inside), 6)
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _spent_in(out_days: List[Dict[str, Any]], period: str, last: str) -> Optional[float]:
|
|
1196
|
+
"""Spend in the current UTC day or month; None when every call in it is unpriced."""
|
|
1197
|
+
prefix = last if period == "day" else last[:7]
|
|
1198
|
+
window = [d for d in out_days if d["date"].startswith(prefix)]
|
|
1199
|
+
unknown = bool(window) and all(d["costUsd"] is None for d in window)
|
|
1200
|
+
return None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
# --- every spending limit (DEC-0027, FAC-SEM-031) ---------------------------------
|
|
1204
|
+
|
|
1205
|
+
LIMIT_SCOPES = ("machine", "project", "pool", "job")
|
|
1206
|
+
PER_ORDER_KINDS = ("per_job", "approval")
|
|
1207
|
+
_LIMIT_ID = re.compile(r"^[a-z0-9][a-z0-9._:-]{0,63}$")
|
|
1208
|
+
_LIMIT_KIND = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
|
|
1209
|
+
_LIMIT_FIELDS = ("id", "scope", "subject", "kind", "period", "windowSeconds", "since", "limitUsd", "spentUsd", "enforced", "tripped")
|
|
1210
|
+
_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}))")
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _is_timestamp(value: Any) -> bool:
|
|
1214
|
+
"""A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset."""
|
|
1215
|
+
m = _TIMESTAMP.fullmatch(value) if isinstance(value, str) else None
|
|
1216
|
+
if not m:
|
|
1217
|
+
return False
|
|
1218
|
+
year, month, day, hour, minute, second = (int(m.group(i)) for i in range(1, 7))
|
|
1219
|
+
try:
|
|
1220
|
+
_dt.date(year, month, day)
|
|
1221
|
+
except ValueError:
|
|
1222
|
+
return False
|
|
1223
|
+
offset_ok = m.group(9) is None or (int(m.group(9)) <= 23 and int(m.group(10)) <= 59)
|
|
1224
|
+
return hour <= 23 and minute <= 59 and second <= 60 and offset_ok
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def _number(value: Any) -> bool:
|
|
1228
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def usage_limit_problems(report: Dict[str, Any]) -> List[str]:
|
|
1232
|
+
"""The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031: unique
|
|
1233
|
+
ids, a subject exactly for project and pool scope, one window that agrees with the kind, per-order limits with
|
|
1234
|
+
no window and no spend, only a relative limit with a null `limitUsd`, a tripped limit enforced, and a legacy
|
|
1235
|
+
`budget` that repeats one enforced machine limit. A breach (spent above the limit) is not a problem: a report
|
|
1236
|
+
truthfully shows one, and a host shows it first. An unknown kind is never refused."""
|
|
1237
|
+
problems: List[str] = []
|
|
1238
|
+
limits = report.get("budgets")
|
|
1239
|
+
if limits is None:
|
|
1240
|
+
return problems
|
|
1241
|
+
if not isinstance(limits, list) or len(limits) > 64:
|
|
1242
|
+
return ["budgets is a list of at most 64 limits"]
|
|
1243
|
+
seen = set()
|
|
1244
|
+
for i, limit in enumerate(limits):
|
|
1245
|
+
at = "/budgets/%d" % i
|
|
1246
|
+
if not isinstance(limit, dict):
|
|
1247
|
+
problems.append("%s is not an object" % at)
|
|
1248
|
+
continue
|
|
1249
|
+
for key in limit:
|
|
1250
|
+
if key not in _LIMIT_FIELDS:
|
|
1251
|
+
problems.append("%s/%s is not a limit field" % (at, key))
|
|
1252
|
+
for key in ("id", "scope", "kind", "limitUsd", "spentUsd", "enforced"):
|
|
1253
|
+
if key not in limit:
|
|
1254
|
+
problems.append("%s is missing %s" % (at, key))
|
|
1255
|
+
if "id" in limit and not (isinstance(limit["id"], str) and _LIMIT_ID.fullmatch(limit["id"])):
|
|
1256
|
+
problems.append("%s/id must match %s" % (at, _LIMIT_ID.pattern))
|
|
1257
|
+
if "scope" in limit and limit["scope"] not in LIMIT_SCOPES:
|
|
1258
|
+
problems.append("%s/scope must be one of %s" % (at, ", ".join(LIMIT_SCOPES)))
|
|
1259
|
+
if "subject" in limit and not (isinstance(limit["subject"], str) and 1 <= len(limit["subject"]) <= 128):
|
|
1260
|
+
problems.append("%s/subject is 1 to 128 characters" % at)
|
|
1261
|
+
if "kind" in limit and not (isinstance(limit["kind"], str) and _LIMIT_KIND.fullmatch(limit["kind"])):
|
|
1262
|
+
problems.append("%s/kind must match %s" % (at, _LIMIT_KIND.pattern))
|
|
1263
|
+
if "period" in limit and limit["period"] not in ("day", "month"):
|
|
1264
|
+
problems.append("%s/period is day or month" % at)
|
|
1265
|
+
if "windowSeconds" in limit and not (isinstance(limit["windowSeconds"], int) and not isinstance(limit["windowSeconds"], bool)
|
|
1266
|
+
and limit["windowSeconds"] >= 1):
|
|
1267
|
+
problems.append("%s/windowSeconds is a whole number of seconds, at least 1" % at)
|
|
1268
|
+
if "since" in limit and not _is_timestamp(limit["since"]):
|
|
1269
|
+
problems.append("%s/since is a UTC timestamp" % at)
|
|
1270
|
+
if "limitUsd" in limit and not (limit["limitUsd"] is None or (_number(limit["limitUsd"]) and limit["limitUsd"] > 0)):
|
|
1271
|
+
problems.append("%s/limitUsd is a dollar amount above zero, or null" % at)
|
|
1272
|
+
if "spentUsd" in limit and not (limit["spentUsd"] is None or (_number(limit["spentUsd"]) and limit["spentUsd"] >= 0)):
|
|
1273
|
+
problems.append("%s/spentUsd is a dollar amount, zero or more, or null" % at)
|
|
1274
|
+
for key in ("enforced", "tripped"):
|
|
1275
|
+
if key in limit and not isinstance(limit[key], bool):
|
|
1276
|
+
problems.append("%s/%s is true or false" % (at, key))
|
|
1277
|
+
|
|
1278
|
+
# FAC-SEM-031
|
|
1279
|
+
ident = str(limit.get("id", ""))
|
|
1280
|
+
if ident in seen:
|
|
1281
|
+
problems.append("FAC-SEM-031 %s/id: limit id %s appears twice" % (at, ident))
|
|
1282
|
+
seen.add(ident)
|
|
1283
|
+
scope = limit.get("scope")
|
|
1284
|
+
named = scope in ("project", "pool")
|
|
1285
|
+
if named and "subject" not in limit:
|
|
1286
|
+
problems.append("FAC-SEM-031 %s/subject: a %s limit names its %s in subject" % (at, scope, scope))
|
|
1287
|
+
if not named and "subject" in limit:
|
|
1288
|
+
problems.append("FAC-SEM-031 %s/subject: a %s limit has no subject" % (at, scope))
|
|
1289
|
+
windows = [f for f in ("period", "windowSeconds", "since") if f in limit]
|
|
1290
|
+
if len(windows) > 1:
|
|
1291
|
+
problems.append("FAC-SEM-031 %s/%s: a limit has one window, not %s" % (at, windows[1], " and ".join(windows)))
|
|
1292
|
+
kind = str(limit.get("kind", ""))
|
|
1293
|
+
seconds = limit.get("windowSeconds") if _number(limit.get("windowSeconds")) else None
|
|
1294
|
+
if kind in PER_ORDER_KINDS:
|
|
1295
|
+
if windows:
|
|
1296
|
+
problems.append("FAC-SEM-031 %s/%s: %s bounds one order and has no window" % (at, windows[0], kind))
|
|
1297
|
+
if limit.get("spentUsd", 0) is not None:
|
|
1298
|
+
problems.append("FAC-SEM-031 %s/spentUsd: %s bounds one order, so nothing is spent against it (null)" % (at, kind))
|
|
1299
|
+
elif kind == "daily":
|
|
1300
|
+
if not (limit.get("period") == "day" or seconds == 86400):
|
|
1301
|
+
problems.append("FAC-SEM-031 %s/period: a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)" % at)
|
|
1302
|
+
elif kind == "monthly":
|
|
1303
|
+
if not (limit.get("period") == "month" or (seconds is not None and 28 * 86400 <= seconds <= 31 * 86400)):
|
|
1304
|
+
problems.append("FAC-SEM-031 %s/period: a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)" % at)
|
|
1305
|
+
elif kind == "velocity":
|
|
1306
|
+
if seconds is None:
|
|
1307
|
+
problems.append("FAC-SEM-031 %s/windowSeconds: a velocity limit counts a rolling window (windowSeconds)" % at)
|
|
1308
|
+
elif kind in ("pool", "emergency"):
|
|
1309
|
+
if not windows:
|
|
1310
|
+
problems.append("FAC-SEM-031 %s/period: a %s limit names the window it counts (period, windowSeconds or since)" % (at, kind))
|
|
1311
|
+
known = kind in PER_ORDER_KINDS or kind in ("daily", "monthly", "pool", "emergency")
|
|
1312
|
+
if known and "limitUsd" in limit and limit["limitUsd"] is None:
|
|
1313
|
+
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))
|
|
1314
|
+
if limit.get("tripped") is True and limit.get("enforced") is not True:
|
|
1315
|
+
problems.append("FAC-SEM-031 %s/tripped: a limit that is not enforced cannot have stopped work" % at)
|
|
1316
|
+
|
|
1317
|
+
budget = report.get("budget")
|
|
1318
|
+
if isinstance(budget, dict) and limits:
|
|
1319
|
+
kind = "daily" if budget.get("period") == "day" else "monthly"
|
|
1320
|
+
|
|
1321
|
+
def same(x: Any, y: Any) -> bool:
|
|
1322
|
+
if x is None or y is None:
|
|
1323
|
+
return x is y
|
|
1324
|
+
return _number(x) and _number(y) and abs(x - y) <= 0.000001
|
|
1325
|
+
|
|
1326
|
+
twin = any(isinstance(l, dict) and l.get("scope") == "machine" and l.get("enforced") is True and l.get("kind") == kind
|
|
1327
|
+
and l.get("period") == budget.get("period") and same(l.get("limitUsd"), budget.get("limitUsd"))
|
|
1328
|
+
and same(l.get("spentUsd"), budget.get("spentUsd")) for l in limits)
|
|
1329
|
+
if not twin:
|
|
1330
|
+
problems.append("FAC-SEM-031 /budget: budget repeats one enforced machine %s limit with period %s in budgets[], so old "
|
|
1331
|
+
"and new readers agree" % (kind, budget.get("period")))
|
|
1332
|
+
return problems
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
# --- the feed client (DEC-0025, FAC-SEM-036) --------------------------------------
|
|
1336
|
+
|
|
1337
|
+
def token_header(auth: Any, token: str) -> Tuple[str, str]:
|
|
1338
|
+
"""The one header a client sends the service token in, as the descriptor's ``auth`` declares it: ``header``
|
|
1339
|
+
defaults to Authorization and ``scheme`` to Bearer; ``none`` carries the raw token. Never assume
|
|
1340
|
+
``Authorization: Bearer`` (a feed client that did was refused on every poll and dropped every notify event)."""
|
|
1341
|
+
declared = auth if isinstance(auth, dict) else {}
|
|
1342
|
+
name = declared.get("header") if isinstance(declared.get("header"), str) else "Authorization"
|
|
1343
|
+
scheme = declared.get("scheme") if isinstance(declared.get("scheme"), str) else "Bearer"
|
|
1344
|
+
return name, token if scheme == "none" else "Bearer " + token
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
# --- settings backups (DEC-0025, FAC-SEM-035) -------------------------------------
|
|
1348
|
+
|
|
1349
|
+
SETTINGS_BACKUP_FORMAT = "fabric-settings-backup/1"
|
|
1350
|
+
SETTINGS_BACKUP_KEEP = 14
|
|
1351
|
+
SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
1352
|
+
_STALE_TEMP_SECONDS = 60 * 60
|
|
1353
|
+
_SAFE_INTEGER = 2 ** 53 - 1
|
|
1354
|
+
_TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
1355
|
+
_SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$")
|
|
1356
|
+
_COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
|
|
1357
|
+
_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?$")
|
|
1358
|
+
_REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
1359
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
1360
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
1361
|
+
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S*")
|
|
1362
|
+
_MAX_CELL_CHARS = 1048576
|
|
1363
|
+
_MAX_ROW_VALUES = 512
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _canonical_check(value: Any, at: str = "") -> None:
|
|
1367
|
+
if value is None or isinstance(value, (bool, str)):
|
|
1368
|
+
return
|
|
1369
|
+
if isinstance(value, int):
|
|
1370
|
+
if abs(value) > _SAFE_INTEGER:
|
|
1371
|
+
raise ServiceError("%s: %d is outside ±(2^53 − 1) and has no canonical form; store it as a string" % (at or "/", value))
|
|
1372
|
+
return
|
|
1373
|
+
if isinstance(value, float):
|
|
1374
|
+
raise ServiceError("%s: a fraction has no canonical form; a settings backup stores it as its decimal string" % (at or "/"))
|
|
1375
|
+
if isinstance(value, list):
|
|
1376
|
+
for i, item in enumerate(value):
|
|
1377
|
+
_canonical_check(item, "%s/%d" % (at, i))
|
|
1378
|
+
return
|
|
1379
|
+
if isinstance(value, dict):
|
|
1380
|
+
for key, item in value.items():
|
|
1381
|
+
if not isinstance(key, str):
|
|
1382
|
+
raise ServiceError("%s: object keys are strings" % (at or "/"))
|
|
1383
|
+
_canonical_check(item, "%s/%s" % (at, key))
|
|
1384
|
+
return
|
|
1385
|
+
raise ServiceError("%s: %s is not a JSON value" % (at or "/", type(value).__name__))
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def settings_canonical_json(value: Any) -> str:
|
|
1389
|
+
"""The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, ", " and ": ",
|
|
1390
|
+
strings escaped as JSON requires and nothing else, integers in decimal — Python's
|
|
1391
|
+
``json.dumps(value, sort_keys=True, ensure_ascii=False)``. Refuses a fraction, an integer outside ±(2^53 − 1)
|
|
1392
|
+
and anything that is not JSON."""
|
|
1393
|
+
_canonical_check(value)
|
|
1394
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def settings_backup_digest(tables: Any) -> str:
|
|
1398
|
+
"""The ``sha256`` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of ``tables``."""
|
|
1399
|
+
return hashlib.sha256(settings_canonical_json(tables).encode("utf-8")).hexdigest()
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def settings_backup_problems(doc: Any) -> List[str]:
|
|
1403
|
+
"""Why a reader refuses this settings backup: its shape (``settings-backup.schema.json``) and FAC-SEM-035 —
|
|
1404
|
+
the checksum is the digest of the tables, counts name exactly the tables and their rows, every row is as wide as
|
|
1405
|
+
its columns, and every value has a canonical form. Empty means intact."""
|
|
1406
|
+
if not isinstance(doc, dict):
|
|
1407
|
+
return ["a settings backup is a JSON object"]
|
|
1408
|
+
problems: List[str] = []
|
|
1409
|
+
if doc.get("format") != SETTINGS_BACKUP_FORMAT:
|
|
1410
|
+
problems.append("format must be %s, not %r: a reader refuses a format it does not know" % (SETTINGS_BACKUP_FORMAT, doc.get("format")))
|
|
1411
|
+
for key in ("format", "service", "created_at", "reason", "sha256", "counts", "tables"):
|
|
1412
|
+
if key not in doc:
|
|
1413
|
+
problems.append("missing %s" % key)
|
|
1414
|
+
for key in doc:
|
|
1415
|
+
if key not in ("format", "service", "created_at", "reason", "sha256", "counts", "tables", "extensions"):
|
|
1416
|
+
problems.append("%s is not a settings backup field" % key)
|
|
1417
|
+
if "extensions" in doc:
|
|
1418
|
+
if not isinstance(doc["extensions"], dict):
|
|
1419
|
+
problems.append("extensions is an object")
|
|
1420
|
+
else:
|
|
1421
|
+
for key in doc["extensions"]:
|
|
1422
|
+
if not _URI.fullmatch(key):
|
|
1423
|
+
problems.append("extensions key %r is not a URI" % key)
|
|
1424
|
+
service = doc.get("service")
|
|
1425
|
+
if "service" in doc:
|
|
1426
|
+
if not isinstance(service, dict) or set(service) != {"id", "instance", "version", "schema"}:
|
|
1427
|
+
problems.append("service is {id, instance, version, schema}")
|
|
1428
|
+
else:
|
|
1429
|
+
if not _ID.fullmatch(str(service["id"])):
|
|
1430
|
+
problems.append("service.id must match %s" % _ID.pattern)
|
|
1431
|
+
if not _INSTANCE.fullmatch(str(service["instance"])):
|
|
1432
|
+
problems.append("service.instance must match %s" % _INSTANCE.pattern)
|
|
1433
|
+
if not (isinstance(service["version"], str) and len(service["version"]) <= 64):
|
|
1434
|
+
problems.append("service.version is a string of at most 64 characters")
|
|
1435
|
+
schema = service["schema"]
|
|
1436
|
+
if not ((isinstance(schema, int) and not isinstance(schema, bool) and schema >= 0)
|
|
1437
|
+
or (isinstance(schema, str) and 1 <= len(schema) <= 64)):
|
|
1438
|
+
problems.append("service.schema is a whole number or a short string")
|
|
1439
|
+
if "created_at" in doc and not _is_timestamp(doc["created_at"]):
|
|
1440
|
+
problems.append("created_at is a UTC timestamp")
|
|
1441
|
+
if "reason" in doc and not (isinstance(doc["reason"], str) and _REASON.fullmatch(doc["reason"])):
|
|
1442
|
+
problems.append("reason must match %s" % _REASON.pattern)
|
|
1443
|
+
if "sha256" in doc and not (isinstance(doc["sha256"], str) and _SHA256.fullmatch(doc["sha256"])):
|
|
1444
|
+
problems.append("sha256 is 64 lowercase hex digits")
|
|
1445
|
+
tables = doc.get("tables") if isinstance(doc.get("tables"), dict) else {}
|
|
1446
|
+
counts = doc.get("counts") if isinstance(doc.get("counts"), dict) else {}
|
|
1447
|
+
if "tables" in doc and not isinstance(doc.get("tables"), dict):
|
|
1448
|
+
problems.append("tables is an object")
|
|
1449
|
+
if "counts" in doc and not isinstance(doc.get("counts"), dict):
|
|
1450
|
+
problems.append("counts is an object")
|
|
1451
|
+
for name in list(tables) + [n for n in counts if n not in tables]:
|
|
1452
|
+
if not _TABLE.fullmatch(name) or _SECRET_TABLE.fullmatch(name):
|
|
1453
|
+
problems.append("table %s is never backed up: it can only hold connections, tokens, secrets, credentials, "
|
|
1454
|
+
"passwords, login codes or sessions (or its name is not a table name)" % name)
|
|
1455
|
+
for name, count in counts.items():
|
|
1456
|
+
if not (isinstance(count, int) and not isinstance(count, bool) and count >= 0):
|
|
1457
|
+
problems.append("counts.%s is a whole number" % name)
|
|
1458
|
+
canonical = True
|
|
1459
|
+
for name, table in tables.items():
|
|
1460
|
+
if not isinstance(table, dict) or set(table) != {"columns", "rows"}:
|
|
1461
|
+
problems.append("tables.%s is {columns, rows}" % name)
|
|
1462
|
+
continue
|
|
1463
|
+
columns, rows = table["columns"], table["rows"]
|
|
1464
|
+
if not (isinstance(columns, list) and 1 <= len(columns) <= 512 and len(set(map(str, columns))) == len(columns)):
|
|
1465
|
+
problems.append("tables.%s.columns is 1 to 512 distinct names" % name)
|
|
1466
|
+
columns = columns if isinstance(columns, list) else []
|
|
1467
|
+
for column in columns:
|
|
1468
|
+
if not (isinstance(column, str) and _COLUMN.fullmatch(column)) or _SECRET_COLUMN.fullmatch(str(column)):
|
|
1469
|
+
problems.append("column %s.%s is refused: its name says it holds a credential value (name the secret, "
|
|
1470
|
+
"never store it: secret_name, token_slot)" % (name, column))
|
|
1471
|
+
if not isinstance(rows, list):
|
|
1472
|
+
problems.append("tables.%s.rows is a list" % name)
|
|
1473
|
+
continue
|
|
1474
|
+
width = len(columns)
|
|
1475
|
+
for i, row in enumerate(rows):
|
|
1476
|
+
at = "/tables/%s/rows/%d" % (name, i)
|
|
1477
|
+
if not isinstance(row, list):
|
|
1478
|
+
problems.append("%s is a list of values" % at)
|
|
1479
|
+
continue
|
|
1480
|
+
if len(row) != width:
|
|
1481
|
+
problems.append("FAC-SEM-035 %s: row has %d values for %d columns" % (at, len(row), width))
|
|
1482
|
+
if len(row) > _MAX_ROW_VALUES:
|
|
1483
|
+
problems.append("%s holds more than %d values" % (at, _MAX_ROW_VALUES))
|
|
1484
|
+
for j, cell in enumerate(row):
|
|
1485
|
+
if isinstance(cell, float) or (isinstance(cell, int) and not isinstance(cell, bool) and abs(cell) > _SAFE_INTEGER):
|
|
1486
|
+
canonical = False
|
|
1487
|
+
problems.append("FAC-SEM-035 %s/%d: a fraction or an unsafe integer has no canonical form; store it as a string" % (at, j))
|
|
1488
|
+
elif isinstance(cell, str) and _SURROGATE.search(cell):
|
|
1489
|
+
canonical = False
|
|
1490
|
+
problems.append("FAC-SEM-035 %s/%d: a string with a lone surrogate cannot be encoded as UTF-8" % (at, j))
|
|
1491
|
+
elif isinstance(cell, str) and len(cell) > _MAX_CELL_CHARS:
|
|
1492
|
+
problems.append("%s/%d is longer than %d characters" % (at, j, _MAX_CELL_CHARS))
|
|
1493
|
+
elif not (cell is None or isinstance(cell, (bool, int, str))):
|
|
1494
|
+
canonical = False
|
|
1495
|
+
problems.append("%s/%d is not a JSON scalar" % (at, j))
|
|
1496
|
+
if counts.get(name) != len(rows):
|
|
1497
|
+
problems.append("FAC-SEM-035 /counts/%s: counts says %s for %d rows" % (name, counts.get(name, "nothing"), len(rows)))
|
|
1498
|
+
for name in counts:
|
|
1499
|
+
if name not in tables:
|
|
1500
|
+
problems.append("FAC-SEM-035 /counts/%s: counts names %s, which the backup does not carry" % (name, name))
|
|
1501
|
+
if canonical and isinstance(doc.get("tables"), dict):
|
|
1502
|
+
try:
|
|
1503
|
+
digest: Optional[str] = settings_backup_digest(tables)
|
|
1504
|
+
except (ServiceError, UnicodeError, TypeError, ValueError) as exc: # a name no canonical form can carry
|
|
1505
|
+
digest = None
|
|
1506
|
+
problems.append("FAC-SEM-035 /tables: the tables have no canonical form (%s)" % exc)
|
|
1507
|
+
if digest is not None and doc.get("sha256") != digest:
|
|
1508
|
+
problems.append("FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it")
|
|
1509
|
+
return problems
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def settings_backup_root(platform: str = sys.platform) -> Path:
|
|
1513
|
+
"""Where every service keeps its settings backups — outside its data directory (DEC-0025)."""
|
|
1514
|
+
if platform == "darwin":
|
|
1515
|
+
return Path.home() / "Library/Application Support/PassionCode"
|
|
1516
|
+
if platform == "win32":
|
|
1517
|
+
appdata = os.environ.get("APPDATA")
|
|
1518
|
+
return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "PassionCode"
|
|
1519
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "PassionCode"
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def settings_backup_dir(service_id: str, instance: str = "default", root: Optional[Path] = None) -> Path:
|
|
1523
|
+
_require_id(service_id)
|
|
1524
|
+
if not (isinstance(instance, str) and _INSTANCE.fullmatch(instance)):
|
|
1525
|
+
raise ServiceError("instance must match %s" % _INSTANCE.pattern)
|
|
1526
|
+
return (root or settings_backup_root()) / "settings-backups" / service_id / instance
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def make_settings_backup(*, service_id: str, instance: str = "default", version: str, schema: Any,
|
|
1530
|
+
tables: Dict[str, Dict[str, Any]], reason: str, now: Optional[_dt.datetime] = None) -> Dict[str, Any]:
|
|
1531
|
+
"""A snapshot of the operator's decisions and bindings: the tables on the service's allow-list, each
|
|
1532
|
+
``{"columns": [...], "rows": [[...], ...]}`` of JSON scalars (a fraction as its decimal string). Refuses a file a
|
|
1533
|
+
reader would refuse."""
|
|
1534
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
1535
|
+
doc = {"format": SETTINGS_BACKUP_FORMAT,
|
|
1536
|
+
"service": {"id": service_id, "instance": instance, "version": version, "schema": schema},
|
|
1537
|
+
"created_at": now.astimezone(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "reason": reason,
|
|
1538
|
+
"sha256": "", "counts": {name: len(table.get("rows") or []) for name, table in tables.items()}, "tables": tables}
|
|
1539
|
+
try:
|
|
1540
|
+
doc["sha256"] = settings_backup_digest(tables)
|
|
1541
|
+
except ServiceError as exc:
|
|
1542
|
+
raise ServiceError("this snapshot has no canonical form: %s" % exc) from None
|
|
1543
|
+
problems = settings_backup_problems(doc)
|
|
1544
|
+
if problems:
|
|
1545
|
+
raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
|
|
1546
|
+
return doc
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _backup_name(doc: Dict[str, Any]) -> str:
|
|
1550
|
+
stamp = doc["created_at"].replace("-", "").replace(":", "")
|
|
1551
|
+
head, _, frac = stamp.rstrip("Z").partition(".")
|
|
1552
|
+
return "settings-%s%sZ.json" % (head, (frac + "000000")[:6])
|
|
1553
|
+
|
|
1554
|
+
|
|
1555
|
+
def settings_backups(directory: Path) -> List[Path]:
|
|
1556
|
+
"""The snapshot files, newest first (their names sort in time order)."""
|
|
1557
|
+
directory = Path(directory)
|
|
1558
|
+
if not directory.is_dir():
|
|
1559
|
+
return []
|
|
1560
|
+
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)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _no_constant(name: str) -> Any:
|
|
1564
|
+
raise ValueError("%s is not JSON" % name)
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def read_settings_backup(path: Path) -> Dict[str, Any]:
|
|
1568
|
+
"""A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035)."""
|
|
1569
|
+
try:
|
|
1570
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=_no_constant)
|
|
1571
|
+
except (OSError, ValueError) as exc:
|
|
1572
|
+
raise ServiceError("settings backup %s cannot be read: %s" % (path, exc)) from None
|
|
1573
|
+
problems = settings_backup_problems(doc)
|
|
1574
|
+
if problems:
|
|
1575
|
+
raise ServiceError("settings backup %s is refused: %s" % (path, "; ".join(problems)))
|
|
1576
|
+
return doc
|
|
1577
|
+
|
|
1578
|
+
|
|
1579
|
+
def write_settings_backup(doc: Dict[str, Any], directory: Path, *, keep: int = SETTINGS_BACKUP_KEEP) -> Optional[Path]:
|
|
1580
|
+
"""Write a snapshot made by ``make_settings_backup``: atomically, 0600 in a 0700 directory, named by its time.
|
|
1581
|
+
A temporary file an interrupted write left is removed first; a snapshot whose checksum equals the newest file's
|
|
1582
|
+
is not written again (None). After a write, all but the ``keep`` newest are removed (at least 14)."""
|
|
1583
|
+
problems = settings_backup_problems(doc)
|
|
1584
|
+
if problems:
|
|
1585
|
+
raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
|
|
1586
|
+
directory = Path(directory)
|
|
1587
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1588
|
+
ensure_private_dir(directory)
|
|
1589
|
+
stale = time.time() - _STALE_TEMP_SECONDS
|
|
1590
|
+
for left in directory.iterdir():
|
|
1591
|
+
# Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
|
|
1592
|
+
try:
|
|
1593
|
+
if left.name.startswith(".settings-") and left.stat().st_mtime < stale:
|
|
1594
|
+
left.unlink()
|
|
1595
|
+
except OSError:
|
|
1596
|
+
pass
|
|
1597
|
+
existing = settings_backups(directory)
|
|
1598
|
+
if existing:
|
|
1599
|
+
try:
|
|
1600
|
+
if read_settings_backup(existing[0])["sha256"] == doc["sha256"]:
|
|
1601
|
+
return None
|
|
1602
|
+
except Exception: # noqa: BLE001 - a damaged newest file is no reason to skip a good snapshot
|
|
1603
|
+
pass
|
|
1604
|
+
path = directory / _backup_name(doc)
|
|
1605
|
+
atomic_write(path, json.dumps(doc, ensure_ascii=False, indent=2).encode("utf-8") + b"\n", 0o600)
|
|
1606
|
+
# The newest by name, but never the one just written: a clock that stepped back must not delete it.
|
|
1607
|
+
for old in [p for p in settings_backups(directory) if p != path][max(keep, SETTINGS_BACKUP_KEEP) - 1:]:
|
|
1608
|
+
old.unlink()
|
|
1609
|
+
return path
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def backup_due(directory: Path, now: Optional[_dt.datetime] = None) -> bool:
|
|
1613
|
+
"""True when there is no snapshot or the newest is older than 24 hours: take one at start or on the daily tick."""
|
|
1614
|
+
newest = settings_backups(directory)
|
|
1615
|
+
if not newest:
|
|
1616
|
+
return True
|
|
1617
|
+
try:
|
|
1618
|
+
stamp = _dt.datetime.strptime(newest[0].name[len("settings-"):-len("Z.json")], "%Y%m%dT%H%M%S%f").replace(tzinfo=_dt.timezone.utc)
|
|
1619
|
+
except ValueError:
|
|
1620
|
+
return True # a name no calendar has: take a fresh snapshot rather than trust it
|
|
1621
|
+
return ((now or _dt.datetime.now(_dt.timezone.utc)) - stamp).total_seconds() > SETTINGS_BACKUP_MAX_AGE_SECONDS
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def newest_intact_backup(directory: Path) -> Tuple[Optional[Path], Optional[Dict[str, Any]], List[Tuple[Path, str]]]:
|
|
1625
|
+
"""The source of an automatic restore: the newest snapshot that is intact and has content. Damaged ones are
|
|
1626
|
+
skipped and returned with the reason, for the restore report."""
|
|
1627
|
+
skipped: List[Tuple[Path, str]] = []
|
|
1628
|
+
for path in settings_backups(directory):
|
|
1629
|
+
try:
|
|
1630
|
+
doc = read_settings_backup(path)
|
|
1631
|
+
except Exception as exc: # noqa: BLE001 - one damaged file never stops the scan
|
|
1632
|
+
skipped.append((path, str(exc)))
|
|
1633
|
+
continue
|
|
1634
|
+
if any(doc["counts"].values()):
|
|
1635
|
+
return path, doc, skipped
|
|
1636
|
+
return None, None, skipped
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
1640
|
+
"""What restoring ``doc`` would do — it adds and never overwrites (DEC-0025). ``current`` describes the database
|
|
1641
|
+
now, per table: ``columns`` (all of them), ``key`` (the primary key columns), ``required`` (columns without a
|
|
1642
|
+
default) and ``existing`` (the primary keys present, as tuples). Per table: the rows to insert (dicts over the
|
|
1643
|
+
columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
|
|
1644
|
+
to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
|
|
1645
|
+
``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
|
|
1646
|
+
row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's act."""
|
|
1647
|
+
problems = settings_backup_problems(doc)
|
|
1648
|
+
if problems:
|
|
1649
|
+
raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
|
|
1650
|
+
plan: Dict[str, Any] = {"source": {"created_at": doc["created_at"], "sha256": doc["sha256"]}, "tables": {}, "unknownTables": []}
|
|
1651
|
+
for name, table in doc["tables"].items():
|
|
1652
|
+
now_table = current.get(name)
|
|
1653
|
+
if now_table is None:
|
|
1654
|
+
plan["unknownTables"].append(name)
|
|
1655
|
+
continue
|
|
1656
|
+
columns = list(now_table.get("columns") or [])
|
|
1657
|
+
key = list(now_table.get("key") or [])
|
|
1658
|
+
required = list(now_table.get("required") or [])
|
|
1659
|
+
existing = {tuple(k) for k in now_table.get("existing") or []}
|
|
1660
|
+
shared = [c for c in table["columns"] if c in columns]
|
|
1661
|
+
missing = [c for c in required if c not in table["columns"]]
|
|
1662
|
+
entry = {"insert": [], "present": 0, "droppedColumns": [c for c in table["columns"] if c not in columns],
|
|
1663
|
+
"defaultedColumns": [c for c in columns if c not in table["columns"]], "missingRequired": missing, "refused": []}
|
|
1664
|
+
planned = set()
|
|
1665
|
+
for row in table["rows"]:
|
|
1666
|
+
values = dict(zip(table["columns"], row))
|
|
1667
|
+
ident = [values.get(c) for c in key]
|
|
1668
|
+
if any(c not in table["columns"] for c in key):
|
|
1669
|
+
entry["refused"].append({"key": ident, "reason": "the primary key is not in the snapshot"})
|
|
1670
|
+
elif tuple(ident) in planned:
|
|
1671
|
+
entry["refused"].append({"key": ident, "reason": "the snapshot holds this key twice"})
|
|
1672
|
+
elif tuple(ident) in existing:
|
|
1673
|
+
entry["present"] += 1
|
|
1674
|
+
elif missing:
|
|
1675
|
+
entry["refused"].append({"key": ident, "reason": "required column %s is not in the snapshot" % ", ".join(missing)})
|
|
1676
|
+
else:
|
|
1677
|
+
planned.add(tuple(ident))
|
|
1678
|
+
entry["insert"].append({c: values[c] for c in shared})
|
|
1679
|
+
plan["tables"][name] = entry
|
|
1680
|
+
return plan
|
|
1681
|
+
|
|
1682
|
+
|
|
1036
1683
|
class JsonlUsageLedger:
|
|
1037
1684
|
"""Usage receipts for services that keep none yet: append-only JSON lines, mode 0600, pruned
|
|
1038
1685
|
to the reported window so the file stays bounded (LC-12). One writer process — the service,
|
|
@@ -1516,6 +2163,187 @@ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Op
|
|
|
1516
2163
|
|
|
1517
2164
|
# --- launchd -----------------------------------------------------------------
|
|
1518
2165
|
|
|
2166
|
+
# --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") ------------------
|
|
2167
|
+
|
|
2168
|
+
def _no_secret_in_supervisor_file(argv: Sequence[str], environment: Optional[Dict[str, str]]) -> None:
|
|
2169
|
+
"""No supervisor file carries a secret (service.md): not in an argument, an environment name or its value."""
|
|
2170
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
2171
|
+
for arg in argv:
|
|
2172
|
+
if fabric_secrets.secret_argument(arg):
|
|
2173
|
+
raise ServiceError("an argument carries a credential; a supervisor file never does (read it from the token file or a secret reference)")
|
|
2174
|
+
for name, value in (environment or {}).items():
|
|
2175
|
+
if fabric_secrets.credential_name(name) or fabric_secrets.secret_value(str(value)):
|
|
2176
|
+
raise ServiceError("environment %s looks like a credential; a supervisor file never carries one" % name)
|
|
2177
|
+
|
|
2178
|
+
|
|
2179
|
+
def _plain(value: str, what: str) -> str:
|
|
2180
|
+
"""A supervisor file is line- and XML-structured: no control character in anything written into it."""
|
|
2181
|
+
if re.search(r"[\x00-\x1f\x7f]", value):
|
|
2182
|
+
raise ServiceError("%s holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it" % what)
|
|
2183
|
+
return value
|
|
2184
|
+
|
|
2185
|
+
|
|
2186
|
+
def _systemd_quote(value: str) -> str:
|
|
2187
|
+
_plain(value, "an argument")
|
|
2188
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$")
|
|
2189
|
+
return '"%s"' % escaped
|
|
2190
|
+
|
|
2191
|
+
|
|
2192
|
+
def systemd_unit(unit: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
2193
|
+
environment: Optional[Dict[str, str]] = None, description: Optional[str] = None,
|
|
2194
|
+
stop_timeout: int = DEFAULT_EXIT_TIMEOUT) -> str:
|
|
2195
|
+
"""A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
|
|
2196
|
+
WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd)."""
|
|
2197
|
+
if not _UNIT.match(unit):
|
|
2198
|
+
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
2199
|
+
argv = [str(a) for a in program_arguments]
|
|
2200
|
+
if not argv or not argv[0].startswith("/"):
|
|
2201
|
+
raise ServiceError("a systemd unit starts an absolute executable")
|
|
2202
|
+
if stop_timeout <= DRAIN_SECONDS:
|
|
2203
|
+
raise ServiceError("TimeoutStopSec must outlast the %d s drain" % DRAIN_SECONDS)
|
|
2204
|
+
env = dict(environment or {})
|
|
2205
|
+
_no_secret_in_supervisor_file(argv, env)
|
|
2206
|
+
env.setdefault(SUPERVISOR_ENV, "systemd")
|
|
2207
|
+
# A unit is a Linux file: a Path built anywhere (a test on Windows, an installer's planner) names a POSIX path.
|
|
2208
|
+
cwd = _plain(working_directory.as_posix() if isinstance(working_directory, PurePath) else str(working_directory),
|
|
2209
|
+
"the working directory")
|
|
2210
|
+
if not cwd.startswith("/"):
|
|
2211
|
+
raise ServiceError("a systemd unit's working directory is absolute")
|
|
2212
|
+
lines = ["[Unit]", "Description=%s" % _plain(description or unit, "the description").replace("%", "%%"), "",
|
|
2213
|
+
"[Service]", "Type=simple", "ExecStart=%s" % " ".join(_systemd_quote(a) for a in argv),
|
|
2214
|
+
# Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
2215
|
+
"WorkingDirectory=%s" % cwd.replace("%", "%%")]
|
|
2216
|
+
for name in env:
|
|
2217
|
+
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
|
2218
|
+
raise ServiceError("environment name %r is not a variable name" % name)
|
|
2219
|
+
lines += ["Environment=%s" % _systemd_quote("%s=%s" % (k, v)) for k, v in sorted(env.items())]
|
|
2220
|
+
lines += ["Restart=always", "RestartSec=10", "TimeoutStopSec=%d" % stop_timeout, "KillMode=mixed", "",
|
|
2221
|
+
"[Install]", "WantedBy=default.target", ""]
|
|
2222
|
+
return "\n".join(lines)
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
def _systemd_dir() -> Path:
|
|
2226
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "systemd" / "user"
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
def _check(result: Any, what: str) -> None:
|
|
2230
|
+
if getattr(result, "returncode", 1) != 0:
|
|
2231
|
+
raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
2235
|
+
system: str = sys.platform, force_enable: bool = False) -> Path:
|
|
2236
|
+
"""Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
2237
|
+
|
|
2238
|
+
The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
|
|
2239
|
+
the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
|
|
2240
|
+
if not system.startswith("linux"):
|
|
2241
|
+
raise ServiceError("systemd is the supervisor on Linux only")
|
|
2242
|
+
if not _UNIT.match(unit):
|
|
2243
|
+
raise ServiceError("unit must match %s" % _UNIT.pattern)
|
|
2244
|
+
folder = unit_dir or _systemd_dir()
|
|
2245
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
2246
|
+
path = folder / unit
|
|
2247
|
+
first = not path.exists()
|
|
2248
|
+
state = "" if first else (getattr(run(["systemctl", "--user", "is-enabled", unit], capture_output=True, text=True),
|
|
2249
|
+
"stdout", "") or "").strip()
|
|
2250
|
+
atomic_write(path, text.encode("utf-8"), 0o644)
|
|
2251
|
+
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
2252
|
+
if state == "disabled" and not force_enable:
|
|
2253
|
+
return path
|
|
2254
|
+
_check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
|
|
2255
|
+
_check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
|
|
2256
|
+
return path
|
|
2257
|
+
|
|
2258
|
+
|
|
2259
|
+
def systemd_uninstall(unit: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
|
|
2260
|
+
system: str = sys.platform) -> None:
|
|
2261
|
+
"""Stop it and keep it off across logins (`disable --now`), then remove the unit."""
|
|
2262
|
+
if not system.startswith("linux"):
|
|
2263
|
+
raise ServiceError("systemd is the supervisor on Linux only")
|
|
2264
|
+
run(["systemctl", "--user", "disable", "--now", unit], capture_output=True, text=True)
|
|
2265
|
+
path = (unit_dir or _systemd_dir()) / unit
|
|
2266
|
+
if path.exists():
|
|
2267
|
+
path.unlink()
|
|
2268
|
+
_check(run(["systemctl", "--user", "daemon-reload"], capture_output=True, text=True), "systemctl --user daemon-reload")
|
|
2269
|
+
|
|
2270
|
+
|
|
2271
|
+
def _xml(text: str) -> str:
|
|
2272
|
+
return (text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """))
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
def task_xml(task: str, program_arguments: Sequence[str], *, working_directory: str, description: Optional[str] = None,
|
|
2276
|
+
user: Optional[str] = None) -> str:
|
|
2277
|
+
"""A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution time limit,
|
|
2278
|
+
run only when the user is logged on, created without administrator rights."""
|
|
2279
|
+
if not _TASK.match(task):
|
|
2280
|
+
raise ServiceError("task must match %s" % _TASK.pattern)
|
|
2281
|
+
argv = [str(a) for a in program_arguments]
|
|
2282
|
+
if not argv or not re.match(r"^[A-Za-z]:[\\/]", argv[0]):
|
|
2283
|
+
raise ServiceError("a task starts a drive-absolute executable (C:\\…)")
|
|
2284
|
+
for value, what in [(a, "an argument") for a in argv] + [(str(working_directory), "the working directory"),
|
|
2285
|
+
(description or task, "the description")]:
|
|
2286
|
+
_plain(value, what)
|
|
2287
|
+
_no_secret_in_supervisor_file(argv, None)
|
|
2288
|
+
who = user or ("%s\\%s" % (os.environ.get("USERDOMAIN", ""), os.environ["USERNAME"]) if os.environ.get("USERNAME") else "")
|
|
2289
|
+
if not who:
|
|
2290
|
+
raise ServiceError("name the user the task runs as (DOMAIN\\user); USERNAME is not set")
|
|
2291
|
+
_plain(who, "the user")
|
|
2292
|
+
body = """<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
2293
|
+
<RegistrationInfo><Description>{desc}</Description><URI>{task}</URI></RegistrationInfo>
|
|
2294
|
+
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{user}</UserId></LogonTrigger></Triggers>
|
|
2295
|
+
<Principals><Principal id="Author"><UserId>{user}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
2296
|
+
<Settings>
|
|
2297
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
2298
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
2299
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
2300
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
2301
|
+
<Enabled>true</Enabled>
|
|
2302
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
2303
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
2304
|
+
</Settings>
|
|
2305
|
+
<Actions Context="Author"><Exec><Command>{command}</Command><Arguments>{arguments}</Arguments><WorkingDirectory>{cwd}</WorkingDirectory></Exec></Actions>
|
|
2306
|
+
</Task>
|
|
2307
|
+
""".format(desc=_xml(description or task), task=_xml(task), user=_xml(who), command=_xml(argv[0]),
|
|
2308
|
+
arguments=_xml(subprocess.list2cmdline(argv[1:])), cwd=_xml(str(working_directory)))
|
|
2309
|
+
return '<?xml version="1.0" encoding="UTF-16"?>\n' + body
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
_TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Enabled>", re.S)
|
|
2313
|
+
|
|
2314
|
+
|
|
2315
|
+
def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
|
|
2316
|
+
force_enable: bool = False) -> None:
|
|
2317
|
+
"""Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
|
|
2318
|
+
definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
|
|
2319
|
+
if system != "win32":
|
|
2320
|
+
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
2321
|
+
query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
|
|
2322
|
+
existed = getattr(query, "returncode", 1) == 0
|
|
2323
|
+
keep_off = existed and not force_enable and bool(_TASK_DISABLED.search(getattr(query, "stdout", "") or ""))
|
|
2324
|
+
fd, name = tempfile.mkstemp(suffix=".xml")
|
|
2325
|
+
try:
|
|
2326
|
+
with os.fdopen(fd, "wb") as handle:
|
|
2327
|
+
handle.write(xml_text.encode("utf-16"))
|
|
2328
|
+
_check(run(["schtasks", "/Create", "/TN", task, "/XML", name, "/F"], capture_output=True, text=True), "schtasks /Create")
|
|
2329
|
+
finally:
|
|
2330
|
+
os.unlink(name)
|
|
2331
|
+
if keep_off:
|
|
2332
|
+
_check(run(["schtasks", "/Change", "/TN", task, "/DISABLE"], capture_output=True, text=True), "schtasks /Change /DISABLE")
|
|
2333
|
+
return
|
|
2334
|
+
if existed:
|
|
2335
|
+
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
2336
|
+
_check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
|
|
2337
|
+
|
|
2338
|
+
|
|
2339
|
+
def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
|
|
2340
|
+
"""End the running task, then delete it."""
|
|
2341
|
+
if system != "win32":
|
|
2342
|
+
raise ServiceError("Task Scheduler is the supervisor on Windows only")
|
|
2343
|
+
run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
|
|
2344
|
+
_check(run(["schtasks", "/Delete", "/TN", task, "/F"], capture_output=True, text=True), "schtasks /Delete")
|
|
2345
|
+
|
|
2346
|
+
|
|
1519
2347
|
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
1520
2348
|
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
1521
2349
|
exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
|
|
@@ -1700,7 +2528,7 @@ def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path]
|
|
|
1700
2528
|
# --- helpers for input validation -----------------------------------------------
|
|
1701
2529
|
|
|
1702
2530
|
def _require_id(service_id: str) -> None:
|
|
1703
|
-
if not _ID.
|
|
2531
|
+
if not (isinstance(service_id, str) and _ID.fullmatch(service_id)):
|
|
1704
2532
|
raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
|
|
1705
2533
|
|
|
1706
2534
|
|