99 lines
4.3 KiB
Python
99 lines
4.3 KiB
Python
|
|
#!/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())
|