@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 +79 -0
- package/README.md +9 -3
- 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 +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -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 +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- 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_service.py +10 -183
- 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/fabric-auth.mjs +286 -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-session.mjs +840 -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 +1 -16
- 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 +13 -45
- 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 +8 -67
- 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 +2 -2
- 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 +202 -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 +2 -12
- 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 +400 -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 +402 -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 +78 -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_session.py +902 -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 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- 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 +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -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_interop.py +1 -6
- 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 +21 -43
- 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 +31 -38
- 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_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 +412 -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 +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock, the supervisor marker
|
|
2
|
+
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import ntpath
|
|
8
|
+
import os
|
|
9
|
+
import posixpath
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
import sys as _sys # noqa: E402
|
|
18
|
+
from pathlib import Path as _Path # noqa: E402
|
|
19
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
20
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
21
|
+
from fabric_base import (
|
|
22
|
+
SUPERVISOR_ENV,
|
|
23
|
+
ServiceError,
|
|
24
|
+
WINDOWS,
|
|
25
|
+
_O_BINARY,
|
|
26
|
+
)
|
|
27
|
+
from fabric_platform import (
|
|
28
|
+
_close_handle,
|
|
29
|
+
_create_first_pipe,
|
|
30
|
+
_pipe_exists,
|
|
31
|
+
)
|
|
32
|
+
from fabric_paths import (
|
|
33
|
+
ensure_private_dir,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
EXIT_ALREADY_RUNNING = 75
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
LOCK_WAIT_SUPERVISED_SECONDS = 300.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
LOCK_BACKOFF_FIRST_SECONDS = 0.5
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
LOCK_BACKOFF_MAX_SECONDS = 30.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AlreadyRunning(ServiceError):
|
|
50
|
+
def __init__(self, holder_pid: Optional[int], lock_path: Path):
|
|
51
|
+
self.holder_pid = holder_pid
|
|
52
|
+
self.lock_path = lock_path
|
|
53
|
+
# FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
|
|
54
|
+
who = "process %d" % holder_pid if holder_pid else "an unknown process"
|
|
55
|
+
super().__init__("Another copy is already running (%s holds %s)." % (who, lock_path))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InstanceLock:
|
|
59
|
+
"""Exclusive flock on <data>/service.lock, taken BEFORE any side effect.
|
|
60
|
+
|
|
61
|
+
Binding a port is not a lock: a second copy that resumes jobs before its
|
|
62
|
+
bind fails has already done damage. The lock is released by the kernel when
|
|
63
|
+
the process dies, so a crash never leaves it stale.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, data_dir: Path):
|
|
67
|
+
self.path = Path(data_dir) / "service.lock"
|
|
68
|
+
self._fd: Optional[int] = None
|
|
69
|
+
self._name: Any = None
|
|
70
|
+
|
|
71
|
+
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
72
|
+
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
73
|
+
#: a file is allowed there).
|
|
74
|
+
WINDOWS_LOCK_OFFSET = 1 << 30
|
|
75
|
+
|
|
76
|
+
def acquire(self) -> "InstanceLock":
|
|
77
|
+
ensure_private_dir(self.path.parent)
|
|
78
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
|
|
79
|
+
try:
|
|
80
|
+
if WINDOWS:
|
|
81
|
+
import msvcrt
|
|
82
|
+
|
|
83
|
+
os.lseek(fd, self.WINDOWS_LOCK_OFFSET, os.SEEK_SET)
|
|
84
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
85
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
86
|
+
else:
|
|
87
|
+
import fcntl
|
|
88
|
+
|
|
89
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
90
|
+
except OSError as exc:
|
|
91
|
+
os.close(fd)
|
|
92
|
+
if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
|
|
93
|
+
raise AlreadyRunning(_read_pid(self.path), self.path) from None
|
|
94
|
+
raise
|
|
95
|
+
try:
|
|
96
|
+
self._name = _hold_lock_name(self.path)
|
|
97
|
+
except BaseException:
|
|
98
|
+
os.close(fd)
|
|
99
|
+
raise
|
|
100
|
+
os.ftruncate(fd, 0)
|
|
101
|
+
os.write(fd, str(os.getpid()).encode())
|
|
102
|
+
os.fsync(fd)
|
|
103
|
+
self._fd = fd
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def release(self) -> None:
|
|
107
|
+
if self._fd is not None:
|
|
108
|
+
os.close(self._fd)
|
|
109
|
+
self._fd = None
|
|
110
|
+
if self._name is not None:
|
|
111
|
+
_drop_lock_name(self._name)
|
|
112
|
+
self._name = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
|
|
116
|
+
"""The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
|
|
117
|
+
if platform.startswith("linux"):
|
|
118
|
+
digest = hashlib.sha256(posixpath.abspath(str(lock_path)).encode()).hexdigest()[:32]
|
|
119
|
+
return "\0fabric-service-" + digest
|
|
120
|
+
if platform == "win32":
|
|
121
|
+
# Windows paths compare without case: C:\Users and c:\users are one lock.
|
|
122
|
+
digest = hashlib.sha256(ntpath.normcase(ntpath.abspath(str(lock_path))).encode()).hexdigest()[:32]
|
|
123
|
+
return "\\\\.\\pipe\\fabric-service-" + digest
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _hold_lock_name(lock_path: Path) -> Any:
|
|
128
|
+
name = lock_name(lock_path)
|
|
129
|
+
if name is None:
|
|
130
|
+
return None
|
|
131
|
+
if WINDOWS:
|
|
132
|
+
handle = _create_first_pipe(name)
|
|
133
|
+
if handle is None:
|
|
134
|
+
raise AlreadyRunning(_read_pid(lock_path), lock_path)
|
|
135
|
+
return handle
|
|
136
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
137
|
+
try:
|
|
138
|
+
sock.bind(name)
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
sock.close()
|
|
141
|
+
if exc.errno == errno.EADDRINUSE:
|
|
142
|
+
raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
|
|
143
|
+
raise
|
|
144
|
+
# Held by its descriptor, like the file lock: a socket object would close when collected.
|
|
145
|
+
return sock.detach()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _drop_lock_name(held: Any) -> None:
|
|
149
|
+
if WINDOWS:
|
|
150
|
+
_close_handle(held)
|
|
151
|
+
else:
|
|
152
|
+
os.close(held)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def lock_name_held(lock_path: Path) -> Optional[bool]:
|
|
156
|
+
"""True when some process holds the lock's name (a running service of either kit); None where there is no name
|
|
157
|
+
to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
|
|
158
|
+
probe cannot make a service that is starting at that moment fail."""
|
|
159
|
+
name = lock_name(lock_path)
|
|
160
|
+
if name is None:
|
|
161
|
+
return None
|
|
162
|
+
if WINDOWS:
|
|
163
|
+
return _pipe_exists(name)
|
|
164
|
+
try:
|
|
165
|
+
with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
|
|
166
|
+
return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
|
|
167
|
+
except OSError:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _read_pid(path: Path) -> Optional[int]:
|
|
172
|
+
try:
|
|
173
|
+
text = path.read_text().strip()
|
|
174
|
+
return int(text) if re.fullmatch(r"[0-9]{1,10}", text, re.ASCII) else None
|
|
175
|
+
except OSError:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
#: The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node.
|
|
180
|
+
SUPERVISORS = ("launchd", "systemd", "task-scheduler", "node")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
#: The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action).
|
|
184
|
+
SUPERVISOR_ARG = "--fabric-supervisor"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _marker_arguments(argv: Sequence[Any]) -> List[str]:
|
|
188
|
+
"""The marker values an argument vector carries after the executable and before ``--``. A bare
|
|
189
|
+
``--fabric-supervisor`` followed by nothing, by ``--`` or by another ``--…`` flag is a marker with an empty value
|
|
190
|
+
and consumes nothing."""
|
|
191
|
+
values: List[str] = []
|
|
192
|
+
index = 1
|
|
193
|
+
while index < len(argv):
|
|
194
|
+
item = argv[index]
|
|
195
|
+
if item == "--":
|
|
196
|
+
break
|
|
197
|
+
if item == SUPERVISOR_ARG:
|
|
198
|
+
following = argv[index + 1] if index + 1 < len(argv) else None
|
|
199
|
+
if isinstance(following, str) and not following.startswith("--"):
|
|
200
|
+
values.append(following)
|
|
201
|
+
index += 1
|
|
202
|
+
else:
|
|
203
|
+
values.append("")
|
|
204
|
+
elif isinstance(item, str) and item.startswith(SUPERVISOR_ARG + "="):
|
|
205
|
+
values.append(item[len(SUPERVISOR_ARG) + 1:])
|
|
206
|
+
index += 1
|
|
207
|
+
return values
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def supervisor_marker(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
211
|
+
"""How this copy was started (DEC-0057), from ``FABRIC_SERVICE_SUPERVISOR=<manager>`` and
|
|
212
|
+
``--fabric-supervisor <manager>`` / ``--fabric-supervisor=<manager>`` (``env`` and ``argv`` default to this process's):
|
|
213
|
+
|
|
214
|
+
- ``{"kind": "supervised", "manager", "forms"}`` — every marker present names the same manager of SUPERVISORS;
|
|
215
|
+
- ``{"kind": "conflict", "values", "forms"}`` — the markers disagree: the copy is unsupervised and exits 75 at once,
|
|
216
|
+
before it takes the lock (an empty or bare argument beside a variable disagrees with it);
|
|
217
|
+
- ``{"kind": "manual", "forms", "unreadable"?}`` — no marker (only an empty variable counts as none), or one that
|
|
218
|
+
names no manager.
|
|
219
|
+
|
|
220
|
+
A copy never infers supervision from its parent process. The contract's ``supervisorMarker``."""
|
|
221
|
+
env = os.environ if env is None else env
|
|
222
|
+
argv = sys.argv if argv is None else argv
|
|
223
|
+
variable = env.get(SUPERVISOR_ENV) if isinstance(env, dict) or hasattr(env, "get") else None
|
|
224
|
+
from_env = [variable] if isinstance(variable, str) and variable != "" else []
|
|
225
|
+
from_args = _marker_arguments(list(argv) if isinstance(argv, (list, tuple)) else [])
|
|
226
|
+
forms = (["environment"] if from_env else []) + (["argument"] if from_args else [])
|
|
227
|
+
values: List[str] = []
|
|
228
|
+
for value in from_env + from_args:
|
|
229
|
+
if value not in values:
|
|
230
|
+
values.append(value)
|
|
231
|
+
if len(values) > 1:
|
|
232
|
+
return {"kind": "conflict", "values": values, "forms": forms}
|
|
233
|
+
if not values:
|
|
234
|
+
return {"kind": "manual", "forms": forms}
|
|
235
|
+
if values[0] in SUPERVISORS:
|
|
236
|
+
return {"kind": "supervised", "manager": values[0], "forms": forms}
|
|
237
|
+
return {"kind": "manual", "unreadable": values[0], "forms": forms}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def lock_wait_budget_seconds(marker: Dict[str, Any]) -> float:
|
|
241
|
+
"""How long a copy started so may wait for the lock: 300 s when supervised, else not at all."""
|
|
242
|
+
return LOCK_WAIT_SUPERVISED_SECONDS if marker.get("kind") == "supervised" else 0.0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def marker_conflict_message(marker: Dict[str, Any]) -> str:
|
|
246
|
+
"""The one sentence a copy whose markers disagree exits with, naming the markers present."""
|
|
247
|
+
shown = ", ".join(repr(v) for v in marker["values"])
|
|
248
|
+
if marker["forms"] == ["environment", "argument"]:
|
|
249
|
+
return "%s and %s disagree (%s); not starting unsupervised." % (SUPERVISOR_ENV, SUPERVISOR_ARG, shown)
|
|
250
|
+
return "%s is given with different values (%s); not starting unsupervised." % (SUPERVISOR_ARG, shown)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def supervised(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
|
|
254
|
+
"""True when a supervisor that restarts on exit started this process and said so with the marker
|
|
255
|
+
(``launchd_plist`` and ``systemd_unit`` set the variable, ``task_xml`` writes the argument, a node sets either)."""
|
|
256
|
+
return supervisor_marker(env, argv)["kind"] == "supervised"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def supervised_by_launchd(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
|
|
260
|
+
"""True when launchd started this process from a plist written by ``launchd_plist``.
|
|
261
|
+
|
|
262
|
+
The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
|
|
263
|
+
``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
|
|
264
|
+
return supervisor_marker(env, argv).get("manager") == "launchd"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def add_supervisor_argument(parser: Any) -> Any:
|
|
268
|
+
"""Let a service's own argparse parser accept the marker in both spellings, and a bare one, and ignore it
|
|
269
|
+
(DEC-0057: argparse exits 2 on an unknown flag). The kit reads the marker from ``sys.argv`` itself."""
|
|
270
|
+
import argparse
|
|
271
|
+
parser.add_argument(SUPERVISOR_ARG, nargs="?", const="", default=None, help=argparse.SUPPRESS)
|
|
272
|
+
return parser
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
|
|
276
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
277
|
+
clock: Callable[[], float] = time.monotonic,
|
|
278
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
|
|
279
|
+
"""Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
|
|
280
|
+
|
|
281
|
+
Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
|
|
282
|
+
marker), a held lock exits 75 at once. Under a supervisor that restarts on exit an immediate
|
|
283
|
+
exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
|
|
284
|
+
(0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
|
|
285
|
+
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
286
|
+
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
|
|
287
|
+
marker = supervisor_marker(env, argv)
|
|
288
|
+
if marker["kind"] == "conflict":
|
|
289
|
+
print(marker_conflict_message(marker), file=sys.stderr)
|
|
290
|
+
raise SystemExit(EXIT_ALREADY_RUNNING)
|
|
291
|
+
if wait_seconds is None:
|
|
292
|
+
wait_seconds = lock_wait_budget_seconds(marker)
|
|
293
|
+
deadline = clock() + max(0.0, wait_seconds)
|
|
294
|
+
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
295
|
+
announced = False
|
|
296
|
+
while True:
|
|
297
|
+
try:
|
|
298
|
+
return InstanceLock(data_dir).acquire()
|
|
299
|
+
except AlreadyRunning as exc:
|
|
300
|
+
remaining = deadline - clock()
|
|
301
|
+
if remaining <= 0:
|
|
302
|
+
print(str(exc), file=sys.stderr)
|
|
303
|
+
raise SystemExit(EXIT_ALREADY_RUNNING)
|
|
304
|
+
if not announced:
|
|
305
|
+
print("%s Waiting up to %.0f s for it to exit." % (exc, remaining), file=sys.stderr, flush=True)
|
|
306
|
+
announced = True
|
|
307
|
+
sleep(min(delay, remaining))
|
|
308
|
+
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Logs and stopping (LC-01, LC-12): a rotating log, a capped stdout log, the stop signal and the drain.
|
|
2
|
+
Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import traceback
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
import sys as _sys # noqa: E402
|
|
17
|
+
from pathlib import Path as _Path # noqa: E402
|
|
18
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
19
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
20
|
+
from fabric_base import (
|
|
21
|
+
DRAIN_SECONDS,
|
|
22
|
+
ServiceError,
|
|
23
|
+
_O_BINARY,
|
|
24
|
+
now_iso,
|
|
25
|
+
)
|
|
26
|
+
from fabric_paths import (
|
|
27
|
+
ensure_private_dir,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DRAIN_GRACE_SECONDS = 2.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
EXIT_HARD_STOP = 70
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# LC-12: logs rotate by size, 5 x 5 MB by default.
|
|
38
|
+
LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
LOG_BACKUPS = 5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RotatingLog:
|
|
45
|
+
"""One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a
|
|
46
|
+
0700 directory. ``service.jsonl`` -> ``.1`` ... ``.<backups>``; the oldest falls off.
|
|
47
|
+
|
|
48
|
+
Never pass a token, a cookie, a login code or a request body that may carry one."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, path: Path, max_bytes: int = LOG_MAX_BYTES, backups: int = LOG_BACKUPS):
|
|
51
|
+
if max_bytes < 256 or backups < 1:
|
|
52
|
+
raise ServiceError("A rotating log needs max_bytes >= 256 and at least one backup.")
|
|
53
|
+
self.path = Path(path)
|
|
54
|
+
self.max_bytes = max_bytes
|
|
55
|
+
self.backups = backups
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def _line(self, record: Dict[str, Any]) -> bytes:
|
|
59
|
+
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
60
|
+
if len(data) <= self.max_bytes:
|
|
61
|
+
return data
|
|
62
|
+
record = dict(record, truncated=True)
|
|
63
|
+
message = str(record.get("message", ""))
|
|
64
|
+
while message:
|
|
65
|
+
message = message[: len(message) // 2]
|
|
66
|
+
record["message"] = message
|
|
67
|
+
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
68
|
+
if len(data) <= self.max_bytes:
|
|
69
|
+
return data
|
|
70
|
+
return (json.dumps({"at": record.get("at"), "level": record.get("level"), "message": "",
|
|
71
|
+
"truncated": True}) + "\n").encode()
|
|
72
|
+
|
|
73
|
+
def write(self, level: str, message: str, **fields: Any) -> None:
|
|
74
|
+
record: Dict[str, Any] = {"at": now_iso(), "level": str(level), "message": str(message)}
|
|
75
|
+
record.update(fields)
|
|
76
|
+
data = self._line(record)
|
|
77
|
+
with self._lock:
|
|
78
|
+
ensure_private_dir(self.path.parent)
|
|
79
|
+
try:
|
|
80
|
+
size = self.path.stat().st_size
|
|
81
|
+
except FileNotFoundError:
|
|
82
|
+
size = 0
|
|
83
|
+
if size and size + len(data) > self.max_bytes:
|
|
84
|
+
self._rotate()
|
|
85
|
+
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
|
|
86
|
+
try:
|
|
87
|
+
os.write(fd, data)
|
|
88
|
+
finally:
|
|
89
|
+
os.close(fd)
|
|
90
|
+
|
|
91
|
+
def _rotate(self) -> None:
|
|
92
|
+
for index in range(self.backups - 1, 0, -1):
|
|
93
|
+
older = Path("%s.%d" % (self.path, index))
|
|
94
|
+
if older.exists():
|
|
95
|
+
os.replace(older, "%s.%d" % (self.path, index + 1))
|
|
96
|
+
os.replace(self.path, "%s.1" % self.path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cap_stdout_log(path: Path, max_bytes: int = LOG_MAX_BYTES) -> bool:
|
|
100
|
+
"""Call once at start: a launchd stdout file over ``max_bytes`` is copied to ``<path>.1`` and
|
|
101
|
+
truncated in place. launchd holds the file open for append, so a rename would leave it
|
|
102
|
+
writing to the old name; truncation keeps its descriptor valid. Returns True when capped."""
|
|
103
|
+
path = Path(path)
|
|
104
|
+
try:
|
|
105
|
+
size = path.stat().st_size
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return False
|
|
108
|
+
if size <= max_bytes:
|
|
109
|
+
return False
|
|
110
|
+
backup = Path("%s.1" % path)
|
|
111
|
+
shutil.copyfile(path, backup)
|
|
112
|
+
os.chmod(backup, 0o600)
|
|
113
|
+
with open(path, "r+b") as handle:
|
|
114
|
+
handle.truncate(0)
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Stopping(ServiceError):
|
|
119
|
+
"""The service is stopping and takes no new work."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Drain:
|
|
123
|
+
"""SIGTERM/SIGINT: stop taking new work, drain in-flight work until ``deadline``, then hand
|
|
124
|
+
over with ``on_stop(drained)``. A hand-over that hangs is cut by a hard exit
|
|
125
|
+
(``EXIT_HARD_STOP``) ``grace`` seconds later, so quit reaches exit inside the lifecycle bound.
|
|
126
|
+
|
|
127
|
+
drain = fs.Drain()
|
|
128
|
+
drain.install(lambda drained: server.shutdown(),
|
|
129
|
+
on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
|
|
130
|
+
with drain.work(): # raises fs.Stopping once a stop has begun
|
|
131
|
+
...
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, deadline: float = DRAIN_SECONDS, grace: float = DRAIN_GRACE_SECONDS):
|
|
135
|
+
self.deadline = deadline
|
|
136
|
+
self.grace = grace
|
|
137
|
+
self._cond = threading.Condition()
|
|
138
|
+
self._inflight = 0
|
|
139
|
+
self._stopping = False
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def stopping(self) -> bool:
|
|
143
|
+
return self._stopping
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def inflight(self) -> int:
|
|
147
|
+
return self._inflight
|
|
148
|
+
|
|
149
|
+
@contextlib.contextmanager
|
|
150
|
+
def work(self):
|
|
151
|
+
with self._cond:
|
|
152
|
+
if self._stopping:
|
|
153
|
+
raise Stopping("The service is stopping and takes no new work.")
|
|
154
|
+
self._inflight += 1
|
|
155
|
+
try:
|
|
156
|
+
yield
|
|
157
|
+
finally:
|
|
158
|
+
with self._cond:
|
|
159
|
+
self._inflight -= 1
|
|
160
|
+
self._cond.notify_all()
|
|
161
|
+
|
|
162
|
+
def request_stop(self) -> bool:
|
|
163
|
+
"""Refuse new work from now on; False when a stop had already begun."""
|
|
164
|
+
with self._cond:
|
|
165
|
+
if self._stopping:
|
|
166
|
+
return False
|
|
167
|
+
self._stopping = True
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
171
|
+
"""Wait for in-flight work to finish; True when it did inside ``timeout``."""
|
|
172
|
+
end = None if timeout is None else time.monotonic() + timeout
|
|
173
|
+
with self._cond:
|
|
174
|
+
while self._inflight > 0:
|
|
175
|
+
left = None if end is None else end - time.monotonic()
|
|
176
|
+
if left is not None and left <= 0:
|
|
177
|
+
return False
|
|
178
|
+
self._cond.wait(left)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def install(self, on_stop: Callable[[bool], None], *, on_stopping: Optional[Callable[[], None]] = None,
|
|
182
|
+
signals: Sequence[int] = (signal.SIGTERM, signal.SIGINT)) -> "Drain":
|
|
183
|
+
"""Install the handlers (main thread only). A second signal changes nothing: the hard-exit
|
|
184
|
+
timer already holds the deadline."""
|
|
185
|
+
def handler(_signum: int, _frame: Any) -> None:
|
|
186
|
+
if self.request_stop():
|
|
187
|
+
threading.Thread(target=self._stop, args=(on_stopping, on_stop), daemon=True).start()
|
|
188
|
+
|
|
189
|
+
for sig in signals:
|
|
190
|
+
signal.signal(sig, handler)
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def _stop(self, on_stopping: Optional[Callable[[], None]], on_stop: Callable[[bool], None]) -> None:
|
|
194
|
+
hard = threading.Timer(self.deadline + self.grace, os._exit, args=(EXIT_HARD_STOP,))
|
|
195
|
+
hard.daemon = True
|
|
196
|
+
hard.start()
|
|
197
|
+
if on_stopping is not None:
|
|
198
|
+
try:
|
|
199
|
+
on_stopping()
|
|
200
|
+
except Exception: # a failing stop notice must not stop the drain
|
|
201
|
+
traceback.print_exc()
|
|
202
|
+
on_stop(self.wait(self.deadline))
|