@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/references/estate-record.md
CHANGED
|
@@ -120,8 +120,9 @@ of `[A-Za-z0-9][A-Za-z0-9_-]{0,127}`. Absent is `personal`; `null` is not absent
|
|
|
120
120
|
`publisherKeys` pins. A key no scope names keeps DEC-0051's meaning, trusted in any context.
|
|
121
121
|
- **Update a copied kit before a revision carries `publisherScopes`.** A service runs the copy of the kit it was built
|
|
122
122
|
with; a copy older than 0.39.0 does not read `publisherScopes`, so it trusts a partner's key in every context and
|
|
123
|
-
finds no rule broken. A service that applies the estate record updates its copy
|
|
124
|
-
`fabric-estate.
|
|
123
|
+
finds no rule broken. A service that applies the estate record updates its copy first:
|
|
124
|
+
`fabric_kit.py install <dir> --module fabric_estate` (or `--lang node --module fabric-estate`). Since 0.40.0 the
|
|
125
|
+
estate reader imports the kit's core modules, so the one file alone no longer loads ([kit modules](kit-modules.md)).
|
|
125
126
|
|
|
126
127
|
Every rule is total on hostile JSON, as the contract's are: an `instancePath` names an item by its index as written (a
|
|
127
128
|
non-object before it does not shift it), and a value whose own `toString` member is data is shown as its JSON. Both kits
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# The service kit: modules, layers and installing them
|
|
2
|
+
|
|
3
|
+
Since 0.40.0 (FAA-65) the kit is a set of small modules in two languages, not two large files. `scripts/kit.json`
|
|
4
|
+
lists every module in dependency order, per language, with the kit modules it imports and its sha256; `history` keeps
|
|
5
|
+
the digests of every release since 0.8.0, so a copy in a service can be told apart. `scripts/fabric_kit.py` installs a
|
|
6
|
+
module with exactly what it needs and reports a copy's state. Tests: `test/test_fabric_kit.py`,
|
|
7
|
+
`test/test_kit_api.py`, `test/kit-api.test.mjs`.
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- [Install, status, build](#install-status-build)
|
|
12
|
+
- [The layers](#the-layers)
|
|
13
|
+
- [What 0.39 code keeps](#what-039-code-keeps)
|
|
14
|
+
- [Dates and times](#dates-and-times)
|
|
15
|
+
- [Adding or changing a module](#adding-or-changing-a-module)
|
|
16
|
+
|
|
17
|
+
## Install, status, build
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
K=<adapter>/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts
|
|
21
|
+
python3 $K/fabric_kit.py install <service-dir> # fabric_service and its closure (Python)
|
|
22
|
+
python3 $K/fabric_kit.py install <service-dir> --module fabric_service --module fabric_interop --module fabric_board
|
|
23
|
+
# what sample_service.py imports
|
|
24
|
+
python3 $K/fabric_kit.py install <service-dir> --lang node # fabric-service.mjs and its closure
|
|
25
|
+
python3 $K/fabric_kit.py install <service-dir> --module fabric_openrouter --module fabric_board
|
|
26
|
+
python3 $K/fabric_kit.py install <service-dir> --all --dry-run # every module, nothing written
|
|
27
|
+
python3 $K/fabric_kit.py status <service-dir> [--json] # current, older (the release), modified, missing
|
|
28
|
+
python3 $K/fabric_kit.py build [--check] # maintainers: refresh kit.json from the sources
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- `install` copies the closure of the named modules and nothing else. It refuses to overwrite a file it does not
|
|
32
|
+
recognise (`modified`: someone edited the copy) unless `--force`, and says which.
|
|
33
|
+
- `status` names each kit file in the folder as `current`, `older` with the release it came from, or `modified`, and
|
|
34
|
+
prints `missing <file>, which <file> imports` for every module the folder lacks, read from what each copy itself
|
|
35
|
+
imports and followed through the modules it would install: that copy would fail when that code path runs. A folder
|
|
36
|
+
holding only the 0.40.0 `fabric_service.py` gets one line for each of the 16 modules it needs; a 0.39.1 copy is
|
|
37
|
+
asked only for what it imports (`test_an_older_copy_is_judged_by_its_own_imports`).
|
|
38
|
+
- Exit codes: 0 done or every copy current, 1 a problem (a copy missing, `older` or `modified`, no kit module in the
|
|
39
|
+
folder at all, an install refused), 2 usage (an unknown `--module` included).
|
|
40
|
+
- Every kit module puts its own folder on `sys.path` before it imports a sibling, so a service that loads a kit file
|
|
41
|
+
by its path from another working directory gets its siblings too (`test_every_module_loads_by_path_from_any_folder`).
|
|
42
|
+
- `build --check` is what CI runs: `kit.json` must be what the sources say.
|
|
43
|
+
|
|
44
|
+
## The layers
|
|
45
|
+
|
|
46
|
+
A module imports only modules listed before it in `kit.json`, so the kit has no import cycle and no upward import
|
|
47
|
+
(`test_the_order_is_a_layering`; a planted cycle is caught). Python name first, the Node twin is the same name with
|
|
48
|
+
`-` for `_` and `.mjs`. No module is over 1000 lines (`test_no_module_over_the_line_ceiling`).
|
|
49
|
+
|
|
50
|
+
| Layer | Module | What it holds |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| L0 | `fabric_jsvalue` | JavaScript's view of JSON values: numbers, strings, equality, `jcs`, `show`; defined once for the kit |
|
|
53
|
+
| L0 | `fabric_time` | strict RFC 3339, UTC days, formatting, day windows, the clock ([dates](#dates-and-times)) |
|
|
54
|
+
| L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
|
|
55
|
+
| L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
|
|
56
|
+
| L1 | `fabric_crypto` | Ed25519 and P-256 verification, base64url, sha256: what the verifiers need and nothing more |
|
|
57
|
+
| L2 | `fabric_estate` | `estate-record/1` revisions and their rules (DEC-0055) |
|
|
58
|
+
| L2 | `fabric_delegation` | the callee's side of `fabric-delegation/0.1` (DEC-0039) |
|
|
59
|
+
| L2 | `fabric_share` | share grants and registrations (DEC-0086) |
|
|
60
|
+
| L2 | `fabric_ceremonies` | the headless join token (DEC-0084) |
|
|
61
|
+
| L2 | `fabric_package` | `fabric-package/1`: pack, sign, verify (DEC-0050, DEC-0051) |
|
|
62
|
+
| L3 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
|
|
63
|
+
| L3 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
|
|
64
|
+
| L3 | `fabric_paths` | the services directory, a service's folders, atomic and private writes |
|
|
65
|
+
| L3 | `fabric_protocols` | protocol revisions (DEC-0067) and feed data (DEC-0070) |
|
|
66
|
+
| L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
|
|
67
|
+
| L3 | `fabric_usage` | usage receipts, the ledger, the usage report, attribution, spending limits ([usage](usage.md)) |
|
|
68
|
+
| L3 | `fabric_backups` | settings backups (DEC-0025) |
|
|
69
|
+
| L3 | `fabric_descriptor` | the descriptor, peers, app descriptors, the well-known document |
|
|
70
|
+
| L3 | `fabric_auth` | the token, the network guard, login codes |
|
|
71
|
+
| L3 | `fabric_events` | events, paging, the JSONL event log (DEC-0029) |
|
|
72
|
+
| L3 | `fabric_logs` | rotating logs, the stop signal and the drain (LC-12) |
|
|
73
|
+
| L3 | `fabric_supervisors` | launchd, systemd user units, Task Scheduler (DEC-0032, LC-14) |
|
|
74
|
+
| L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
|
|
75
|
+
| L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
|
|
76
|
+
| L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools ([interop](interop.md)) |
|
|
77
|
+
| L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
|
|
78
|
+
| L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
|
|
79
|
+
| L4 | `fabric_board_session` | the board responder: a session, the checkpoint, the fenced request |
|
|
80
|
+
| L4 | `fabric_board_fixture` | the scripted board the probe runs a responder against |
|
|
81
|
+
| L4 | `fabric_board` | the facade of the board connector ([board](board.md)) |
|
|
82
|
+
| L4 | `fabric_http` | outbound HTTP: deadline, bounded retries, `Retry-After`, size cap ([connectors](connectors.md)) |
|
|
83
|
+
| L4 | `fabric_openrouter` | OpenRouter chat: own key, spend ceiling, receipts, fallback ([connectors](connectors.md)) |
|
|
84
|
+
|
|
85
|
+
Beside the kit, not in `kit.json`: the probe (`check_service.py`, `check_service_rules.py`), the sample
|
|
86
|
+
(`sample_service.py`, `sample-remote-service.mjs`), `fabric_kit.py` itself and the dashboard tools. Recipes built on
|
|
87
|
+
the kit live in `../recipes/` ([the support agent](../recipes/support-agent/README.md)).
|
|
88
|
+
|
|
89
|
+
## What 0.39 code keeps
|
|
90
|
+
|
|
91
|
+
`import fabric_service as fs` and `import ... from './fabric-service.mjs'` keep working: the facades re-export every
|
|
92
|
+
public name 0.39.1 had, in both languages (the snapshot is `test/fixtures/kit-api/kit-api-0.39.1.json`; a missing
|
|
93
|
+
name fails `test_kit_api`), and the Python facade the private names other code reached through it (`_number`,
|
|
94
|
+
`_DAY_MS`, `_utc_date_ms` from the core). `fabric_board` and `fabric-board.mjs` are
|
|
95
|
+
facades the same way. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
|
|
96
|
+
install with `fabric_kit.py install` and `status` names what is missing. A test or service that replaced a function
|
|
97
|
+
by assigning it on the facade (`fabric_service.X = ...`) no longer changes what the kit does: the code reads its own
|
|
98
|
+
module's name, so patch the module that defines it (`fabric_lock.X`, `fabric_session_env.X`). A service that imported a module-private
|
|
99
|
+
helper (`_x`) of the Node twin should import it from its module now.
|
|
100
|
+
|
|
101
|
+
## Dates and times
|
|
102
|
+
|
|
103
|
+
`fabric_time` / `fabric-time.mjs` is the kit's one reading of dates, on the cases both twins test
|
|
104
|
+
(`test/fixtures/kit-time-cases.json`). Anything unreadable is NaN, never an exception, so a check compares it and
|
|
105
|
+
reports a finding.
|
|
106
|
+
|
|
107
|
+
| Python | Node | What |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `parse_rfc3339_ms(text)` | `parseRfc3339Ms(text)` | strict RFC 3339: a `T`, an offset and a day that exists; no space, no leap second; milliseconds or NaN |
|
|
110
|
+
| `utc_date_ms(text)` | `utcDateMs(text)` | a `YYYY-MM-DD` that exists, as midnight UTC in milliseconds, or NaN |
|
|
111
|
+
| `utc_day(ms)` | `utcDay(ms)` | the UTC calendar day of an instant (NaN or Infinity raise) |
|
|
112
|
+
| `format_rfc3339(ms)` | `formatRfc3339(ms)` | `2026-10-21T09:00:00.000Z`, which parses back to the same value |
|
|
113
|
+
| `day_window(today_ms, days)` | `dayWindow(todayMs, days)` | the last N UTC days ending with today's, oldest first (a usage report's 31) |
|
|
114
|
+
| `clock_ms(at)` | `clockMs(at)` | a verifier's clock (an aware `datetime` / a `Date`, or epoch seconds) in milliseconds |
|
|
115
|
+
| `now_ms(clock)` | `nowMs(clock)` | now in milliseconds; `clock` returns seconds in Python (`time.time`), milliseconds in Node (`Date.now`) |
|
|
116
|
+
| `DAY_MS` | `DAY_MS` | 86 400 000 |
|
|
117
|
+
|
|
118
|
+
`fabric_estate.estate_time` is `parse_rfc3339_ms` under its older name.
|
|
119
|
+
|
|
120
|
+
## Adding or changing a module
|
|
121
|
+
|
|
122
|
+
1. Put it in the lowest layer whose modules it needs; import only modules listed before it.
|
|
123
|
+
2. Write the Node twin with the same public names in camelCase, and shared cases under `test/fixtures/` that both
|
|
124
|
+
test files read.
|
|
125
|
+
3. Add it to `kit.json` (module, file, layer) and run `fabric_kit.py build`; add it to the table above
|
|
126
|
+
(`test_every_kit_module_is_documented`).
|
|
127
|
+
4. A value or helper the core already has is imported, never redefined (`test_the_core_is_defined_once`).
|
|
128
|
+
5. Stay under 1000 lines; split by responsibility, keeping a facade when callers already import the old name.
|
|
@@ -29,6 +29,8 @@ ledger.record(receipt) # fs.JsonlUsageLedger(data / "usage.j
|
|
|
29
29
|
ledger.record(k.makeUsageReceipt('openrouter', model, { inputTokens, outputTokens, costUsd: usage.cost ?? null }));
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
- **OpenRouter through the kit.** `fabric_openrouter.Client` records these receipts itself, one per answered call,
|
|
33
|
+
and checks a spend ceiling before every attempt ([connectors](connectors.md)).
|
|
32
34
|
- **The provider's own numbers.** Take tokens from the response's usage block, not from a local
|
|
33
35
|
estimate. The cost is `provider` when the provider reported the charge (OpenRouter `usage.cost`,
|
|
34
36
|
a billing API) and `price-list` when you computed it from a published price list.
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -57,74 +57,16 @@ import fabric_interop as fi # noqa: E402
|
|
|
57
57
|
Result = Dict[str, str]
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for key, value in (plist.get("EnvironmentVariables") or {}).items():
|
|
71
|
-
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
72
|
-
problems.append("secret-like variable %s in the plist" % key)
|
|
73
|
-
if token and token in str(value):
|
|
74
|
-
problems.append("the service token itself is in the plist")
|
|
75
|
-
return problems
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def marker_problem(plist: dict) -> Optional[str]:
|
|
79
|
-
"""What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
|
|
80
|
-
|
|
81
|
-
Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
|
|
82
|
-
lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
|
|
83
|
-
env = plist.get("EnvironmentVariables")
|
|
84
|
-
env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
|
|
85
|
-
argv = plist.get("ProgramArguments")
|
|
86
|
-
argv = [str(a) for a in argv] if isinstance(argv, list) else []
|
|
87
|
-
marker = fs.supervisor_marker(env, argv)
|
|
88
|
-
if marker["kind"] == "supervised" and marker["manager"] == "launchd":
|
|
89
|
-
return None
|
|
90
|
-
fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
|
|
91
|
-
"regenerate the plist with launchd_plist / launchdPlist and reinstall")
|
|
92
|
-
if marker["kind"] == "conflict":
|
|
93
|
-
return "%s: %s" % (fs.marker_conflict_message(marker), fix)
|
|
94
|
-
if marker["kind"] == "supervised":
|
|
95
|
-
return "the marker names %s, not launchd: %s" % (marker["manager"], fix)
|
|
96
|
-
if marker.get("unreadable") is not None:
|
|
97
|
-
return "the marker %r names no supervisor: %s" % (marker["unreadable"], fix)
|
|
98
|
-
return "no %s=launchd: %s" % (fs.SUPERVISOR_ENV, fix)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def priority_problems(plist: dict) -> list:
|
|
102
|
-
"""A service that answers hosts and agents must not be scheduled as background work."""
|
|
103
|
-
out = []
|
|
104
|
-
if plist.get("ProcessType", "Standard") in ("Background", "Adaptive"):
|
|
105
|
-
out.append("ProcessType is %s" % plist.get("ProcessType"))
|
|
106
|
-
if isinstance(plist.get("Nice"), int) and plist["Nice"] > 0:
|
|
107
|
-
out.append("Nice is %d" % plist["Nice"])
|
|
108
|
-
if plist.get("LowPriorityIO") is True or plist.get("LowPriorityBackgroundIO") is True:
|
|
109
|
-
out.append("low-priority I/O")
|
|
110
|
-
return out
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# A platform router (Heroku, Fly, Render, a CDN) routes by Host, so a request naming a foreign
|
|
114
|
-
# Host never reaches an online service: the router itself answers, typically 404 or 421. That is
|
|
115
|
-
# still a refusal — the service disclosed nothing — so for a remote placement the host check
|
|
116
|
-
# accepts it, as long as the body is not the well-known document. Every other guard request names
|
|
117
|
-
# the right Host, reaches the service, and must be the service's own 403.
|
|
118
|
-
PLATFORM_HOST_REFUSALS = (400, 404, 421)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
|
|
122
|
-
if status == 403:
|
|
123
|
-
return "PASS", "HTTP 403"
|
|
124
|
-
disclosed = b"fabric-service/" in (body or b"")
|
|
125
|
-
if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
|
|
126
|
-
return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
|
|
127
|
-
return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
|
|
60
|
+
from check_service_rules import PLATFORM_HOST_REFUSALS # noqa: E402,F401 - moved (FAA-65)
|
|
61
|
+
from check_service_rules import plist_problems # noqa: E402,F401 - moved (FAA-65)
|
|
62
|
+
from check_service_rules import marker_problem # noqa: E402,F401 - moved (FAA-65)
|
|
63
|
+
from check_service_rules import priority_problems # noqa: E402,F401 - moved (FAA-65)
|
|
64
|
+
from check_service_rules import guard_verdict # noqa: E402,F401 - moved (FAA-65)
|
|
65
|
+
from check_service_rules import board_responder_verdict # noqa: E402,F401 - moved (FAA-65)
|
|
66
|
+
from check_service_rules import session_lifetime_problem # noqa: E402,F401 - moved (FAA-65)
|
|
67
|
+
from check_service_rules import _semver # noqa: E402,F401 - moved (FAA-65)
|
|
68
|
+
from check_service_rules import usage_problems # noqa: E402,F401 - moved (FAA-65)
|
|
69
|
+
from check_service_rules import same_repository # noqa: E402,F401 - moved (FAA-65)
|
|
128
70
|
|
|
129
71
|
|
|
130
72
|
class _PinnedHTTPS(http.client.HTTPSConnection):
|
|
@@ -839,121 +781,6 @@ class Probe:
|
|
|
839
781
|
return self.results
|
|
840
782
|
|
|
841
783
|
|
|
842
|
-
def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
|
|
843
|
-
"""board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
|
|
844
|
-
declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
|
|
845
|
-
service's own copy (a .py or .mjs path); by default the kit beside this probe."""
|
|
846
|
-
protocols = wk.get("protocols") if isinstance(wk, dict) else None
|
|
847
|
-
declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
|
|
848
|
-
if not declared:
|
|
849
|
-
return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
|
|
850
|
-
accepted = fs.accepted_revisions("fabric-project-comms")
|
|
851
|
-
unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
|
|
852
|
-
if unread:
|
|
853
|
-
return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
|
|
854
|
-
try:
|
|
855
|
-
if kit and kit.endswith(".mjs"):
|
|
856
|
-
if not shutil.which("node"):
|
|
857
|
-
return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
|
|
858
|
-
url = Path(kit).resolve().as_uri()
|
|
859
|
-
out = subprocess.run(["node", "--input-type=module", "-e",
|
|
860
|
-
"import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
|
|
861
|
-
env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
|
|
862
|
-
problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
|
|
863
|
-
else:
|
|
864
|
-
import importlib.util
|
|
865
|
-
path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
|
|
866
|
-
spec = importlib.util.spec_from_file_location("fabric_board_under_test", str(path))
|
|
867
|
-
module = importlib.util.module_from_spec(spec)
|
|
868
|
-
sys.path.insert(0, str(path.resolve().parent))
|
|
869
|
-
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
870
|
-
problems = module.self_check()
|
|
871
|
-
except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
|
|
872
|
-
return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
|
|
873
|
-
if problems:
|
|
874
|
-
return "FAIL", "; ".join(problems)
|
|
875
|
-
return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
|
|
876
|
-
", ".join(declared), " at %s" % kit if kit else " beside this probe")
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
|
|
880
|
-
"""DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
|
|
881
|
-
§5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
|
|
882
|
-
attrs = {}
|
|
883
|
-
for part in set_cookie.split(";")[1:]:
|
|
884
|
-
name, _, value = part.strip().partition("=")
|
|
885
|
-
attrs[name.strip().lower()] = value.strip()
|
|
886
|
-
bound = fs.OPERATOR_SESSION_MAX_SECONDS
|
|
887
|
-
if "max-age" in attrs:
|
|
888
|
-
if not re.fullmatch(r"-?\d+", attrs["max-age"]):
|
|
889
|
-
return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
|
|
890
|
-
if int(attrs["max-age"]) > bound:
|
|
891
|
-
return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
|
|
892
|
-
return None
|
|
893
|
-
if "expires" in attrs:
|
|
894
|
-
try:
|
|
895
|
-
at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
|
|
896
|
-
except (TypeError, ValueError, IndexError, OverflowError):
|
|
897
|
-
return "the session cookie's Expires %r is not a date" % attrs["expires"]
|
|
898
|
-
if at - (time.time() if now is None else now) > bound:
|
|
899
|
-
return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
|
|
900
|
-
return None
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
def _semver(text: str) -> Tuple[int, int, int]:
|
|
904
|
-
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
|
|
905
|
-
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
|
|
909
|
-
"""What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
|
|
910
|
-
FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
|
|
911
|
-
if not isinstance(report, dict):
|
|
912
|
-
return ["not an object"]
|
|
913
|
-
out: List[str] = []
|
|
914
|
-
if report.get("protocol") != fs.PROTOCOL:
|
|
915
|
-
out.append("protocol is %r" % report.get("protocol"))
|
|
916
|
-
if report.get("service") != {"id": service_id, "instance": instance}:
|
|
917
|
-
out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
|
|
918
|
-
if report.get("currency") != "USD":
|
|
919
|
-
out.append("currency is not USD")
|
|
920
|
-
days = report.get("days")
|
|
921
|
-
if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
|
|
922
|
-
return out + ["days is not a list of at most 31"]
|
|
923
|
-
# The arithmetic of FAC-SEM-025 is the kit's `usage_findings`, the contract's `usageRules` held to its vectors: one
|
|
924
|
-
# implementation, so the probe never disagrees with the kit and never throws on a count that is not a number.
|
|
925
|
-
for i, day in enumerate(days):
|
|
926
|
-
if not isinstance(day, dict):
|
|
927
|
-
out.append("days[%d]: not an object" % i)
|
|
928
|
-
continue
|
|
929
|
-
if not isinstance(day.get("byModel"), list):
|
|
930
|
-
out.append("days[%d]: byModel missing" % i)
|
|
931
|
-
continue
|
|
932
|
-
for row, at in [(day, "days[%d]" % i)] + [(m, "days[%d].byModel[%d]" % (i, j)) for j, m in enumerate(day["byModel"]) if isinstance(m, dict)]:
|
|
933
|
-
cost = row.get("costUsd")
|
|
934
|
-
if cost is not None and (isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost < 0):
|
|
935
|
-
out.append("%s: costUsd is not a non-negative number" % at)
|
|
936
|
-
for j, m in enumerate(day["byModel"]):
|
|
937
|
-
# DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
|
|
938
|
-
# own metered models (a listing in the cloud).
|
|
939
|
-
if isinstance(m, dict) and "route" in m and m["route"] not in fs.USAGE_ROUTES:
|
|
940
|
-
out.append("days[%d].byModel[%d]: route %r is not subscription, key or platform-metered" % (i, j, m["route"]))
|
|
941
|
-
for finding in fs.usage_findings(report):
|
|
942
|
-
out.append("%s: %s" % (finding["instancePath"] or "/", finding["message"]))
|
|
943
|
-
return out
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
def same_repository(a: str, b: str) -> bool:
|
|
947
|
-
"""git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
|
|
948
|
-
def norm(u: str) -> str:
|
|
949
|
-
u = u.strip().lower()
|
|
950
|
-
u = re.sub(r"^git@([^:]+):", r"\1/", u)
|
|
951
|
-
u = re.sub(r"^[a-z+]+://", "", u)
|
|
952
|
-
u = re.sub(r"^[^@/]+@", "", u)
|
|
953
|
-
return re.sub(r"\.git$", "", u).rstrip("/")
|
|
954
|
-
return bool(a and b) and norm(a) == norm(b)
|
|
955
|
-
|
|
956
|
-
|
|
957
784
|
def locate(target: Optional[str], descriptor: Optional[str], services_dir: Path) -> Path:
|
|
958
785
|
if descriptor:
|
|
959
786
|
return Path(descriptor).expanduser()
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""The probe's rules that read one thing and need no running service (FAA-65): a plist's marker and priority, the
|
|
2
|
+
network guard's verdict, the board responder's fixture conversation, a session cookie's lifetime, a usage report, and
|
|
3
|
+
whether two repository URLs name one repository. ``check_service.py`` runs them against a live service.
|
|
4
|
+
Standard library only, Python 3.9+."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import email.utils
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import fabric_service as fs # noqa: E402 - beside this file
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plist_problems(plist: dict, label: object, token: object = None) -> list:
|
|
23
|
+
"""What makes a service's launchd job unfit to stay up: identity, restart, secrets."""
|
|
24
|
+
problems = []
|
|
25
|
+
if plist.get("Label") != label:
|
|
26
|
+
problems.append("Label %r" % plist.get("Label"))
|
|
27
|
+
if plist.get("RunAtLoad") is not True:
|
|
28
|
+
problems.append("RunAtLoad is not true")
|
|
29
|
+
if plist.get("KeepAlive") is not True:
|
|
30
|
+
problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
|
|
31
|
+
for key, value in (plist.get("EnvironmentVariables") or {}).items():
|
|
32
|
+
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
33
|
+
problems.append("secret-like variable %s in the plist" % key)
|
|
34
|
+
if token and token in str(value):
|
|
35
|
+
problems.append("the service token itself is in the plist")
|
|
36
|
+
return problems
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def marker_problem(plist: dict) -> Optional[str]:
|
|
40
|
+
"""What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
|
|
41
|
+
|
|
42
|
+
Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
|
|
43
|
+
lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
|
|
44
|
+
env = plist.get("EnvironmentVariables")
|
|
45
|
+
env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
|
|
46
|
+
argv = plist.get("ProgramArguments")
|
|
47
|
+
argv = [str(a) for a in argv] if isinstance(argv, list) else []
|
|
48
|
+
marker = fs.supervisor_marker(env, argv)
|
|
49
|
+
if marker["kind"] == "supervised" and marker["manager"] == "launchd":
|
|
50
|
+
return None
|
|
51
|
+
fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
|
|
52
|
+
"regenerate the plist with launchd_plist / launchdPlist and reinstall")
|
|
53
|
+
if marker["kind"] == "conflict":
|
|
54
|
+
return "%s: %s" % (fs.marker_conflict_message(marker), fix)
|
|
55
|
+
if marker["kind"] == "supervised":
|
|
56
|
+
return "the marker names %s, not launchd: %s" % (marker["manager"], fix)
|
|
57
|
+
if marker.get("unreadable") is not None:
|
|
58
|
+
return "the marker %r names no supervisor: %s" % (marker["unreadable"], fix)
|
|
59
|
+
return "no %s=launchd: %s" % (fs.SUPERVISOR_ENV, fix)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def priority_problems(plist: dict) -> list:
|
|
63
|
+
"""A service that answers hosts and agents must not be scheduled as background work."""
|
|
64
|
+
out = []
|
|
65
|
+
if plist.get("ProcessType", "Standard") in ("Background", "Adaptive"):
|
|
66
|
+
out.append("ProcessType is %s" % plist.get("ProcessType"))
|
|
67
|
+
if isinstance(plist.get("Nice"), int) and plist["Nice"] > 0:
|
|
68
|
+
out.append("Nice is %d" % plist["Nice"])
|
|
69
|
+
if plist.get("LowPriorityIO") is True or plist.get("LowPriorityBackgroundIO") is True:
|
|
70
|
+
out.append("low-priority I/O")
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# A platform router (Heroku, Fly, Render, a CDN) routes by Host, so a request naming a foreign
|
|
75
|
+
# Host never reaches an online service: the router itself answers, typically 404 or 421. That is
|
|
76
|
+
# still a refusal — the service disclosed nothing — so for a remote placement the host check
|
|
77
|
+
# accepts it, as long as the body is not the well-known document. Every other guard request names
|
|
78
|
+
# the right Host, reaches the service, and must be the service's own 403.
|
|
79
|
+
PLATFORM_HOST_REFUSALS = (400, 404, 421)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
|
|
83
|
+
if status == 403:
|
|
84
|
+
return "PASS", "HTTP 403"
|
|
85
|
+
disclosed = b"fabric-service/" in (body or b"")
|
|
86
|
+
if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
|
|
87
|
+
return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
|
|
88
|
+
return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
|
|
92
|
+
"""board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
|
|
93
|
+
declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
|
|
94
|
+
service's own copy (a .py or .mjs path); by default the kit beside this probe."""
|
|
95
|
+
protocols = wk.get("protocols") if isinstance(wk, dict) else None
|
|
96
|
+
declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
|
|
97
|
+
if not declared:
|
|
98
|
+
return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
|
|
99
|
+
accepted = fs.accepted_revisions("fabric-project-comms")
|
|
100
|
+
unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
|
|
101
|
+
if unread:
|
|
102
|
+
return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
|
|
103
|
+
try:
|
|
104
|
+
if kit and kit.endswith(".mjs"):
|
|
105
|
+
if not shutil.which("node"):
|
|
106
|
+
return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
|
|
107
|
+
url = Path(kit).resolve().as_uri()
|
|
108
|
+
out = subprocess.run(["node", "--input-type=module", "-e",
|
|
109
|
+
"import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
|
|
110
|
+
env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
|
|
111
|
+
problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
|
|
112
|
+
else:
|
|
113
|
+
# A clean interpreter: the kit is several modules (FAA-65), and modules this probe already imported from
|
|
114
|
+
# beside it would stand in for the copy under test.
|
|
115
|
+
path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
|
|
116
|
+
code = ("import json, sys; sys.path.insert(0, sys.argv[1]); import %s as board; "
|
|
117
|
+
"print(json.dumps(board.self_check()))" % path.stem)
|
|
118
|
+
out = subprocess.run([sys.executable, "-E", "-s", "-c", code, str(path.resolve().parent)], capture_output=True,
|
|
119
|
+
text=True, timeout=60, cwd=str(path.resolve().parent))
|
|
120
|
+
problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the kit did not run: %s" % out.stderr.strip()[-200:]]
|
|
121
|
+
except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
|
|
122
|
+
return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
|
|
123
|
+
if problems:
|
|
124
|
+
return "FAIL", "; ".join(problems)
|
|
125
|
+
return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
|
|
126
|
+
", ".join(declared), " at %s" % kit if kit else " beside this probe")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
|
|
130
|
+
"""DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
|
|
131
|
+
§5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
|
|
132
|
+
attrs = {}
|
|
133
|
+
for part in set_cookie.split(";")[1:]:
|
|
134
|
+
name, _, value = part.strip().partition("=")
|
|
135
|
+
attrs[name.strip().lower()] = value.strip()
|
|
136
|
+
bound = fs.OPERATOR_SESSION_MAX_SECONDS
|
|
137
|
+
if "max-age" in attrs:
|
|
138
|
+
if not re.fullmatch(r"-?\d+", attrs["max-age"]):
|
|
139
|
+
return "the session cookie's Max-Age %r is not a number" % attrs["max-age"]
|
|
140
|
+
if int(attrs["max-age"]) > bound:
|
|
141
|
+
return "the session cookie's Max-Age %s outlives 7 days (%d s, DEC-0039)" % (attrs["max-age"], bound)
|
|
142
|
+
return None
|
|
143
|
+
if "expires" in attrs:
|
|
144
|
+
try:
|
|
145
|
+
at = email.utils.parsedate_to_datetime(attrs["expires"]).timestamp()
|
|
146
|
+
except (TypeError, ValueError, IndexError, OverflowError):
|
|
147
|
+
return "the session cookie's Expires %r is not a date" % attrs["expires"]
|
|
148
|
+
if at - (time.time() if now is None else now) > bound:
|
|
149
|
+
return "the session cookie's Expires %s outlives 7 days (DEC-0039)" % attrs["expires"]
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _semver(text: str) -> Tuple[int, int, int]:
|
|
154
|
+
m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text or "")
|
|
155
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else (0, 0, 0)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def usage_problems(report: Any, service_id: str, instance: str) -> List[str]:
|
|
159
|
+
"""What makes this not a DEC-0021 usage report of the service: shape, identity and the sums of
|
|
160
|
+
FAC-SEM-025. An unknown cost must be null; a day's totals are the sums of its models."""
|
|
161
|
+
if not isinstance(report, dict):
|
|
162
|
+
return ["not an object"]
|
|
163
|
+
out: List[str] = []
|
|
164
|
+
if report.get("protocol") != fs.PROTOCOL:
|
|
165
|
+
out.append("protocol is %r" % report.get("protocol"))
|
|
166
|
+
if report.get("service") != {"id": service_id, "instance": instance}:
|
|
167
|
+
out.append("reports for %r, not %s.%s" % (report.get("service"), service_id, instance))
|
|
168
|
+
if report.get("currency") != "USD":
|
|
169
|
+
out.append("currency is not USD")
|
|
170
|
+
days = report.get("days")
|
|
171
|
+
if not isinstance(days, list) or len(days) > fs.USAGE_DAYS:
|
|
172
|
+
return out + ["days is not a list of at most 31"]
|
|
173
|
+
# The arithmetic of FAC-SEM-025 is the kit's `usage_findings`, the contract's `usageRules` held to its vectors: one
|
|
174
|
+
# implementation, so the probe never disagrees with the kit and never throws on a count that is not a number.
|
|
175
|
+
for i, day in enumerate(days):
|
|
176
|
+
if not isinstance(day, dict):
|
|
177
|
+
out.append("days[%d]: not an object" % i)
|
|
178
|
+
continue
|
|
179
|
+
if not isinstance(day.get("byModel"), list):
|
|
180
|
+
out.append("days[%d]: byModel missing" % i)
|
|
181
|
+
continue
|
|
182
|
+
for row, at in [(day, "days[%d]" % i)] + [(m, "days[%d].byModel[%d]" % (i, j)) for j, m in enumerate(day["byModel"]) if isinstance(m, dict)]:
|
|
183
|
+
cost = row.get("costUsd")
|
|
184
|
+
if cost is not None and (isinstance(cost, bool) or not isinstance(cost, (int, float)) or cost < 0):
|
|
185
|
+
out.append("%s: costUsd is not a non-negative number" % at)
|
|
186
|
+
for j, m in enumerate(day["byModel"]):
|
|
187
|
+
# DEC-0046, DEC-0061: a row's route is a login in its vendor's runner, an issued key, or the platform's
|
|
188
|
+
# own metered models (a listing in the cloud).
|
|
189
|
+
if isinstance(m, dict) and "route" in m and m["route"] not in fs.USAGE_ROUTES:
|
|
190
|
+
out.append("days[%d].byModel[%d]: route %r is not subscription, key or platform-metered" % (i, j, m["route"]))
|
|
191
|
+
for finding in fs.usage_findings(report):
|
|
192
|
+
out.append("%s: %s" % (finding["instancePath"] or "/", finding["message"]))
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def same_repository(a: str, b: str) -> bool:
|
|
197
|
+
"""git@github.com:o/r.git, https://github.com/o/r and github.com/o/r are one repository."""
|
|
198
|
+
def norm(u: str) -> str:
|
|
199
|
+
u = u.strip().lower()
|
|
200
|
+
u = re.sub(r"^git@([^:]+):", r"\1/", u)
|
|
201
|
+
u = re.sub(r"^[a-z+]+://", "", u)
|
|
202
|
+
u = re.sub(r"^[^@/]+@", "", u)
|
|
203
|
+
return re.sub(r"\.git$", "", u).rstrip("/")
|
|
204
|
+
return bool(a and b) and norm(a) == norm(b)
|