NK-WP-0058-T08 worker protocol and excerpt tool; T06 window watch (row O5), first use of the protocol
T08: .claude/rules/worker-protocol.md (imported by CLAUDE.md, pointed to from AGENTS.md) and tools/contract-tests/excerpt.py: name the rows, hand every worker one excerpt, one owner per step, check with the excerpt's tests. A test requires every table row to have details and a contract test (found O4). T06: bounded-executor/window_watch.py flags or closes the write role when no run Job is active or recent; stub tests; in the image. Built from the excerpt for O5, E1, C1, E8, E9, R2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 943911@bnt-lap001 Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0
This commit is contained in:
parent
558bfce848
commit
c63aa398d6
9 changed files with 398 additions and 3 deletions
18
.claude/rules/worker-protocol.md
Normal file
18
.claude/rules/worker-protocol.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
## Cross-repo worker protocol (NK-WP-0058-T08)
|
||||
|
||||
**Applies when** a change touches an interface on the bounded-execution path, or work is split across repositories
|
||||
or worker agents. On 2026-11-04, workers in two repos built competing seeders and used different Secret names.
|
||||
|
||||
1. **Name the rows.** List the rows of `docs/bounded-execution-contract-table.md` the change touches. A new interface
|
||||
gets its row first: owner and source of truth, consumer, the exact contract, and the test that will cover it.
|
||||
2. **Hand out one excerpt.** Run `python3 tools/contract-tests/excerpt.py <rows>` and give every worker that
|
||||
excerpt verbatim. Names, paths, roles, payloads and exit codes come from it. A worker never invents a second
|
||||
name or a parallel script for a step that already has an owning script.
|
||||
3. **One owner per step.** A worker changes only its own repository's part. Another repository's part is a request
|
||||
to that owner, unless the founder has instructed otherwise; record the instruction.
|
||||
4. **Check against the excerpt.** Before reporting done:
|
||||
- run the excerpt's contract tests;
|
||||
- run `make contract-test` and `make conventions-lint`;
|
||||
- move a pin in `contracts/owner-pins.json` only to a pushed owner commit;
|
||||
- record in the workplan result which rows and excerpt the work used.
|
||||
5. **Parallel workers** use unique scratch file names and run State Hub syncs one at a time.
|
||||
|
|
@ -300,3 +300,10 @@ to this repository's owner contracts. Work: `emergent-security/EMERGENT-WP-0002`
|
|||
6. **Leave a trail:** commits get assistant trailers from the hook; log State Hub progress with your agent
|
||||
name and session.
|
||||
<!-- END NETKINGDOM-SECURITY-QUICKSTART -->
|
||||
|
||||
## Cross-repo worker protocol (all agents)
|
||||
|
||||
Before a change that touches the bounded-execution path or spans repositories, read and follow
|
||||
`.claude/rules/worker-protocol.md` (NK-WP-0058-T08). In short: name the contract-table rows, give every worker the
|
||||
output of `python3 tools/contract-tests/excerpt.py <rows>`, keep one owner per step, and check the result with the
|
||||
excerpt's contract tests, `make contract-test` and `make conventions-lint`.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
@.claude/rules/repo-boundary.md
|
||||
@.claude/rules/credential-routing.md
|
||||
@.claude/rules/agents.md
|
||||
@.claude/rules/worker-protocol.md
|
||||
|
||||
<!-- BEGIN EMERGENT-SECURITY-PILOT -->
|
||||
## NetKingdom situational knowledge pilot
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ RUN python -m pip install --no-cache-dir --require-hashes -r /app/bounded-execut
|
|||
COPY canon/schemas/bounded-execution-admission_v0.1.schema.json /app/canon/schemas/
|
||||
COPY tools/bounded-execution-admission/validate.py /app/tools/bounded-execution-admission/
|
||||
COPY sso-mfa/k8s/synthetic-journey/journey.py /app/sso-mfa/k8s/synthetic-journey/
|
||||
COPY bounded-executor/executor.py bounded-executor/operations.py bounded-executor/clients.py bounded-executor/cli.py /app/bounded-executor/
|
||||
COPY bounded-executor/executor.py bounded-executor/operations.py bounded-executor/clients.py bounded-executor/cli.py \
|
||||
bounded-executor/window_watch.py /app/bounded-executor/
|
||||
USER 10001:10001
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENTRYPOINT ["python", "/app/bounded-executor/cli.py"]
|
||||
|
|
|
|||
110
bounded-executor/tests/test_window_watch.py
Normal file
110
bounded-executor/tests/test_window_watch.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""window_watch.py on stubs (NK-WP-0058-T06, contract row O5): rule, both modes, and errors."""
|
||||
import datetime as dt
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
spec = importlib.util.spec_from_file_location('window_watch', HERE.parent / 'window_watch.py')
|
||||
W = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(W)
|
||||
OPS = W.OPS
|
||||
NOW = dt.datetime(2026, 11, 6, 3, 0, tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
def job(minutes_ago, active=0):
|
||||
created = (NOW - dt.timedelta(minutes=minutes_ago)).isoformat().replace('+00:00', 'Z')
|
||||
return {'metadata': {'creationTimestamp': created}, 'status': {'active': active} if active else {}}
|
||||
|
||||
|
||||
class FakeBao:
|
||||
def __init__(self, present=True, delete_works=True):
|
||||
self.present, self.delete_works, self.calls = present, delete_works, []
|
||||
|
||||
def _call(self, method, path, body=None, auth=True):
|
||||
self.calls.append((method, path))
|
||||
assert path == W.ROLE_PATH, path # nothing but the write role is ever touched
|
||||
if method == 'GET':
|
||||
if not self.present:
|
||||
raise OPS.OperationError('openbao GET 404')
|
||||
return {'data': {}}
|
||||
if method == 'DELETE':
|
||||
self.present = not self.delete_works
|
||||
return {}
|
||||
raise AssertionError(method)
|
||||
|
||||
|
||||
class FakeKube:
|
||||
def __init__(self, jobs):
|
||||
self.jobs, self.paths = jobs, []
|
||||
|
||||
def get(self, path):
|
||||
self.paths.append(path)
|
||||
return {'items': self.jobs}
|
||||
|
||||
|
||||
class Rule(unittest.TestCase):
|
||||
def test_decide(self):
|
||||
cases = [
|
||||
(False, [], 'ok'), # no role: nothing open
|
||||
(True, [], 'stale'), # role, never a run: left open
|
||||
(True, [job(3)], 'ok'), # role, run just created: a window in use
|
||||
(True, [job(30, active=1)], 'ok'), # role, run still active
|
||||
(True, [job(30)], 'stale'), # role, last run finished 30 min ago: left open
|
||||
(True, [job(30), job(2)], 'ok'), # the newest run counts
|
||||
]
|
||||
for present, jobs, verdict in cases:
|
||||
with self.subTest(present=present, jobs=len(jobs)):
|
||||
self.assertEqual(W.decide(present, jobs, NOW)[0], verdict)
|
||||
|
||||
def test_run_jobs_selector_excludes_other_components(self):
|
||||
kube = FakeKube([])
|
||||
W.run_jobs(kube)
|
||||
self.assertIn('labelSelector=app.kubernetes.io%2Fname%3Dbounded-executor%2C%21app.kubernetes.io%2Fcomponent',
|
||||
kube.paths[0])
|
||||
self.assertTrue(kube.paths[0].startswith('/apis/batch/v1/namespaces/sso/jobs?'))
|
||||
|
||||
|
||||
class Modes(unittest.TestCase):
|
||||
def test_ok_touches_nothing(self):
|
||||
bao = FakeBao(present=False)
|
||||
result, code = W.watch(bao, FakeKube([]), 'close', NOW)
|
||||
self.assertEqual((result['status'], code), ('ok', 0))
|
||||
self.assertEqual([m for m, _ in bao.calls], ['GET'])
|
||||
|
||||
def test_flag_reads_only_and_fails_the_job(self):
|
||||
bao = FakeBao()
|
||||
result, code = W.watch(bao, FakeKube([job(30)]), 'flag', NOW)
|
||||
self.assertEqual((result['status'], code), ('flagged_stale', 3))
|
||||
self.assertEqual([m for m, _ in bao.calls], ['GET'])
|
||||
self.assertEqual(result['newest_age_s'], 1800)
|
||||
|
||||
def test_close_deletes_and_proves_absent(self):
|
||||
bao = FakeBao()
|
||||
result, code = W.watch(bao, FakeKube([]), 'close', NOW)
|
||||
self.assertEqual((result['status'], code), ('closed_stale', 0))
|
||||
self.assertEqual([m for m, _ in bao.calls], ['GET', 'DELETE', 'GET'])
|
||||
self.assertFalse(bao.present)
|
||||
|
||||
def test_close_that_does_not_take_is_an_error(self):
|
||||
result, code = W.watch(FakeBao(delete_works=False), FakeKube([]), 'close', NOW)
|
||||
self.assertEqual((result['status'], result['reason'], code), ('error', 'write_role_still_present', 5))
|
||||
|
||||
def test_a_window_in_use_is_never_closed(self):
|
||||
bao = FakeBao()
|
||||
result, code = W.watch(bao, FakeKube([job(1, active=1)]), 'close', NOW)
|
||||
self.assertEqual((result['status'], code), ('ok', 0))
|
||||
self.assertTrue(bao.present)
|
||||
|
||||
def test_other_openbao_errors_are_not_read_as_absent(self):
|
||||
class Denied(FakeBao):
|
||||
def _call(self, method, path, body=None, auth=True):
|
||||
raise OPS.OperationError('openbao GET 403')
|
||||
with self.assertRaises(OPS.OperationError):
|
||||
W.watch(Denied(), FakeKube([]), 'close', NOW)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
98
bounded-executor/window_watch.py
Normal file
98
bounded-executor/window_watch.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Window watch: the write role must not outlive a run (NK-WP-0058-T06; contract row O5).
|
||||
|
||||
python3 bounded-executor/window_watch.py --mode flag|close [--openbao-addr URL] [--jwt-file FILE]
|
||||
|
||||
Rule: the write role (CCR-2026-0058) exists, no bounded-executor run Job is active in sso, and none was created in
|
||||
the last GRACE seconds. A role in that state is a window left open, as on 2026-11-04/05. Run Jobs carry
|
||||
app.kubernetes.io/name=bounded-executor and no app.kubernetes.io/component label (request, preflight, journey and
|
||||
netcheck pods carry one).
|
||||
|
||||
flag read-only: print `flagged_stale` and exit 3, so the watch Job fails and its status shows it
|
||||
close delete the role, prove it is absent, print `closed_stale`, exit 0. Closing only reduces privilege: a run
|
||||
that was about to start is refused before any effect.
|
||||
|
||||
The identity is ServiceAccount sso/nk-window-watch. It logs in to OpenBao role
|
||||
net-kingdom-bounded-executor-window-watch, whose policy allows read (flag), or read and delete (close), on the
|
||||
write role only, and it may list Jobs in sso. Prints one value-free line; exit 0 ok or closed, 3 flagged, 5 error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import operations as OPS # noqa: E402
|
||||
|
||||
WRITE_ROLE = 'net-kingdom-bind-executor-svc-authelia-write'
|
||||
WATCH_ROLE = 'net-kingdom-bounded-executor-window-watch'
|
||||
ROLE_PATH = '/v1/auth/kubernetes/role/' + WRITE_ROLE
|
||||
RUN_JOBS = 'app.kubernetes.io/name=bounded-executor,!app.kubernetes.io/component'
|
||||
GRACE = 600 # seconds: the window opens moments before its run Job is created
|
||||
|
||||
|
||||
def role_present(bao) -> bool:
|
||||
try:
|
||||
bao._call('GET', ROLE_PATH)
|
||||
return True
|
||||
except OPS.OperationError as error:
|
||||
if str(error).endswith(' 404'):
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def run_jobs(kube, namespace='sso'):
|
||||
query = urllib.parse.urlencode({'labelSelector': RUN_JOBS})
|
||||
return kube.get(f'/apis/batch/v1/namespaces/{namespace}/jobs?{query}').get('items', [])
|
||||
|
||||
|
||||
def decide(present: bool, jobs: list, now: dt.datetime, grace: int = GRACE) -> tuple[str, dict]:
|
||||
"""The rule, without I/O: ('ok' | 'stale', value-free facts)."""
|
||||
active = sum(1 for j in jobs if (j.get('status') or {}).get('active'))
|
||||
created = [dt.datetime.fromisoformat(j['metadata']['creationTimestamp'].replace('Z', '+00:00')) for j in jobs]
|
||||
newest = max(created, default=None)
|
||||
age = int((now - newest).total_seconds()) if newest else None
|
||||
facts = {'write_role_present': present, 'run_jobs': len(jobs), 'active': active, 'newest_age_s': age}
|
||||
stale = present and not active and (age is None or age > grace)
|
||||
return ('stale' if stale else 'ok'), facts
|
||||
|
||||
|
||||
def watch(bao, kube, mode: str, now: dt.datetime) -> tuple[dict, int]:
|
||||
verdict, facts = decide(role_present(bao), run_jobs(kube), now)
|
||||
if verdict == 'ok':
|
||||
return {'status': 'ok', **facts}, 0
|
||||
if mode == 'flag':
|
||||
return {'status': 'flagged_stale', **facts}, 3
|
||||
bao._call('DELETE', ROLE_PATH)
|
||||
if role_present(bao):
|
||||
return {'status': 'error', 'reason': 'write_role_still_present', **facts}, 5
|
||||
return {'status': 'closed_stale', **facts}, 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__.split('\n', 1)[0])
|
||||
p.add_argument('--mode', choices=['flag', 'close'], required=True)
|
||||
p.add_argument('--openbao-addr', default='http://openbao.openbao.svc:8200')
|
||||
p.add_argument('--jwt-file', default='/var/run/secrets/openbao/token')
|
||||
a = p.parse_args(argv)
|
||||
bao = None
|
||||
try:
|
||||
bao = OPS.OpenBao(a.openbao_addr, k8s_role=WATCH_ROLE, jwt_file=a.jwt_file)
|
||||
result, code = watch(bao, OPS.Kube(), a.mode, dt.datetime.now(dt.timezone.utc))
|
||||
except Exception as error: # value-free: the clients' own errors carry fixed texts
|
||||
reason = str(error) if isinstance(error, OPS.OperationError) else type(error).__name__
|
||||
result, code = {'status': 'error', 'reason': reason}, 5
|
||||
finally:
|
||||
if bao is not None:
|
||||
bao.revoke_self()
|
||||
print(json.dumps({'watch': 'bounded-execution-window', 'mode': a.mode, **result}, sort_keys=True))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -311,7 +311,8 @@ NK-WP-0058-T09, as of 2026-11-05. It uses the same revisions as part 1, plus acc
|
|||
`sso/nk-bind-executor-audit` key `audit-token`. That is a removal or correction request to railiance-platform.
|
||||
- audit-core's seeder hard-codes `/home/worsch/railiance-platform/...CCR-2026-0063...yaml` and the cluster UID
|
||||
(`seed-nk-bounded-executor-sender.py:58-69`), for T07.
|
||||
- the names appear in four repos (CCR, seeder, ESO manifest, rapp Job) and no test compares them.
|
||||
- the names appear in four repos (CCR, seeder, ESO manifest, rapp Job); since T02,
|
||||
`tools/contract-tests/tests/test_custody_names.py` compares them, and the rapp's CI checks the Job mount.
|
||||
|
||||
### C6: the executor's submitter profile
|
||||
|
||||
|
|
@ -368,6 +369,7 @@ evidence `docs/evidence/2026-11-04-nk56-t06-go-live/`, `2026-11-04-nk56-t06-pilo
|
|||
| O2 | warden lanes ↔ child scripts | ops-warden: `warden access <lane> --exec` (silent, one command, fails on output, revokes on every path). Children: railiance-platform and audit-core silent scripts | the founder; seven lane invocations in the go-live runbook | none across repos | Seed r1 refused `token_env_must_be_unset`: warden strips `BAO_TOKEN` and `VAULT_TOKEN`, not `OPENBAO_TOKEN` | **keep**; **owner request** to ops-warden |
|
||||
| O3 | Platform-admin session: lane vs shell | Two patterns: ops-warden lanes (railiance-platform, audit-core) and an interactive `bao login -method=oidc -path=netkingdom role=platform-admin` in the shell (net-kingdom `executor-lanes.py`, `pilot-run.sh`, T06 runbook 6c) | `executor-lanes.py attended_platform_admin()` | none | 2026-11-05: `window-close` failed `phase: identity, LaneError` in a shell without a login | **merge** into one pattern |
|
||||
| O4 | Cluster access routes | Three in one runbook: `kubectl --kubeconfig ~/.kube/config-railiance01` (admin); `keycape-client.py` via `portal-client-rollout.py` hard-coding `/home/worsch/.kube/config-hosteurope` with `--server https://127.0.0.1:16444` (cluster UID pinned); the agent's default context (NetworkPolicy reads only) | founder, agents | none | none directly; costs founder setup | **merge** (T07: no hard-coded kubeconfigs) |
|
||||
| O5 | Window watch: the write role outside a run | net-kingdom `bounded-executor/window_watch.py` (rule, modes); rapp CronJob `bounded-executor-window-watch` (schedule, identity); railiance-platform CCR for its OpenBao role (read, or read and delete, on the write role only) | the founder; the agent status identity (CronJob status) | `bounded-executor/tests/test_window_watch.py` (stubs); rapp packaging tests | 2026-11-04/05: the write role stayed open overnight (T11) | **keep** (new, NK-WP-0058-T06) |
|
||||
| F1 | Founder ↔ runbook steps | net-kingdom T06 runbook, railiance-platform go-live, audit-core founder runbook | the founder | live only | about 50 copy-paste rounds; values carried by hand between steps (table below) | **merge** into T04's commands |
|
||||
| F2 | Founder identity in the browser | informed-decision UI at aal2; the identity model (`admin` for production, `platform-root` kept out) | the founder; executor `_check_approval` | none | The first approval came as `uid=platform-root`, the identity signed in at that moment, and was refused | **keep** the refusal; add identity hygiene to T04 |
|
||||
|
||||
|
|
@ -404,6 +406,34 @@ Argo pin with live) cover the gap.
|
|||
as silent children of the warden lane (`executor-lanes.py --quiet`). `ready` and `run` each need one login,
|
||||
and no shell login is left open. `pilot-run.sh` is deleted.
|
||||
|
||||
### O4: one cluster route
|
||||
|
||||
- **Contract (T07, 2026-11-06):** tools and runbooks reach the cluster through one route, `NK_KUBECONFIG`,
|
||||
defaulting to `~/.kube/config-railiance01`. No second kubeconfig file, absolute home path or `--server` override.
|
||||
- **Exceptions:** node-local commands on railiance01 use the node's own `/etc/rancher/k3s/k3s.yaml`. They're listed
|
||||
in `tools/conventions/lint.py` with their reasons.
|
||||
- **The agent's own read-only route (T05):** `~/.kube/config-agent-status`, identity `sso/nk-agent-status`, 1 h
|
||||
tokens.
|
||||
- **Checked by:** the `one-cluster-route` rule of `tools/conventions/lint.py`.
|
||||
|
||||
### O5: window watch
|
||||
|
||||
- **Rule:** the write role `net-kingdom-bind-executor-svc-authelia-write` exists, no bounded-executor run Job is
|
||||
active, and none was created in the last 10 minutes. Run Jobs are the Jobs labelled
|
||||
`app.kubernetes.io/name=bounded-executor` without an `app.kubernetes.io/component` label. A role in that state is
|
||||
a window left open.
|
||||
- **Modes:**
|
||||
- `flag` (read-only): the watch Job exits 3 and its status shows `Failed`.
|
||||
- `close`: delete the role, prove it is absent, and exit 0 with `closed_stale`.
|
||||
|
||||
Closing only reduces privilege; a run that was about to start is then refused before any effect.
|
||||
- **Identity:** ServiceAccount `sso/nk-window-watch`, projected token with audience `openbao`. Its OpenBao role is
|
||||
`net-kingdom-bounded-executor-window-watch`, with a policy of `read` (flag) or `read` and `delete` (close) on
|
||||
`auth/kubernetes/role/net-kingdom-bind-executor-svc-authelia-write` only, plus token self-lifecycle. Its
|
||||
Kubernetes Role allows `list` on Jobs in `sso`. No Secrets.
|
||||
- **Output:** one value-free line: `ok`, `flagged_stale` or `closed_stale`, the count of run Jobs and the age of the
|
||||
newest one. It holds no token and no value.
|
||||
|
||||
### F1: the founder's steps, 2026-11-04/05
|
||||
|
||||
| Step | Authority it needs | Value carried by hand | Value-free result | Safe to repeat |
|
||||
|
|
@ -526,7 +556,8 @@ The agent's default context can read NetworkPolicies in `sso`, `approval-engine`
|
|||
| C1 | `tests/test_executor_lanes.py` runs railiance-platform's real helper modules | net-kingdom contract CI |
|
||||
| C2, C3 | the readiness check: `case_type_bytes_match_broker`, `case_materialized` | net-kingdom contract CI |
|
||||
| C4, C5 | `tools/contract-tests/tests/test_custody_names.py` | net-kingdom contract CI |
|
||||
| O2–O4, F1, F2 | `tools/bounded-execution-run/tests/test_bx.py` (lane child silent, window always closed); conventions in T07 | net-kingdom contract CI |
|
||||
| O5 | `bounded-executor/tests/test_window_watch.py` (rule and modes on stubs); rapp `tests/test_packaging.py` (CronJob shape) | net-kingdom contract CI; rapp CI |
|
||||
| O2–O4, F1, F2 | `tools/bounded-execution-run/tests/test_bx.py` (lane child silent, window always closed); `tools/conventions/lint.py` (`one-cluster-route` for O4) | net-kingdom contract CI |
|
||||
|
||||
Not covered from source: OpenBao's ingress (rapp-openbao is private) and live drift from source, such as the
|
||||
approval-engine policy that was live but not in its file. The readiness preflight Job catches unreachable endpoints
|
||||
|
|
|
|||
62
tools/contract-tests/excerpt.py
Normal file
62
tools/contract-tests/excerpt.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Contract excerpt for a worker on a cross-repo change (NK-WP-0058-T08). Read-only.
|
||||
|
||||
python3 tools/contract-tests/excerpt.py E1 C5 O1 # rows of docs/bounded-execution-contract-table.md
|
||||
|
||||
Prints, for each row: its line in the table (owner and source of truth, consumer, mark), its detail sections (names,
|
||||
paths, roles, payloads, exit codes, and the one owning script or file per step), and the contract tests that cover
|
||||
it. A worker gets this excerpt before starting and checks its result with those tests and `make contract-test`.
|
||||
An unknown row fails: a new interface gets its row in the table first.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TABLE = Path(__file__).resolve().parents[2] / 'docs/bounded-execution-contract-table.md'
|
||||
ROW = re.compile(r'^[EROCF]\d+$')
|
||||
|
||||
|
||||
def ids_in(text):
|
||||
"""Row ids named in a cell or heading, with ranges like O2–O4 expanded."""
|
||||
found = set()
|
||||
for first, last in re.findall(r'\b([EROCF]\d+)\s*[–-]\s*([EROCF]\d+)\b', text):
|
||||
if first[0] == last[0]:
|
||||
found |= {f'{first[0]}{n}' for n in range(int(first[1:]), int(last[1:]) + 1)}
|
||||
return found | set(re.findall(r'\b[EROCF]\d+\b', text))
|
||||
|
||||
|
||||
def sections(text):
|
||||
"""(heading, body) for each ### section."""
|
||||
parts = re.split(r'^(### .+)$', text, flags=re.M)
|
||||
return [(parts[i], parts[i + 1].split('\n## ')[0]) for i in range(1, len(parts) - 1, 2)]
|
||||
|
||||
|
||||
def excerpt(row_ids, text=None):
|
||||
text = text if text is not None else TABLE.read_text()
|
||||
out = []
|
||||
for row in row_ids:
|
||||
if not ROW.match(row):
|
||||
raise SystemExit(f'not a row id: {row}')
|
||||
line = next((l for l in text.splitlines() if l.startswith(f'| {row} |')), None)
|
||||
if line is None:
|
||||
raise SystemExit(f'{row}: no such row; add the interface to the table first')
|
||||
out += [f'## {row}', '', line, '']
|
||||
for heading, body in sections(text):
|
||||
if row in ids_in(heading.split(':')[0]):
|
||||
out += [heading, body.strip(), '']
|
||||
coverage = [l for l in text[text.index('### Coverage by row'):].splitlines()
|
||||
if l.startswith('| ') and not l.startswith('| Row') and row in ids_in(l.split('|')[1])]
|
||||
out += ['**Contract tests:**', *(coverage or ['none recorded: add one with the change']), '']
|
||||
return '\n'.join(out)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
rows = argv if argv is not None else sys.argv[1:]
|
||||
if not rows:
|
||||
raise SystemExit(__doc__)
|
||||
print(excerpt(rows))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
67
tools/contract-tests/tests/test_excerpt.py
Normal file
67
tools/contract-tests/tests/test_excerpt.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""tools/contract-tests/excerpt.py (NK-WP-0058-T08): worker excerpts from the contract table."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
spec = importlib.util.spec_from_file_location('contract_excerpt', REPO / 'tools/contract-tests/excerpt.py')
|
||||
excerpt = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(excerpt)
|
||||
|
||||
SYNTHETIC = """## Part 1
|
||||
|
||||
| ID | Interface | Owner |
|
||||
| --- | --- | --- |
|
||||
| E1 | lanes | platform |
|
||||
| O2 | lane child | warden |
|
||||
|
||||
### E1: lanes
|
||||
|
||||
- role `r`, path `p`
|
||||
|
||||
### O2 and O3: sessions
|
||||
|
||||
- silent child
|
||||
|
||||
## Contract CI
|
||||
|
||||
### Coverage by row (2026-11-06)
|
||||
|
||||
| Row | Contract test | Runs in |
|
||||
| --- | --- | --- |
|
||||
| E1 | `tests/test_lanes.py` | CI |
|
||||
| O2–O4 | `tests/test_bx.py` | CI |
|
||||
"""
|
||||
|
||||
|
||||
def test_ranges_and_lists():
|
||||
assert excerpt.ids_in('O2–O4, F1, F2') == {'O2', 'O3', 'O4', 'F1', 'F2'}
|
||||
assert excerpt.ids_in('E2, E3') == {'E2', 'E3'}
|
||||
|
||||
|
||||
def test_row_details_and_tests():
|
||||
text = excerpt.excerpt(['E1', 'O2'], SYNTHETIC)
|
||||
assert '| E1 | lanes | platform |' in text and 'role `r`, path `p`' in text
|
||||
assert '`tests/test_lanes.py`' in text
|
||||
assert '### O2 and O3: sessions' in text and '`tests/test_bx.py`' in text
|
||||
|
||||
|
||||
def test_unknown_rows_fail():
|
||||
with pytest.raises(SystemExit, match='add the interface to the table first'):
|
||||
excerpt.excerpt(['C9'], SYNTHETIC)
|
||||
with pytest.raises(SystemExit, match='not a row id'):
|
||||
excerpt.excerpt(['nonsense'], SYNTHETIC)
|
||||
|
||||
|
||||
def test_every_row_of_the_real_table_has_details_and_a_contract_test():
|
||||
text = excerpt.TABLE.read_text()
|
||||
rows = [line.split('|')[1].strip() for line in text.splitlines()
|
||||
if line.startswith('| ') and excerpt.ROW.match(line.split('|')[1].strip())
|
||||
and '## Routing' not in text[:text.index(line)][-2000:]]
|
||||
rows = sorted({r for r in rows})
|
||||
assert rows, 'no rows found'
|
||||
for row in rows:
|
||||
out = excerpt.excerpt([row], text)
|
||||
assert f'### {row}' in out or f' {row}:' in out or f'and {row}' in out, f'{row}: no detail section'
|
||||
assert 'none recorded' not in out, f'{row}: no contract test recorded'
|
||||
Loading…
Add table
Add a link
Reference in a new issue