@passioncode-ai/passioncode 0.1.4
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 +73 -0
- package/LICENSE +22 -0
- package/README.md +69 -0
- package/SECURITY.md +42 -0
- package/bin/passioncode.js +65 -0
- package/family.json +45 -0
- package/lib/launcher.js +361 -0
- package/package.json +48 -0
- package/payload/.claude-plugin/marketplace.json +47 -0
- package/payload/manifest.json +77 -0
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +24 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +175 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +71 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-bundle.md +62 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +55 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +537 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +126 -0
- package/payload/plugins/observatory-log/.claude-plugin/plugin.json +19 -0
- package/payload/plugins/observatory-log/hooks/ask-why.py +98 -0
- package/payload/plugins/observatory-log/hooks/hooks.json +31 -0
- package/payload/plugins/observatory-log/hooks/record-turn.sh +81 -0
- package/payload/plugins/observatory-log/hooks/session-start.sh +27 -0
- package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +111 -0
- package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +115 -0
- package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +87 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +13 -0
- package/payload/plugins/passioncode/hooks/hooks.json +10 -0
- package/payload/plugins/passioncode/hooks/probe.js +23 -0
- package/payload/plugins/passioncode/hooks/session-start.js +45 -0
- package/payload/plugins/passioncode/hooks/update-check.js +126 -0
- package/payload/plugins/passioncode/trust.json +6 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Live conformance probe for a fabric-service/0.1 service.
|
|
3
|
+
|
|
4
|
+
check_service.py <id>[.<instance>] # find the descriptor in the services directory
|
|
5
|
+
check_service.py --descriptor PATH
|
|
6
|
+
options: --services-dir DIR --json --skip-login
|
|
7
|
+
|
|
8
|
+
Every rule gets PASS, FAIL or NOT_RUN with its evidence. Exit 0 when nothing
|
|
9
|
+
FAILs, 1 when something does, 2 on a usage error. The probe only reads, except
|
|
10
|
+
that it redeems one login code it asked for itself (that creates one session).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import errno
|
|
17
|
+
import http.client
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import plistlib
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import stat
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
|
+
import fabric_service as fs # noqa: E402
|
|
32
|
+
|
|
33
|
+
Result = Dict[str, str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Probe:
|
|
37
|
+
def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool):
|
|
38
|
+
self.path = descriptor_path
|
|
39
|
+
self.d = descriptor
|
|
40
|
+
self.dir = services_dir
|
|
41
|
+
self.skip_login = skip_login
|
|
42
|
+
self.results: List[Result] = []
|
|
43
|
+
self.wk: Optional[Dict[str, Any]] = None
|
|
44
|
+
self.token: Optional[str] = None
|
|
45
|
+
try:
|
|
46
|
+
self.port = fs.port_of(str(descriptor.get("origin", "")))
|
|
47
|
+
except fs.ServiceError:
|
|
48
|
+
self.port = 0
|
|
49
|
+
|
|
50
|
+
def add(self, rule: str, verdict: str, evidence: str) -> None:
|
|
51
|
+
self.results.append({"rule": rule, "verdict": verdict, "evidence": evidence})
|
|
52
|
+
|
|
53
|
+
def request(self, method: str, path: str, headers: Optional[Dict[str, str]] = None,
|
|
54
|
+
body: Optional[bytes] = None) -> Tuple[int, Dict[str, str], bytes]:
|
|
55
|
+
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
|
|
56
|
+
base = {"Host": "127.0.0.1:%d" % self.port}
|
|
57
|
+
base.update(headers or {})
|
|
58
|
+
try:
|
|
59
|
+
conn.request(method, path, body=body, headers=base)
|
|
60
|
+
resp = conn.getresponse()
|
|
61
|
+
return resp.status, {k.lower(): v for k, v in resp.getheaders()}, resp.read()
|
|
62
|
+
finally:
|
|
63
|
+
conn.close()
|
|
64
|
+
|
|
65
|
+
def auth_headers(self) -> Dict[str, str]:
|
|
66
|
+
auth = self.d.get("auth", {})
|
|
67
|
+
header = auth.get("header", "Authorization")
|
|
68
|
+
value = self.token or ""
|
|
69
|
+
return {header: ("Bearer " + value) if auth.get("scheme", "Bearer") == "Bearer" else value}
|
|
70
|
+
|
|
71
|
+
# descriptor ------------------------------------------------------------
|
|
72
|
+
def descriptor_rules(self) -> None:
|
|
73
|
+
problems = fs.validate_descriptor(self.d)
|
|
74
|
+
self.add("descriptor.valid", "FAIL" if problems else "PASS", "; ".join(problems) or str(self.path))
|
|
75
|
+
mode = stat.S_IMODE(os.stat(self.path).st_mode)
|
|
76
|
+
self.add("descriptor.private", "PASS" if mode & 0o077 == 0 else "FAIL", "mode %o" % mode)
|
|
77
|
+
me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
|
|
78
|
+
clashes = []
|
|
79
|
+
for path, other in fs.read_descriptors(self.dir):
|
|
80
|
+
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
81
|
+
if path.resolve() == self.path.resolve():
|
|
82
|
+
continue
|
|
83
|
+
if key == me:
|
|
84
|
+
clashes.append("%s declared again in %s" % (me, path.name))
|
|
85
|
+
elif other.get("origin") == self.d.get("origin"):
|
|
86
|
+
clashes.append("port %d also claimed by %s" % (self.port, key))
|
|
87
|
+
self.add("descriptor.port-claim", "FAIL" if clashes else "PASS", "; ".join(clashes) or "port %d is unique" % self.port)
|
|
88
|
+
|
|
89
|
+
# well-known ----------------------------------------------------------------
|
|
90
|
+
def well_known_rules(self) -> None:
|
|
91
|
+
try:
|
|
92
|
+
timings = []
|
|
93
|
+
for _ in range(3):
|
|
94
|
+
started = time.perf_counter()
|
|
95
|
+
status, headers, body = self.request("GET", "/.well-known/fabric-service")
|
|
96
|
+
timings.append((time.perf_counter() - started) * 1000)
|
|
97
|
+
except OSError as exc:
|
|
98
|
+
self.add("well-known.answers", "FAIL", "no answer on %s: %s" % (self.d.get("origin"), exc))
|
|
99
|
+
return
|
|
100
|
+
if status != 200:
|
|
101
|
+
self.add("well-known.answers", "FAIL", "HTTP %d" % status)
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
self.wk = json.loads(body)
|
|
105
|
+
except ValueError:
|
|
106
|
+
self.add("well-known.answers", "FAIL", "body is not JSON")
|
|
107
|
+
return
|
|
108
|
+
self.add("well-known.answers", "PASS", "HTTP 200")
|
|
109
|
+
median = sorted(timings)[1]
|
|
110
|
+
self.add("well-known.fast", "PASS" if median < 100 else "FAIL", "median %.1f ms" % median)
|
|
111
|
+
wk = self.wk
|
|
112
|
+
problems = []
|
|
113
|
+
if wk.get("protocol") != fs.PROTOCOL:
|
|
114
|
+
problems.append("protocol %r" % wk.get("protocol"))
|
|
115
|
+
if "degraded" not in wk:
|
|
116
|
+
problems.append("degraded missing")
|
|
117
|
+
build = (wk.get("service") or {}).get("build") or {}
|
|
118
|
+
if not (build.get("commit") or build.get("digest")):
|
|
119
|
+
problems.append("build has neither commit nor digest")
|
|
120
|
+
if wk.get("status") not in fs.STATUSES:
|
|
121
|
+
problems.append("status %r" % wk.get("status"))
|
|
122
|
+
if "events" not in (wk.get("surfaces") or {}):
|
|
123
|
+
problems.append("surfaces.events missing")
|
|
124
|
+
self.add("well-known.shape", "FAIL" if problems else "PASS", "; ".join(problems) or "protocol, build, status, degraded, surfaces")
|
|
125
|
+
svc = wk.get("service") or {}
|
|
126
|
+
answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
|
|
127
|
+
expected = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
|
|
128
|
+
self.add("well-known.identity", "PASS" if answered == expected else "FAIL",
|
|
129
|
+
"answers as %s" % answered + ("" if answered == expected else ", descriptor says %s" % expected))
|
|
130
|
+
ready_bad = wk.get("status") == "ready" and bool(wk.get("degraded"))
|
|
131
|
+
self.add("well-known.ready-means-healthy", "FAIL" if ready_bad else "PASS",
|
|
132
|
+
"status %s, %d degraded" % (wk.get("status"), len(wk.get("degraded") or [])))
|
|
133
|
+
|
|
134
|
+
# network ------------------------------------------------------------------
|
|
135
|
+
def network_rules(self) -> None:
|
|
136
|
+
for rule, headers in (
|
|
137
|
+
("network.host-check", {"Host": "evil.example"}),
|
|
138
|
+
("network.origin-check", {"Origin": "http://evil.example"}),
|
|
139
|
+
("network.cross-site-check", {"Sec-Fetch-Site": "cross-site"}),
|
|
140
|
+
):
|
|
141
|
+
try:
|
|
142
|
+
status, _, _ = self.request("GET", "/.well-known/fabric-service", headers)
|
|
143
|
+
self.add(rule, "PASS" if status == 403 else "FAIL", "HTTP %d" % status)
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
self.add(rule, "NOT_RUN", str(exc))
|
|
146
|
+
if not shutil.which("lsof"):
|
|
147
|
+
self.add("network.loopback-only", "NOT_RUN", "lsof is not installed")
|
|
148
|
+
return
|
|
149
|
+
out = subprocess.run(["lsof", "-nP", "-iTCP:%d" % self.port, "-sTCP:LISTEN"], capture_output=True, text=True).stdout
|
|
150
|
+
names = re.findall(r"TCP (\S+) \(LISTEN\)", out)
|
|
151
|
+
wide = [n for n in names if not n.startswith(("127.0.0.1:", "[::1]:", "localhost:"))]
|
|
152
|
+
self.add("network.loopback-only", "FAIL" if wide or not names else "PASS",
|
|
153
|
+
", ".join(names) or "nothing listens on %d" % self.port)
|
|
154
|
+
|
|
155
|
+
# auth, events, login ----------------------------------------------------------
|
|
156
|
+
def auth_rules(self) -> None:
|
|
157
|
+
token_file = str((self.d.get("auth") or {}).get("tokenFile", ""))
|
|
158
|
+
try:
|
|
159
|
+
self.token = fs.read_token(fs.expand(token_file))
|
|
160
|
+
self.add("auth.token-file", "PASS", "%s is 0600 and owned by you" % token_file)
|
|
161
|
+
except (fs.ServiceError, OSError) as exc:
|
|
162
|
+
self.add("auth.token-file", "FAIL", str(exc))
|
|
163
|
+
events_path = ((self.wk or {}).get("surfaces") or {}).get("events", {}).get("path", "/fabric/v1/events")
|
|
164
|
+
try:
|
|
165
|
+
status, _, _ = self.request("GET", events_path + "?limit=1")
|
|
166
|
+
self.add("events.requires-token", "PASS" if status == 401 else "FAIL", "HTTP %d without a token" % status)
|
|
167
|
+
except OSError as exc:
|
|
168
|
+
self.add("events.requires-token", "NOT_RUN", str(exc))
|
|
169
|
+
if not self.token:
|
|
170
|
+
self.add("events.page", "NOT_RUN", "no readable token")
|
|
171
|
+
return
|
|
172
|
+
try:
|
|
173
|
+
status, _, body = self.request("GET", events_path + "?limit=5", self.auth_headers())
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
self.add("events.page", "NOT_RUN", str(exc))
|
|
176
|
+
return
|
|
177
|
+
if status != 200:
|
|
178
|
+
self.add("events.page", "FAIL", "HTTP %d with the token" % status)
|
|
179
|
+
return
|
|
180
|
+
try:
|
|
181
|
+
page = json.loads(body)
|
|
182
|
+
events = page["events"]
|
|
183
|
+
assert "cursor" in page
|
|
184
|
+
except (ValueError, KeyError, AssertionError):
|
|
185
|
+
self.add("events.page", "FAIL", "not an events page")
|
|
186
|
+
return
|
|
187
|
+
bad = []
|
|
188
|
+
for e in events:
|
|
189
|
+
if e.get("level") not in fs.LEVELS or not re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$", str(e.get("kind", ""))):
|
|
190
|
+
bad.append(str(e.get("id")))
|
|
191
|
+
elif " " not in str(e.get("text", "")).strip():
|
|
192
|
+
bad.append("%s (text is not a sentence)" % e.get("id"))
|
|
193
|
+
self.add("events.page", "FAIL" if bad else "PASS",
|
|
194
|
+
("bad events: " + ", ".join(bad)) if bad else "%d event(s), cursor %r" % (len(events), page["cursor"]))
|
|
195
|
+
|
|
196
|
+
def login_rules(self) -> None:
|
|
197
|
+
dashboard = ((self.wk or {}).get("surfaces") or {}).get("dashboard")
|
|
198
|
+
if not dashboard or not dashboard.get("login"):
|
|
199
|
+
self.add("login.single-use", "NOT_RUN", "dashboard declares no login")
|
|
200
|
+
return
|
|
201
|
+
if self.skip_login or not self.token:
|
|
202
|
+
self.add("login.single-use", "NOT_RUN", "--skip-login" if self.skip_login else "no readable token")
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
status, _, body = self.request("POST", "/fabric/v1/login-code", self.auth_headers())
|
|
206
|
+
code = json.loads(body)
|
|
207
|
+
url = code["url"]
|
|
208
|
+
status1, headers1, _ = self.request("GET", url)
|
|
209
|
+
status2, _, _ = self.request("GET", url)
|
|
210
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
211
|
+
self.add("login.single-use", "FAIL", "login-code flow broke: %s" % exc)
|
|
212
|
+
return
|
|
213
|
+
cookie = headers1.get("set-cookie", "")
|
|
214
|
+
ok = status1 in (302, 303) and "HttpOnly" in cookie and "SameSite=Strict" in cookie and status2 not in (302, 303)
|
|
215
|
+
self.add("login.single-use", "PASS" if ok else "FAIL",
|
|
216
|
+
"first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
|
|
217
|
+
|
|
218
|
+
# lifecycle ----------------------------------------------------------------
|
|
219
|
+
def lifecycle_rules(self) -> None:
|
|
220
|
+
life = self.d.get("lifecycle") or {}
|
|
221
|
+
data = fs.expand(str((self.d.get("paths") or {}).get("data", "~/")))
|
|
222
|
+
inside = subprocess.run(["git", "-C", str(data), "rev-parse", "--show-toplevel"], capture_output=True, text=True) if data.is_dir() and shutil.which("git") else None
|
|
223
|
+
self.state_rule(data, inside)
|
|
224
|
+
self.lock_rule(data)
|
|
225
|
+
if life.get("manager") != "launchd":
|
|
226
|
+
self.add("lifecycle.launchd", "NOT_RUN", "lifecycle.manager is %s" % life.get("manager"))
|
|
227
|
+
return
|
|
228
|
+
plist_path = fs.expand(str(life.get("plist")))
|
|
229
|
+
try:
|
|
230
|
+
plist = plistlib.loads(plist_path.read_bytes())
|
|
231
|
+
except (OSError, ValueError) as exc:
|
|
232
|
+
self.add("lifecycle.plist", "FAIL", "cannot read %s: %s" % (plist_path, exc))
|
|
233
|
+
return
|
|
234
|
+
problems = []
|
|
235
|
+
if plist.get("Label") != life.get("label"):
|
|
236
|
+
problems.append("Label %r" % plist.get("Label"))
|
|
237
|
+
if plist.get("RunAtLoad") is not True:
|
|
238
|
+
problems.append("RunAtLoad is not true")
|
|
239
|
+
if plist.get("KeepAlive") is not True:
|
|
240
|
+
problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
|
|
241
|
+
for key, value in (plist.get("EnvironmentVariables") or {}).items():
|
|
242
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
243
|
+
problems.append("secret-like variable %s in the plist" % key)
|
|
244
|
+
if self.token and self.token in str(value):
|
|
245
|
+
problems.append("the service token itself is in the plist")
|
|
246
|
+
self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
|
|
247
|
+
if not shutil.which("launchctl"):
|
|
248
|
+
self.add("lifecycle.one-copy", "NOT_RUN", "launchctl is not available")
|
|
249
|
+
return
|
|
250
|
+
out = subprocess.run(["launchctl", "print", "gui/%d/%s" % (os.getuid(), life.get("label"))], capture_output=True, text=True)
|
|
251
|
+
match = re.search(r"^\s*pid = (\d+)", out.stdout, re.MULTILINE)
|
|
252
|
+
served = ((self.wk or {}).get("process") or {}).get("pid")
|
|
253
|
+
if out.returncode != 0:
|
|
254
|
+
self.add("lifecycle.one-copy", "FAIL", "launchd job %s is not loaded" % life.get("label"))
|
|
255
|
+
elif not match:
|
|
256
|
+
self.add("lifecycle.one-copy", "FAIL", "launchd job loaded but not running")
|
|
257
|
+
else:
|
|
258
|
+
same = int(match.group(1)) == served
|
|
259
|
+
self.add("lifecycle.one-copy", "PASS" if same else "FAIL",
|
|
260
|
+
"launchd pid %s, answering pid %s" % (match.group(1), served))
|
|
261
|
+
|
|
262
|
+
def state_rule(self, data: Path, inside: Optional[subprocess.CompletedProcess]) -> None:
|
|
263
|
+
"""State must not live in the service's CODE: its own checkout or a release.
|
|
264
|
+
|
|
265
|
+
A repository that exists to version the data itself (a registry, a plan) is
|
|
266
|
+
a store, not code; deleting the service's checkout does not touch it."""
|
|
267
|
+
rule = "state.outside-code"
|
|
268
|
+
if "/releases/" in str(data):
|
|
269
|
+
self.add(rule, "FAIL", "%s is inside a release directory" % data)
|
|
270
|
+
return
|
|
271
|
+
if inside is None:
|
|
272
|
+
self.add(rule, "NOT_RUN", "data directory %s missing or git absent" % data)
|
|
273
|
+
return
|
|
274
|
+
if inside.returncode != 0:
|
|
275
|
+
self.add(rule, "PASS", "%s is not inside a repository" % data)
|
|
276
|
+
return
|
|
277
|
+
top = inside.stdout.strip()
|
|
278
|
+
remote = subprocess.run(["git", "-C", top, "remote", "get-url", "origin"], capture_output=True, text=True)
|
|
279
|
+
source = str((self.d.get("source") or {}).get("repository") or "")
|
|
280
|
+
if not source:
|
|
281
|
+
self.add(rule, "NOT_RUN", "data is inside repository %s and the descriptor names no source.repository to tell code from data" % top)
|
|
282
|
+
elif remote.returncode == 0 and same_repository(remote.stdout.strip(), source):
|
|
283
|
+
self.add(rule, "FAIL", "data lives in the service's own code checkout %s (%s)" % (top, source))
|
|
284
|
+
else:
|
|
285
|
+
self.add(rule, "PASS", "data is in repository %s, which is not the service's code (%s)" % (top, remote.stdout.strip() or "no remote"))
|
|
286
|
+
|
|
287
|
+
def lock_rule(self, data: Path) -> None:
|
|
288
|
+
lock = data / "service.lock"
|
|
289
|
+
if not lock.exists():
|
|
290
|
+
self.add("lifecycle.instance-lock", "FAIL", "%s does not exist" % lock)
|
|
291
|
+
return
|
|
292
|
+
try:
|
|
293
|
+
import fcntl
|
|
294
|
+
except ImportError:
|
|
295
|
+
self.add("lifecycle.instance-lock", "NOT_RUN", "fcntl unavailable")
|
|
296
|
+
return
|
|
297
|
+
fd = os.open(str(lock), os.O_RDONLY)
|
|
298
|
+
try:
|
|
299
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
held = exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES)
|
|
302
|
+
self.add("lifecycle.instance-lock", "PASS" if held else "FAIL", "held by the running service" if held else str(exc))
|
|
303
|
+
else:
|
|
304
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
305
|
+
self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
|
|
306
|
+
finally:
|
|
307
|
+
os.close(fd)
|
|
308
|
+
|
|
309
|
+
def run(self) -> List[Result]:
|
|
310
|
+
self.descriptor_rules()
|
|
311
|
+
if not self.port:
|
|
312
|
+
return self.results
|
|
313
|
+
self.well_known_rules()
|
|
314
|
+
if self.wk is not None:
|
|
315
|
+
self.network_rules()
|
|
316
|
+
self.auth_rules()
|
|
317
|
+
self.login_rules()
|
|
318
|
+
self.lifecycle_rules()
|
|
319
|
+
return self.results
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def same_repository(a: str, b: str) -> bool:
|
|
323
|
+
"""git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
|
|
324
|
+
def norm(u: str) -> str:
|
|
325
|
+
u = u.strip().lower()
|
|
326
|
+
u = re.sub(r"^git@([^:]+):", r"\1/", u)
|
|
327
|
+
u = re.sub(r"^[a-z+]+://", "", u)
|
|
328
|
+
u = re.sub(r"^[^@/]+@", "", u)
|
|
329
|
+
return re.sub(r"\.git$", "", u).rstrip("/")
|
|
330
|
+
return bool(a and b) and norm(a) == norm(b)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def locate(target: Optional[str], descriptor: Optional[str], services_dir: Path) -> Path:
|
|
334
|
+
if descriptor:
|
|
335
|
+
return Path(descriptor).expanduser()
|
|
336
|
+
if not target:
|
|
337
|
+
raise SystemExit(2)
|
|
338
|
+
service_id, _, instance = target.partition(".")
|
|
339
|
+
return fs.descriptor_path(service_id, instance or "default", services_dir)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
343
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
344
|
+
parser.add_argument("target", nargs="?", help="<id> or <id>.<instance>")
|
|
345
|
+
parser.add_argument("--descriptor")
|
|
346
|
+
parser.add_argument("--services-dir")
|
|
347
|
+
parser.add_argument("--json", action="store_true")
|
|
348
|
+
parser.add_argument("--skip-login", action="store_true")
|
|
349
|
+
args = parser.parse_args(argv)
|
|
350
|
+
if not args.target and not args.descriptor:
|
|
351
|
+
parser.print_usage(sys.stderr)
|
|
352
|
+
return 2
|
|
353
|
+
services_dir = Path(args.services_dir).expanduser() if args.services_dir else fs.services_dir()
|
|
354
|
+
path = locate(args.target, args.descriptor, services_dir)
|
|
355
|
+
try:
|
|
356
|
+
descriptor = json.loads(path.read_text(encoding="utf-8"))
|
|
357
|
+
except (OSError, ValueError) as exc:
|
|
358
|
+
print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
results = Probe(path, descriptor, services_dir, args.skip_login).run()
|
|
361
|
+
failed = sum(r["verdict"] == "FAIL" for r in results)
|
|
362
|
+
if args.json:
|
|
363
|
+
print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))
|
|
364
|
+
else:
|
|
365
|
+
width = max(len(r["rule"]) for r in results)
|
|
366
|
+
for r in results:
|
|
367
|
+
print("%-8s %-*s %s" % (r["verdict"], width, r["rule"], r["evidence"]))
|
|
368
|
+
print("\n%d rule(s), %d FAIL, %d NOT_RUN" % (len(results), failed, sum(r["verdict"] == "NOT_RUN" for r in results)))
|
|
369
|
+
return 1 if failed else 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
if __name__ == "__main__":
|
|
373
|
+
raise SystemExit(main())
|