@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
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069),
|
|
2
|
+
build identity and the well-known document, the loopback HTTP server. Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import http.server
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socketserver
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import sys as _sys # noqa: E402
|
|
16
|
+
from pathlib import Path as _Path # noqa: E402
|
|
17
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
18
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
19
|
+
from fabric_base import (
|
|
20
|
+
PROTOCOL,
|
|
21
|
+
ServiceError,
|
|
22
|
+
_ID,
|
|
23
|
+
_INSTANCE,
|
|
24
|
+
_LOCAL_PATH,
|
|
25
|
+
_TASK,
|
|
26
|
+
_UNIT,
|
|
27
|
+
expand,
|
|
28
|
+
now_iso,
|
|
29
|
+
)
|
|
30
|
+
from fabric_paths import (
|
|
31
|
+
atomic_write,
|
|
32
|
+
ensure_private_dir,
|
|
33
|
+
services_dir,
|
|
34
|
+
)
|
|
35
|
+
from fabric_protocols import (
|
|
36
|
+
protocols_findings,
|
|
37
|
+
)
|
|
38
|
+
from fabric_usage import (
|
|
39
|
+
ROUTES,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
|
|
44
|
+
# document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
45
|
+
DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
DASHBOARD_KIT = "fabric-dashboard-kit@1.1.1"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
STATUSES = ("starting", "ready", "degraded", "stopping")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_ORIGIN = re.compile(r"^http://127\.0\.0\.1:([0-9]{3,5})$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# DEC-0019: a remote placement — an online agent or dashboard — lives at an https DNS name.
|
|
61
|
+
_REMOTE_ORIGIN = re.compile(r"^https://((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_RESERVED_HOST = re.compile(r"(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)$")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
PLACEMENTS = ("local", "remote")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# DEC-0032: the supervisor per system and the field that names its job.
|
|
71
|
+
# DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app
|
|
72
|
+
# a person installs and starts; its descriptor says it is installed, never that it runs).
|
|
73
|
+
MANAGERS = ("launchd", "systemd", "task-scheduler", "node", "person", "none")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069).
|
|
77
|
+
_NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
|
|
84
|
+
"""ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
|
|
85
|
+
no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
|
|
86
|
+
on a Mac with a slow resolver the port then stays bound but silent, so a host sees
|
|
87
|
+
neither an answer nor a refusal."""
|
|
88
|
+
|
|
89
|
+
daemon_threads = True
|
|
90
|
+
|
|
91
|
+
def server_bind(self) -> None:
|
|
92
|
+
socketserver.TCPServer.server_bind(self)
|
|
93
|
+
host, port = self.server_address[:2]
|
|
94
|
+
self.server_name, self.server_port = host, port
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
98
|
+
return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_descriptors(directory: Optional[Path] = None) -> List[Tuple[Path, Dict[str, Any]]]:
|
|
102
|
+
root = directory or services_dir()
|
|
103
|
+
found: List[Tuple[Path, Dict[str, Any]]] = []
|
|
104
|
+
if not root.is_dir():
|
|
105
|
+
return found
|
|
106
|
+
for path in sorted(root.glob("*.json")):
|
|
107
|
+
try:
|
|
108
|
+
found.append((path, json.loads(path.read_text(encoding="utf-8"))))
|
|
109
|
+
except (OSError, ValueError):
|
|
110
|
+
continue
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_descriptor(descriptor: Dict[str, Any]) -> List[str]:
|
|
115
|
+
"""Structural checks mirroring service-descriptor.schema.json (the schema stays normative)."""
|
|
116
|
+
problems: List[str] = []
|
|
117
|
+
remote = placement_of(descriptor) == "remote"
|
|
118
|
+
if "placement" in descriptor and descriptor["placement"] not in PLACEMENTS:
|
|
119
|
+
problems.append("placement must be local or remote")
|
|
120
|
+
life_in = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
121
|
+
app = life_in.get("manager") == "person"
|
|
122
|
+
# DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
|
|
123
|
+
required = ("protocol", "id", "instance", "name", "auth", "lifecycle", "installedAt", "installedBy") + (() if app else ("origin",))
|
|
124
|
+
if not remote:
|
|
125
|
+
required = required + ("paths",)
|
|
126
|
+
for key in required:
|
|
127
|
+
if key not in descriptor:
|
|
128
|
+
problems.append("missing %s" % key)
|
|
129
|
+
if problems:
|
|
130
|
+
return problems
|
|
131
|
+
if descriptor["protocol"] != PROTOCOL:
|
|
132
|
+
problems.append("protocol must be %s" % PROTOCOL)
|
|
133
|
+
if not _ID.match(str(descriptor["id"])):
|
|
134
|
+
problems.append("id must match %s" % _ID.pattern)
|
|
135
|
+
if not _INSTANCE.match(str(descriptor["instance"])):
|
|
136
|
+
problems.append("instance must match %s" % _INSTANCE.pattern)
|
|
137
|
+
if remote:
|
|
138
|
+
problems.extend(remote_origin_problems(descriptor["origin"]))
|
|
139
|
+
elif "origin" in descriptor and not _ORIGIN.match(str(descriptor["origin"])):
|
|
140
|
+
problems.append("origin must be http://127.0.0.1:<port>")
|
|
141
|
+
auth = descriptor.get("auth") or {}
|
|
142
|
+
if "tokenFile" not in auth:
|
|
143
|
+
problems.append("auth.tokenFile is required")
|
|
144
|
+
elif not _LOCAL_PATH.match(str(auth["tokenFile"])):
|
|
145
|
+
problems.append("auth.tokenFile must be a local path, never a network share")
|
|
146
|
+
paths = descriptor.get("paths") or {}
|
|
147
|
+
for value in ([paths["data"]] if "data" in paths else []) + list(paths.get("logs") or []):
|
|
148
|
+
if not _LOCAL_PATH.match(str(value)):
|
|
149
|
+
problems.append("paths must be local paths, never a network share: %r" % value)
|
|
150
|
+
header = auth.get("header", "Authorization")
|
|
151
|
+
scheme = auth.get("scheme", "Bearer")
|
|
152
|
+
if not (isinstance(header, str) and _AUTH_HEADER.fullmatch(header)):
|
|
153
|
+
problems.append("auth.header must match %s" % _AUTH_HEADER.pattern)
|
|
154
|
+
if scheme not in ("Bearer", "none"):
|
|
155
|
+
problems.append("auth.scheme is Bearer or none")
|
|
156
|
+
if header != "Authorization" and scheme != "none":
|
|
157
|
+
problems.append("a custom auth header carries the raw token: scheme must be none")
|
|
158
|
+
life = descriptor.get("lifecycle") or {}
|
|
159
|
+
manager = life.get("manager")
|
|
160
|
+
if manager not in MANAGERS:
|
|
161
|
+
problems.append("lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none")
|
|
162
|
+
# DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
|
|
163
|
+
if manager == "person" and "app" not in life:
|
|
164
|
+
problems.append("a person's app declares lifecycle.app, the installed app")
|
|
165
|
+
if "app" in life and manager != "person":
|
|
166
|
+
problems.append("lifecycle.app belongs to manager person only")
|
|
167
|
+
if "app" in life and not _LOCAL_PATH.match(str(life["app"])):
|
|
168
|
+
problems.append("lifecycle.app must be a local path")
|
|
169
|
+
if manager == "launchd" and not (life.get("label") and str(life.get("plist", "")).endswith(".plist")):
|
|
170
|
+
problems.append("a launchd service declares label and plist")
|
|
171
|
+
if "plist" in life and not _LOCAL_PATH.match(str(life["plist"])):
|
|
172
|
+
problems.append("lifecycle.plist must be a local path")
|
|
173
|
+
# DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
174
|
+
for field in ("label", "plist"):
|
|
175
|
+
if field in life and manager in _NOT_LAUNCHD:
|
|
176
|
+
problems.append("lifecycle.%s belongs to launchd, not to %s" % (field, manager))
|
|
177
|
+
# DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
178
|
+
for field, owner, pattern in (("unit", "systemd", _UNIT), ("task", "task-scheduler", _TASK)):
|
|
179
|
+
if manager == owner and field not in life:
|
|
180
|
+
problems.append("a %s service declares %s" % (owner, field))
|
|
181
|
+
if field in life and manager != owner:
|
|
182
|
+
problems.append("lifecycle.%s belongs to manager %s only" % (field, owner))
|
|
183
|
+
if field in life and not pattern.match(str(life[field])):
|
|
184
|
+
problems.append("lifecycle.%s must match %s" % (field, pattern.pattern))
|
|
185
|
+
if remote:
|
|
186
|
+
if manager != "none":
|
|
187
|
+
problems.append("a remote service is supervised by its platform: lifecycle.manager must be none")
|
|
188
|
+
for field, of in (("label", "launchd"), ("plist", "launchd"), ("unit", "systemd"), ("task", "Task Scheduler")):
|
|
189
|
+
if field in life:
|
|
190
|
+
problems.append("a remote service has no %s %s" % (of, field))
|
|
191
|
+
if "update" in (descriptor.get("commands") or {}):
|
|
192
|
+
problems.append("a remote service declares no update command")
|
|
193
|
+
problems.extend(descriptor_member_problems(descriptor))
|
|
194
|
+
for name, argv in (descriptor.get("commands") or {}).items():
|
|
195
|
+
if name not in ("doctor", "update"):
|
|
196
|
+
problems.append("unknown command %s" % name)
|
|
197
|
+
elif not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
|
|
198
|
+
problems.append("command %s must be an argument array" % name)
|
|
199
|
+
elif not _LOCAL_PATH.match(argv[0]):
|
|
200
|
+
problems.append("command %s must start with an absolute or ~/ executable" % name)
|
|
201
|
+
return problems
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
#: The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053).
|
|
205
|
+
PEER_DATA_CLASSES = ("public", "internal", "confidential", "restricted")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
#: The kind of a runner's sign-in, never a credential's value (DEC-0061).
|
|
209
|
+
CREDENTIAL_KINDS = ("subscription", "key", "platform-metered", "none")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
#: A subscription runs only in its vendor's runner (DEC-0046): runner kind → login.
|
|
213
|
+
SUBSCRIPTION_RUNNERS = {"claude-code": "claude", "kimi-code": "kimi", "codex": "chatgpt"}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_PEER_ENDPOINT = re.compile(r"^https://(?P<host>([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$", re.ASCII)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_RESIDENCY = re.compile(r"^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$", re.ASCII)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
_RUNNER_KIND_NAME = re.compile(r"^[a-z][a-z0-9-]{1,62}$", re.ASCII)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _pointer(name: str) -> str:
|
|
226
|
+
"""A JSON Pointer token (RFC 6901 §4)."""
|
|
227
|
+
return str(name).replace("~", "~0").replace("/", "~1")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _text(value: Any, low: int = 1, high: int = 200) -> bool:
|
|
231
|
+
return isinstance(value, str) and low <= len(value) <= high
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def descriptor_member_problems(descriptor: Dict[str, Any]) -> List[str]:
|
|
235
|
+
"""DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with
|
|
236
|
+
FAC-SEM-085 (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`,
|
|
237
|
+
named by its place, never its value)."""
|
|
238
|
+
import fabric_secrets # noqa: E402 - beside this file; the contract's detector (DEC-0044)
|
|
239
|
+
problems: List[str] = []
|
|
240
|
+
life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
241
|
+
by = descriptor.get("installedBy")
|
|
242
|
+
places: List[Tuple[str, Any]] = []
|
|
243
|
+
if isinstance(by, str):
|
|
244
|
+
if not _text(by):
|
|
245
|
+
problems.append("installedBy is the installer and its version, 1 to 200 characters")
|
|
246
|
+
places.append(("/installedBy", by))
|
|
247
|
+
elif isinstance(by, dict):
|
|
248
|
+
problems += ["installedBy has no member %s" % k for k in by if k not in ("by", "role", "tool")]
|
|
249
|
+
if by.get("by") not in ("person", "node", "organization"):
|
|
250
|
+
problems.append("installedBy.by is person, node or organization")
|
|
251
|
+
if by.get("by") == "person" and by.get("role") not in ("operator", "administrator"):
|
|
252
|
+
problems.append("installedBy.role is operator or administrator for a person")
|
|
253
|
+
if by.get("by") != "person" and "role" in by:
|
|
254
|
+
problems.append("installedBy.role belongs to a person only")
|
|
255
|
+
if not _text(by.get("tool")):
|
|
256
|
+
problems.append("installedBy.tool is the installer and its version, 1 to 200 characters")
|
|
257
|
+
places += [("/installedBy/%s" % _pointer(k), v) for k, v in by.items()]
|
|
258
|
+
else:
|
|
259
|
+
problems.append("installedBy is the installer's name or {by, role?, tool}")
|
|
260
|
+
if life.get("manager") == "person" and not (isinstance(by, dict) and by.get("by") == "person"):
|
|
261
|
+
problems.append("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)")
|
|
262
|
+
runner = descriptor.get("runner")
|
|
263
|
+
if "runner" in descriptor:
|
|
264
|
+
if not isinstance(runner, dict):
|
|
265
|
+
problems.append("runner is {kind?, credentialKind}")
|
|
266
|
+
runner = {}
|
|
267
|
+
problems += ["runner has no member %s" % k for k in runner if k not in ("kind", "credentialKind")]
|
|
268
|
+
if runner.get("credentialKind") not in CREDENTIAL_KINDS:
|
|
269
|
+
problems.append("runner.credentialKind is subscription, key, platform-metered or none")
|
|
270
|
+
if "kind" in runner and not (isinstance(runner["kind"], str) and _RUNNER_KIND_NAME.match(runner["kind"])):
|
|
271
|
+
problems.append("runner.kind is a runner kind's name")
|
|
272
|
+
if runner.get("credentialKind") == "subscription" and runner.get("kind") not in SUBSCRIPTION_RUNNERS:
|
|
273
|
+
problems.append("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex")
|
|
274
|
+
places += [("/runner/%s" % _pointer(k), v) for k, v in runner.items()]
|
|
275
|
+
if "peer" in descriptor:
|
|
276
|
+
peer = descriptor.get("peer")
|
|
277
|
+
if not isinstance(peer, dict):
|
|
278
|
+
problems.append("peer is {endpoint, custody, residency, maxDataClass}")
|
|
279
|
+
peer = {}
|
|
280
|
+
problems += ["peer has no member %s" % k for k in peer if k not in ("endpoint", "custody", "residency", "maxDataClass")]
|
|
281
|
+
endpoint = peer.get("endpoint")
|
|
282
|
+
match = _PEER_ENDPOINT.match(endpoint) if isinstance(endpoint, str) and len(endpoint) <= 2048 else None
|
|
283
|
+
if not match:
|
|
284
|
+
problems.append("peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal")
|
|
285
|
+
elif _RESERVED_HOST.search(match.group("host")):
|
|
286
|
+
problems.append("FAC-SEM-085: a service offered to others cannot be reached at the reserved name %s" % match.group("host"))
|
|
287
|
+
if peer.get("custody") not in ("organization-managed", "personal"):
|
|
288
|
+
problems.append("peer.custody is organization-managed or personal")
|
|
289
|
+
residency = peer.get("residency")
|
|
290
|
+
if (not isinstance(residency, list) or not 1 <= len(residency) <= 32 or len(set(map(json.dumps, residency))) != len(residency)
|
|
291
|
+
or not all(isinstance(r, str) and _RESIDENCY.match(r) for r in residency)):
|
|
292
|
+
problems.append("peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code")
|
|
293
|
+
if peer.get("maxDataClass") not in PEER_DATA_CLASSES:
|
|
294
|
+
problems.append("peer.maxDataClass is public, internal, confidential or restricted")
|
|
295
|
+
if "runner" not in descriptor:
|
|
296
|
+
problems.append("a service offered to others declares runner (DEC-0061)")
|
|
297
|
+
elif isinstance(runner, dict) and runner.get("credentialKind") == "subscription":
|
|
298
|
+
problems.append("a service offered to others never runs on a subscription (DEC-0061)")
|
|
299
|
+
for place, value in places:
|
|
300
|
+
if isinstance(value, str) and fabric_secrets.secret_argument(value):
|
|
301
|
+
problems.append("FAC-SEM-086: %s holds a value shaped like a credential; it names a kind, a role or an installer" % place)
|
|
302
|
+
return problems
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def peer_refusal(descriptor: Any, data_class: Any) -> Optional[str]:
|
|
306
|
+
"""The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
|
|
307
|
+
`not-offered` (no `peer`), `peer-invalid` (its maximum is not one of the four classes), `data-class-unknown` (the
|
|
308
|
+
request's class is not one of them, exactly as written) or `above-max-data-class`; None when it may. The contract's
|
|
309
|
+
`peerRefusal`."""
|
|
310
|
+
if not isinstance(descriptor, dict) or not isinstance(descriptor.get("peer"), dict):
|
|
311
|
+
return "not-offered"
|
|
312
|
+
ceiling = descriptor["peer"].get("maxDataClass")
|
|
313
|
+
if not isinstance(ceiling, str) or ceiling not in PEER_DATA_CLASSES:
|
|
314
|
+
return "peer-invalid"
|
|
315
|
+
if not isinstance(data_class, str) or data_class not in PEER_DATA_CLASSES:
|
|
316
|
+
return "data-class-unknown"
|
|
317
|
+
return "above-max-data-class" if PEER_DATA_CLASSES.index(data_class) > PEER_DATA_CLASSES.index(ceiling) else None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def app_descriptor_action(existing: Any, tool: str, app_path: str) -> str:
|
|
321
|
+
"""What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` when it runs from a
|
|
322
|
+
translocated copy (macOS App Translocation); otherwise `write` when there is none, when it is not an app's descriptor,
|
|
323
|
+
when its version (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's
|
|
324
|
+
`appDescriptorAction`."""
|
|
325
|
+
if "/AppTranslocation/" in app_path:
|
|
326
|
+
return "keep"
|
|
327
|
+
if not isinstance(existing, dict) or not isinstance(existing.get("installedBy"), dict):
|
|
328
|
+
return "write"
|
|
329
|
+
recorded = existing["lifecycle"].get("app", _MISSING_APP) if isinstance(existing.get("lifecycle"), dict) else _MISSING_APP
|
|
330
|
+
return "keep" if existing["installedBy"].get("tool", _MISSING_APP) == tool and recorded == app_path else "write"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_MISSING_APP = object()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def app_descriptor_stale(descriptor: Any, app_present: bool) -> bool:
|
|
337
|
+
"""Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find.
|
|
338
|
+
A supervised service's descriptor is never stale by this test. The contract's `appDescriptorStale`."""
|
|
339
|
+
life = descriptor.get("lifecycle") if isinstance(descriptor, dict) else None
|
|
340
|
+
return isinstance(life, dict) and life.get("manager") == "person" and not app_present
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def app_present(descriptor: Dict[str, Any]) -> bool:
|
|
344
|
+
"""Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file)."""
|
|
345
|
+
life = descriptor.get("lifecycle") if isinstance(descriptor.get("lifecycle"), dict) else {}
|
|
346
|
+
app = life.get("app")
|
|
347
|
+
try:
|
|
348
|
+
return isinstance(app, str) and expand(app).exists()
|
|
349
|
+
except ServiceError: # not a local path: nothing installed there
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
#: Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`.
|
|
354
|
+
FABRIC_SERVICE = ("fabric", "default")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def fabric_button_presence(directory: Optional[Path] = None) -> str:
|
|
358
|
+
"""`present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
|
|
359
|
+
descriptor is registered and, for an app's descriptor, its app is still installed. A server sets
|
|
360
|
+
`data-fabric="absent"` on the button's script otherwise. The button only follows a link, so the liveness file is
|
|
361
|
+
not read here; a caller that sends anything reads it."""
|
|
362
|
+
path = descriptor_path(*FABRIC_SERVICE, directory=directory)
|
|
363
|
+
try:
|
|
364
|
+
found = json.loads(path.read_text(encoding="utf-8"))
|
|
365
|
+
except (OSError, ValueError):
|
|
366
|
+
return "absent"
|
|
367
|
+
if not isinstance(found, dict) or validate_descriptor(found):
|
|
368
|
+
return "absent"
|
|
369
|
+
return "absent" if app_descriptor_stale(found, app_present(found)) else "present"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def placement_of(descriptor: Dict[str, Any]) -> str:
|
|
373
|
+
"""DEC-0019: ``local`` unless the descriptor says ``remote``."""
|
|
374
|
+
return "remote" if (descriptor or {}).get("placement") == "remote" else "local"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def remote_origin_problems(origin: Any) -> List[str]:
|
|
378
|
+
"""Problems with a remote origin: https, a public DNS name, an optional port, nothing else."""
|
|
379
|
+
match = _REMOTE_ORIGIN.match(str(origin or ""))
|
|
380
|
+
if not match:
|
|
381
|
+
return ["a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal"]
|
|
382
|
+
if _RESERVED_HOST.search(match.group(1)):
|
|
383
|
+
return ["a remote service cannot live on the reserved name %s" % match.group(1)]
|
|
384
|
+
if match.group(2) is not None and not 1 <= int(match.group(2)) <= 65535:
|
|
385
|
+
return ["the origin port is out of range"]
|
|
386
|
+
return []
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def port_of(origin: str) -> int:
|
|
390
|
+
match = _ORIGIN.match(origin)
|
|
391
|
+
if not match:
|
|
392
|
+
raise ServiceError("Origin %r is not http://127.0.0.1:<port>." % origin)
|
|
393
|
+
return int(match.group(1))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None) -> Path:
|
|
397
|
+
"""Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim)."""
|
|
398
|
+
problems = validate_descriptor(descriptor)
|
|
399
|
+
if problems:
|
|
400
|
+
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
401
|
+
root = directory or services_dir()
|
|
402
|
+
me = "%s.%s" % (descriptor["id"], descriptor["instance"])
|
|
403
|
+
# DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
404
|
+
port = None if placement_of(descriptor) == "remote" or "origin" not in descriptor else port_of(descriptor["origin"])
|
|
405
|
+
for path, other in read_descriptors(root):
|
|
406
|
+
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
407
|
+
if key == me or port is None: # a remote origin's port is another computer's; an app claims none
|
|
408
|
+
continue
|
|
409
|
+
try:
|
|
410
|
+
other_port = port_of(str(other.get("origin", "")))
|
|
411
|
+
except ServiceError:
|
|
412
|
+
continue
|
|
413
|
+
if other_port == port:
|
|
414
|
+
raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
|
|
415
|
+
ensure_private_dir(root)
|
|
416
|
+
target = descriptor_path(descriptor["id"], descriptor["instance"], root)
|
|
417
|
+
atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
|
|
418
|
+
return target
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def remove_descriptor(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> bool:
|
|
422
|
+
try:
|
|
423
|
+
descriptor_path(service_id, instance, directory).unlink()
|
|
424
|
+
return True
|
|
425
|
+
except FileNotFoundError:
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def build_info(repo_root: Optional[Path] = None, package_file: Optional[Path] = None) -> Dict[str, Any]:
|
|
430
|
+
"""Commit (env FABRIC_BUILD_COMMIT, else git) or a sha256 digest of the package file."""
|
|
431
|
+
commit = os.environ.get("FABRIC_BUILD_COMMIT")
|
|
432
|
+
dirty: Optional[bool] = None
|
|
433
|
+
if not commit and repo_root is not None:
|
|
434
|
+
try:
|
|
435
|
+
commit = subprocess.run(["git", "-C", str(repo_root), "rev-parse", "--short=12", "HEAD"],
|
|
436
|
+
capture_output=True, text=True, timeout=5, check=True).stdout.strip()
|
|
437
|
+
status = subprocess.run(["git", "-C", str(repo_root), "status", "--porcelain", "--untracked-files=no"],
|
|
438
|
+
capture_output=True, text=True, timeout=5, check=True).stdout
|
|
439
|
+
dirty = bool(status.strip())
|
|
440
|
+
except (OSError, subprocess.SubprocessError):
|
|
441
|
+
commit = None
|
|
442
|
+
info: Dict[str, Any] = {"builtAt": now_iso()}
|
|
443
|
+
if commit:
|
|
444
|
+
info["commit"] = commit
|
|
445
|
+
if dirty is not None:
|
|
446
|
+
info["dirty"] = dirty
|
|
447
|
+
elif package_file is not None and Path(package_file).is_file():
|
|
448
|
+
info["digest"] = "sha256:" + hashlib.sha256(Path(package_file).read_bytes()).hexdigest()
|
|
449
|
+
else:
|
|
450
|
+
raise ServiceError("No build identity: set FABRIC_BUILD_COMMIT, pass repo_root or package_file.")
|
|
451
|
+
return info
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
_THEME_ID = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
_KIT_REF = re.compile(r"^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def dashboard_extension_problems(value: Any) -> List[str]:
|
|
461
|
+
"""What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json)."""
|
|
462
|
+
if not isinstance(value, dict):
|
|
463
|
+
return ["the declaration is not an object"]
|
|
464
|
+
out: List[str] = []
|
|
465
|
+
extra = sorted(set(value) - {"minWidth", "appearance", "themes", "kit"})
|
|
466
|
+
if extra:
|
|
467
|
+
out.append("unknown field(s): %s" % ", ".join(extra))
|
|
468
|
+
width = value.get("minWidth")
|
|
469
|
+
if isinstance(width, bool) or not isinstance(width, int):
|
|
470
|
+
out.append("minWidth is required and must be a whole number of CSS pixels")
|
|
471
|
+
elif not 360 <= width <= 1280:
|
|
472
|
+
out.append("minWidth %d is outside 360…1280" % width)
|
|
473
|
+
if "appearance" in value:
|
|
474
|
+
a = value["appearance"]
|
|
475
|
+
if not isinstance(a, list) or len(a) > 2 or len(set(map(str, a))) != len(a) or any(x not in ("scheme", "theme") for x in a):
|
|
476
|
+
out.append("appearance is a list of distinct fields from scheme, theme")
|
|
477
|
+
if "themes" in value:
|
|
478
|
+
t = value["themes"]
|
|
479
|
+
if not isinstance(t, list) or not 1 <= len(t) <= 32 or len(set(map(str, t))) != len(t) \
|
|
480
|
+
or any(not isinstance(x, str) or not _THEME_ID.match(x) for x in t):
|
|
481
|
+
out.append("themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$")
|
|
482
|
+
if "kit" in value and not (isinstance(value["kit"], str) and _KIT_REF.match(value["kit"])):
|
|
483
|
+
out.append("kit is name@semver, for example %s" % DASHBOARD_KIT)
|
|
484
|
+
return out
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def dashboard_extension(min_width: int = 360, appearance: Sequence[str] = ("scheme", "theme"),
|
|
488
|
+
themes: Optional[Sequence[str]] = REFERENCE_THEMES,
|
|
489
|
+
kit: Optional[str] = DASHBOARD_KIT) -> Dict[str, Any]:
|
|
490
|
+
"""The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
|
|
491
|
+
when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that."""
|
|
492
|
+
value: Dict[str, Any] = {"minWidth": min_width}
|
|
493
|
+
if appearance:
|
|
494
|
+
value["appearance"] = list(appearance)
|
|
495
|
+
if themes:
|
|
496
|
+
value["themes"] = list(themes)
|
|
497
|
+
if kit:
|
|
498
|
+
value["kit"] = kit
|
|
499
|
+
problems = dashboard_extension_problems(value)
|
|
500
|
+
if problems:
|
|
501
|
+
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
502
|
+
return {DASHBOARD_EXTENSION_KEY: value}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
_PROFILE_ID = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
_RUNNER_KIND = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
_MODEL_ID = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}/[a-z0-9][a-z0-9._:-]{0,127}$")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def configuration_problems(doc: Any) -> List[str]:
|
|
515
|
+
"""DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it."""
|
|
516
|
+
if not isinstance(doc, dict):
|
|
517
|
+
return ["configuration is an object"]
|
|
518
|
+
problems = ["configuration has no member %s" % k for k in doc if k not in ("slots", "resolved")]
|
|
519
|
+
slots = doc.get("slots")
|
|
520
|
+
if not isinstance(slots, list) or len(slots) > 2 or len(set(map(str, slots))) != len(slots) or not all(s in ("runner", "model") for s in slots):
|
|
521
|
+
problems.append("slots is a list of runner and model, each once")
|
|
522
|
+
resolved = doc.get("resolved")
|
|
523
|
+
if not isinstance(resolved, dict):
|
|
524
|
+
return problems + ["resolved is an object"]
|
|
525
|
+
for k, v in resolved.items():
|
|
526
|
+
ok = {"from": lambda x: x in ("request", "agent", "binding", "estate"),
|
|
527
|
+
"runnerProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
|
|
528
|
+
"modelProfile": lambda x: isinstance(x, str) and bool(_PROFILE_ID.match(x)),
|
|
529
|
+
"runnerKind": lambda x: isinstance(x, str) and bool(_RUNNER_KIND.match(x)),
|
|
530
|
+
"route": lambda x: x in ROUTES,
|
|
531
|
+
"models": lambda x: isinstance(x, list) and len(x) <= 16 and all(isinstance(m, str) and _MODEL_ID.match(m) for m in x)}.get(k)
|
|
532
|
+
if ok is None:
|
|
533
|
+
problems.append("resolved has no member %s" % k)
|
|
534
|
+
elif not ok(v):
|
|
535
|
+
problems.append("resolved.%s is not valid" % k)
|
|
536
|
+
return problems
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def build_well_known(*, service_id: str, instance: str, name: str, version: str, build: Dict[str, Any],
|
|
540
|
+
started_at: str, status: str, degraded: Sequence[Dict[str, str]],
|
|
541
|
+
surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
|
|
542
|
+
update_available: Optional[str] = None, pid: Optional[int] = None,
|
|
543
|
+
extensions: Optional[Dict[str, Any]] = None,
|
|
544
|
+
configuration: Optional[Dict[str, Any]] = None,
|
|
545
|
+
protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
|
|
546
|
+
"""The unauthenticated identity answer. `degraded` is always present; ready means empty.
|
|
547
|
+
`configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
|
|
548
|
+
`extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
|
|
549
|
+
in it must be valid (dashboard_extension builds one)."""
|
|
550
|
+
if status not in STATUSES:
|
|
551
|
+
raise ServiceError("status must be one of %s." % ", ".join(STATUSES))
|
|
552
|
+
degraded = list(degraded)
|
|
553
|
+
if status == "ready" and degraded:
|
|
554
|
+
status = "degraded"
|
|
555
|
+
if "events" not in surfaces:
|
|
556
|
+
raise ServiceError("surfaces.events is required.")
|
|
557
|
+
doc: Dict[str, Any] = {
|
|
558
|
+
"protocol": PROTOCOL,
|
|
559
|
+
"service": {"id": service_id, "instance": instance, "name": name, "version": version, "build": build},
|
|
560
|
+
"process": {"pid": pid or os.getpid(), "startedAt": started_at},
|
|
561
|
+
"status": status,
|
|
562
|
+
"degraded": degraded,
|
|
563
|
+
"surfaces": surfaces,
|
|
564
|
+
"update": {"available": update_available},
|
|
565
|
+
}
|
|
566
|
+
if summary:
|
|
567
|
+
doc["summary"] = list(summary)[:6]
|
|
568
|
+
if extensions:
|
|
569
|
+
if DASHBOARD_EXTENSION_KEY in extensions:
|
|
570
|
+
problems = dashboard_extension_problems(extensions[DASHBOARD_EXTENSION_KEY])
|
|
571
|
+
if problems:
|
|
572
|
+
raise ServiceError("Dashboard declaration: %s." % "; ".join(problems))
|
|
573
|
+
doc["extensions"] = dict(extensions)
|
|
574
|
+
if configuration is not None:
|
|
575
|
+
problems = configuration_problems(configuration)
|
|
576
|
+
if problems:
|
|
577
|
+
raise ServiceError("configuration: %s." % "; ".join(problems))
|
|
578
|
+
doc["configuration"] = configuration
|
|
579
|
+
if protocols is not None:
|
|
580
|
+
# DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so
|
|
581
|
+
# a service lists them only once its hosts are pinned at or after it.
|
|
582
|
+
problems = protocols_findings(list(protocols))
|
|
583
|
+
if problems:
|
|
584
|
+
raise ServiceError("protocols: %s." % "; ".join(f["message"] for f in problems))
|
|
585
|
+
doc["protocols"] = list(protocols)
|
|
586
|
+
return doc
|