NK-WP-0048-T12: register-client restarts the issuer and waits for readiness after a changed registry
Per coordinator r6. New register-client descriptor sha256:434c53e8...43cb; 1.2/1.3 compositions regenerated; 1.3 map and status-only ready_check updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 696317@bnt-lap001 Assistant-Session: bab9e511-0915-45b3-926a-2fab552edff6
This commit is contained in:
parent
3a68d05eb9
commit
fad2c72750
6 changed files with 159 additions and 12 deletions
|
|
@ -313,6 +313,10 @@ spec:
|
|||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
ready_check: after a changed registry the adapter stamps one restart of the single-replica
|
||||
Recreate issuer and its status-only readiness verdict is ready (rollout 1/1/1, one restarted
|
||||
Ready pod, discovery serves https://kc.coulomb.social with all endpoints); failed or uncertain
|
||||
keeps the step uncertain for attended owner rollback or reconciliation (NK-WP-0048-T12)
|
||||
attended_justification: 'The live KeyCape client registry is a Kubernetes Secret,
|
||||
and agents do not read or write Secrets. Until KeyCape loads clients from a
|
||||
non-secret registry with secrets referenced from custody, one attended operator
|
||||
|
|
@ -495,4 +499,4 @@ spec:
|
|||
ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a
|
||||
hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224
|
||||
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
|
||||
register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7
|
||||
register-client: sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb
|
||||
|
|
|
|||
|
|
@ -150,11 +150,11 @@
|
|||
"type_owner": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"candidate_descriptor_digest": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7",
|
||||
"candidate_descriptor_digest": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb",
|
||||
"descriptor": {
|
||||
"actor": "human",
|
||||
"adapter": "net-kingdom/keycape-registration@v1",
|
||||
"adapter_source": "sha256:b30f7abfbb5bf439c5ce067325b01c8b711076a38a6fb8484f55d5c8912921f3",
|
||||
"adapter_source": "sha256:212172e34155d1852375b4c6c2868c3da82f5f8e7c7957f6c9eab1430880f6d3",
|
||||
"allowed_created": [
|
||||
"keycape-client"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"ensure-groups": "sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a",
|
||||
"hand-over": "sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224",
|
||||
"mint-secret": "sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70",
|
||||
"register-client": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7"
|
||||
"register-client": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb"
|
||||
},
|
||||
"owner_review": false,
|
||||
"params": {
|
||||
|
|
@ -154,11 +154,11 @@
|
|||
"type_owner": "net-kingdom"
|
||||
},
|
||||
{
|
||||
"candidate_descriptor_digest": "sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7",
|
||||
"candidate_descriptor_digest": "sha256:434c53e88d4dd86a0325603468c3d6618570f38155484a76ed37d2f1550d43cb",
|
||||
"descriptor": {
|
||||
"actor": "human",
|
||||
"adapter": "net-kingdom/keycape-registration@v1",
|
||||
"adapter_source": "sha256:b30f7abfbb5bf439c5ce067325b01c8b711076a38a6fb8484f55d5c8912921f3",
|
||||
"adapter_source": "sha256:212172e34155d1852375b4c6c2868c3da82f5f8e7c7957f6c9eab1430880f6d3",
|
||||
"allowed_created": [
|
||||
"keycape-client"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -24,13 +24,20 @@ from warden.case_receipt_state import Outcome, Refused
|
|||
ROOT = Path(__file__).resolve().parent
|
||||
SOURCE_FILES = ('keycape-registration-adapter.py', 'keycape-client.py',
|
||||
'portal-client-rollout.py', 'openbao-client-config.py',
|
||||
'register-user-engine-portal.py')
|
||||
'register-user-engine-portal.py', 'restart-readiness.py')
|
||||
REFERENCE = 'net-kingdom/keycape-registration@v1'
|
||||
|
||||
# Importing the maintained helper performs no tooling operation.
|
||||
_spec = importlib.util.spec_from_file_location('registration_owner_helper', ROOT / 'keycape-client.py')
|
||||
helper = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(helper)
|
||||
_spec = importlib.util.spec_from_file_location('registration_restart', ROOT / 'restart-readiness.py')
|
||||
restart_readiness = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(restart_readiness)
|
||||
RESTART_KEYS = frozenset({'read', 'write', 'observe', 'sleep', 'issuer'})
|
||||
# KeyCape loads clients at startup: a changed registry is not in effect until the
|
||||
# issuer restarts. Bounded wait: 60 x 5 s.
|
||||
RESTART_ATTEMPTS, RESTART_INTERVAL = 60, 5
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -85,7 +92,7 @@ def descriptor(check_config_digest):
|
|||
def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
||||
expected_resource_version: str, case_acceptance: str,
|
||||
check_config: Path, check_config_digest: str, publish, authenticate,
|
||||
revoke_session, holder=None, observe=None):
|
||||
revoke_session, restart, holder=None, observe=None):
|
||||
"""Bind one admitted case and observed revision; return a closed kernel Adapter.
|
||||
|
||||
publish(bytes)->identifier durably retains only the closed metadata payload.
|
||||
|
|
@ -94,6 +101,14 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
|||
session; Warden independently authenticates it using its own evidence verifier.
|
||||
These callbacks are capabilities, never command strings or case parameters.
|
||||
|
||||
restart is a mapping of read()->Deployment, write(Deployment, expected_rv)->rv
|
||||
(CAS), observe()->(Deployment, pods, discovery), sleep(seconds) and the
|
||||
expected issuer URL. After any apply that changed the registry, the step
|
||||
stamps one restart of the single-replica issuer and waits for the reviewed
|
||||
readiness verdict (restart-readiness.py, NK-WP-0048-T12). Anything but
|
||||
`ready` raises, so the kernel keeps the step uncertain for attended owner
|
||||
rollback or reconciliation; it is never retried here.
|
||||
|
||||
holder(original_attempt_id)->[(identifier, bytes)] and observe()->secret are
|
||||
optional read-only capabilities, injected together or not at all. With them
|
||||
the Adapter gains reconcile_original: it confirms a creation only from one
|
||||
|
|
@ -133,6 +148,10 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
|||
raise Refused('acceptance evidence binding differs')
|
||||
if not all(callable(x) for x in (publish, authenticate, revoke_session)):
|
||||
raise Refused('owner evidence and revocation capabilities required')
|
||||
if (not isinstance(restart, dict) or set(restart) != RESTART_KEYS
|
||||
or not all(callable(restart[k]) for k in RESTART_KEYS - {'issuer'})
|
||||
or not isinstance(restart['issuer'], str) or not restart['issuer'].startswith('https://')):
|
||||
raise Refused('issuer restart and readiness capabilities required')
|
||||
if (holder is None) != (observe is None) or not all(
|
||||
x is None or callable(x) for x in (holder, observe)):
|
||||
raise Refused('original-attempt holder and readback must be injected together')
|
||||
|
|
@ -152,6 +171,21 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
|||
if source_digest(check_config_digest) != reviewed_source:
|
||||
raise Refused('owner source changed')
|
||||
|
||||
def restart_issuer(inv):
|
||||
stamp = 'case-' + digest(inv.attempt_id.encode()).split(':', 1)[1][:32]
|
||||
deployment = restart['read']()
|
||||
revision = deployment.get('metadata', {}).get('resourceVersion')
|
||||
planned = restart_readiness.plan_restart(deployment, stamp, revision)
|
||||
written = restart['write'](planned, revision)
|
||||
if not isinstance(written, str) or not 0 < len(written) <= 192:
|
||||
raise Refused('restart write ambiguous; owner reconciliation required')
|
||||
receipt = restart_readiness.wait_ready(restart['observe'], restart['sleep'], stamp=stamp,
|
||||
expected_issuer=restart['issuer'], attempts=RESTART_ATTEMPTS, interval=RESTART_INTERVAL)
|
||||
if receipt['verdict'] != 'ready':
|
||||
raise Refused(f"issuer restart {receipt['verdict']}; owner reconciliation required")
|
||||
return {'stamp': stamp, 'before': revision, 'after': written,
|
||||
'verdict': receipt['verdict'], 'attempts': receipt['attempts']}
|
||||
|
||||
def execute(inv, operation, created=()):
|
||||
nonlocal creation
|
||||
try:
|
||||
|
|
@ -192,6 +226,7 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
|||
outcome = (Outcome.ROLLED_BACK if operation == 'remove' else
|
||||
Outcome.SUCCESS if changed else Outcome.NO_CHANGE)
|
||||
objects = ('keycape-client',) if operation == 'add' and changed else ()
|
||||
restarted = restart_issuer(inv) if changed else None
|
||||
finally:
|
||||
# Even helper failure must end the parent session. Failures propagate
|
||||
# contained to the kernel and become uncertain, never safe to replay.
|
||||
|
|
@ -202,7 +237,7 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
|
|||
payload = json.dumps({'schema': 'netkingdom.keycape-registration.attempt.v1',
|
||||
'invocation': asdict(inv), 'source': reviewed_source,
|
||||
'outcome': outcome.value, 'created': objects, 'before': receipt['before'],
|
||||
'after': after, 'session_revocation': revocation},
|
||||
'after': after, 'issuer_restart': restarted, 'session_revocation': revocation},
|
||||
sort_keys=True, separators=(',', ':')).encode()
|
||||
with contained_output():
|
||||
evidence = publish(payload)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,49 @@ f = importlib.util.module_from_spec(spec)
|
|||
spec.loader.exec_module(f)
|
||||
|
||||
|
||||
ISSUER = 'https://kc.coulomb.social'
|
||||
|
||||
|
||||
class FakeIssuer:
|
||||
"""Single-replica Recreate issuer; becomes Ready once a restart stamp is written."""
|
||||
|
||||
def __init__(self, outcome='ready'):
|
||||
self.outcome, self.writes, self.sleeps = outcome, [], []
|
||||
self.deployment = {'kind': 'Deployment',
|
||||
'metadata': {'name': 'keycape', 'namespace': 'sso', 'resourceVersion': '40', 'generation': 3},
|
||||
'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'},
|
||||
'template': {'spec': {'containers': [{'name': 'keycape'}]}}},
|
||||
'status': {'observedGeneration': 3, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1}}
|
||||
|
||||
def read(self):
|
||||
return copy.deepcopy(self.deployment)
|
||||
|
||||
def write(self, deployment, expected):
|
||||
assert expected == self.deployment['metadata']['resourceVersion']
|
||||
self.writes.append(copy.deepcopy(deployment))
|
||||
self.deployment = copy.deepcopy(deployment)
|
||||
self.deployment['metadata']['resourceVersion'] = str(int(expected) + 1)
|
||||
self.deployment['metadata']['generation'] += 1
|
||||
self.deployment['status']['observedGeneration'] += 1
|
||||
return self.deployment['metadata']['resourceVersion']
|
||||
|
||||
def observe(self):
|
||||
stamp = self.deployment['spec']['template'].get('metadata', {}).get('annotations', {}).get(
|
||||
a.restart_readiness.ANNOTATION)
|
||||
state = {'waiting': {'reason': 'CrashLoopBackOff'}} if self.outcome == 'crash' else {'running': {}}
|
||||
ready = 'False' if self.outcome in ('crash', 'stuck') else 'True'
|
||||
pod = {'metadata': {'annotations': {a.restart_readiness.ANNOTATION: stamp}},
|
||||
'status': {'phase': 'Running', 'conditions': [{'type': 'Ready', 'status': ready}],
|
||||
'containerStatuses': [{'name': 'keycape', 'state': state}]}}
|
||||
discovery = {'issuer': ISSUER, 'authorization_endpoint': ISSUER + '/authorize',
|
||||
'token_endpoint': ISSUER + '/token', 'jwks_uri': ISSUER + '/jwks'}
|
||||
return copy.deepcopy(self.deployment), [pod], discovery if ready == 'True' else None
|
||||
|
||||
def capabilities(self):
|
||||
return {'read': self.read, 'write': self.write, 'observe': self.observe,
|
||||
'sleep': self.sleeps.append, 'issuer': ISSUER}
|
||||
|
||||
|
||||
class RegistrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
|
|
@ -44,7 +87,9 @@ class RegistrationTests(unittest.TestCase):
|
|||
expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test',
|
||||
check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()),
|
||||
publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
|
||||
revoke_session=self.revoke)
|
||||
revoke_session=self.revoke, restart=None)
|
||||
self.issuer = FakeIssuer()
|
||||
self.kw['restart'] = self.issuer.capabilities()
|
||||
self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True})
|
||||
self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube)
|
||||
self.check_patch.start()
|
||||
|
|
@ -106,6 +151,47 @@ class RegistrationTests(unittest.TestCase):
|
|||
self.assertNotIn(b'config.yaml', payload)
|
||||
self.assertNotIn(b'signing-key', payload)
|
||||
|
||||
def test_changed_registry_restarts_issuer_and_records_ready_verdict(self):
|
||||
adapter = a.build(**self.kw)
|
||||
result = adapter.invoke(self.inv)
|
||||
self.assertEqual(result.outcome, Outcome.SUCCESS)
|
||||
self.assertEqual(len(self.issuer.writes), 1)
|
||||
payload = json.loads(self.evidence[result.evidence])
|
||||
self.assertEqual(payload['issuer_restart']['verdict'], 'ready')
|
||||
self.assertEqual(payload['issuer_restart']['before'], '40')
|
||||
undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
|
||||
self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
|
||||
self.assertEqual(len(self.issuer.writes), 2)
|
||||
|
||||
def test_no_change_does_not_restart_issuer(self):
|
||||
self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
|
||||
result = a.build(**self.kw).invoke(self.inv)
|
||||
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
|
||||
self.assertFalse(self.issuer.writes)
|
||||
self.assertIsNone(json.loads(self.evidence[result.evidence])['issuer_restart'])
|
||||
|
||||
def test_failed_or_stuck_restart_is_uncertain_and_revokes_session(self):
|
||||
for outcome in ('crash', 'stuck'):
|
||||
with self.subTest(outcome=outcome):
|
||||
self.setUp()
|
||||
self.issuer = FakeIssuer(outcome)
|
||||
adapter = a.build(**dict(self.kw, restart=self.issuer.capabilities()))
|
||||
with self.assertRaises(Refused):
|
||||
adapter.invoke(self.inv)
|
||||
self.assertEqual(len(self.issuer.writes), 1)
|
||||
self.assertFalse(self.evidence)
|
||||
self.assertEqual(self.revocations, [self.inv])
|
||||
if outcome == 'stuck':
|
||||
self.assertEqual(len(self.issuer.sleeps), a.RESTART_ATTEMPTS - 1)
|
||||
|
||||
def test_restart_capabilities_are_required_and_bounded(self):
|
||||
caps = self.issuer.capabilities()
|
||||
for bad in (None, {}, dict(caps, issuer='http://kc.coulomb.social'),
|
||||
dict(caps, write='not-callable'), dict(caps, extra=print)):
|
||||
with self.subTest(bad=bad if not isinstance(bad, dict) else sorted(bad)):
|
||||
with self.assertRaisesRegex(Refused, 'restart'):
|
||||
a.build(**dict(self.kw, restart=bad))
|
||||
|
||||
def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self):
|
||||
symlink = self.binary.with_name('symlink')
|
||||
symlink.symlink_to(self.binary)
|
||||
|
|
|
|||
|
|
@ -630,7 +630,7 @@ publication receipt: Custodian
|
|||
id: NK-WP-0048-T12
|
||||
status: wait
|
||||
priority: high
|
||||
blocking_reason: "Restart/readiness source candidate delivered 2026-10-23 (sso-mfa/k8s/keycape/restart-readiness.py, 11 synthetic tests). Awaits the coordinator's 1.3 placement decision (ready_check vs NetKingdom step vs carried limit) at the T16 re-freeze, then owner review and descriptor wiring."
|
||||
blocking_reason: "Wired into register-client 2026-10-23 per coordinator r6 recommendation (descriptor sha256:434c53e8..43cb; 1.3 map + ready_check updated). Awaits coordinator r7 together with the F3 fix; key-cape to confirm readiness criteria."
|
||||
state_hub_task_id: "7ce4eee3-cd47-55a9-bfa5-4f1835d4d6fd"
|
||||
```
|
||||
|
||||
|
|
@ -682,6 +682,28 @@ Separately: `test_adapter_source_closure.py` already fails 7 probes in this
|
|||
workstation environment, with or without this change, because the probe
|
||||
subprocess cannot import `warden`.
|
||||
|
||||
2026-10-23 wiring. Coordinator r6 (Custodian `README-r6.md`, a870092)
|
||||
recommended putting the restart inside register-client rather than as a bare
|
||||
ready_check or an eighth step. Done:
|
||||
- `keycape-registration-adapter.py` now requires a `restart` capability bundle
|
||||
(read, CAS write, observe, sleep, https issuer). After any apply that changed
|
||||
the registry, in either direction, it stamps one restart and waits for
|
||||
readiness (at most 60 x 5 s). `failed` or `uncertain` raises, so the kernel
|
||||
keeps the step uncertain; the session is still revoked.
|
||||
- The attempt payload records `issuer_restart`, and `restart-readiness.py` joins
|
||||
the source closure.
|
||||
- 4 new tests: ready, no-change without restart, crash/stuck uncertain, and
|
||||
bounded capabilities.
|
||||
- The register-client descriptor is now `sha256:434c53e8…43cb` (was
|
||||
`4ecc769f…26e7`). The 1.2/1.3 compositions are regenerated, and the 1.3 map
|
||||
plus a status-only `ready_check` on register-client are updated.
|
||||
- The keycape and type suites pass: 363 passed, 5 skipped.
|
||||
|
||||
The source-closure failure noted above was a relative-PYTHONPATH artifact; fixed
|
||||
in 3a68d05. Open, from r6: readiness does not prove the new client was loaded
|
||||
(key-cape to confirm the criteria), and bind-env's own restart has no readiness
|
||||
check. Coordinator r7 reviews this together with the F3 fix.
|
||||
|
||||
## T13 — Prepare the fixed registration adapter source candidate
|
||||
|
||||
```task
|
||||
|
|
@ -870,7 +892,7 @@ id: NK-WP-0048-T16
|
|||
status: wait
|
||||
depends_on: [NK-WP-0048-T24, NK-WP-0048-T25, NK-WP-0048-T26, NK-WP-0048-T27, NK-WP-0048-T28, NK-WP-0048-T29, NK-WP-0048-T30]
|
||||
priority: high
|
||||
blocking_reason: "Owner re-check of the 1.3 candidate cannot start: coordinator r5 (001a38c) left NK48-R5-F1..F3 open and their fixes changed 1.3 type.yaml (now ef02dd.., re-check froze e75fe9..). Needs re-freeze + coordinator r6 with no open findings; kernel review r2 is clean."
|
||||
blocking_reason: "Coordinator r6 (the-custodian a870092) rejected: NK48-R5-F1/F2 resolved, F3 (consumer config inverse) open via T34 / RAPPS-WP-0016-T05. After F3 + T12 integration: re-freeze, coordinator r7, then owner re-check."
|
||||
state_hub_task_id: "200bf500-5084-5ae7-be26-3033fe1f296e"
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue