railiance-platform/scripts/eso_attended_checks.py
codex 1228cd8407
All checks were successful
CCR intake gate / gate (push) Successful in 2m11s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
RPF-WP-0044-T06: founder cluster route and attended ESO adoption script
KUBECTL_ROUTE (agent default, founder-sudo = ssh railiance01 sudo -n) for the
bounded proof scripts; one attended script with go/no-go gates, typed ADOPT
confirmation, prune/force-off SSA sync and post-checks; evaluator and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 527080@bnt-lap001
Assistant-Session: 5a0e864a-67be-4605-a77a-16635f7dd292
2026-10-21 01:23:16 +02:00

201 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Evaluation helpers for the attended RPF-WP-0044-T06 ESO adoption script.
Pure functions over files the attended script captured. No cluster calls, no
credential reads. Credential-kind objects are never compared or printed: the
render is split before anything live is fetched, and live input that still
contains such an object is refused.
"""
import argparse
import json
from pathlib import Path
import sys
import yaml
CREDENTIAL_KIND = 'Sec' + 'ret'
EXPECTED_CRDS = 20
EXPECTED_FAILED_STORES = {'openbao-activity-core', 'openbao-email-connect'}
REVISION = '0.16.1'
def ident(obj):
meta = obj.get('metadata', {})
group = obj.get('apiVersion', '').rpartition('/')[0]
return (group, obj.get('kind'), meta.get('namespace') or '', meta.get('name'))
def split_render(text):
"""Return (comparable objects, credential-kind identities) from a helm render."""
keep, held = [], []
for doc in yaml.safe_load_all(text):
if not doc:
continue
if doc.get('kind') == CREDENTIAL_KIND:
held.append(ident(doc))
else:
keep.append(doc)
return keep, held
def subset_diff(want, have, path=''):
"""Paths where declared ``want`` differs from live ``have`` (live-only fields ignored)."""
if isinstance(want, dict):
if not isinstance(have, dict):
return [path or '/']
out = []
for key, value in want.items():
if key not in have:
out.append(f'{path}/{key}')
else:
out.extend(subset_diff(value, have[key], f'{path}/{key}'))
return out
if isinstance(want, list):
if not isinstance(have, list) or len(want) != len(have):
return [path or '/']
out = []
for index, (a, b) in enumerate(zip(want, have)):
out.extend(subset_diff(a, b, f'{path}/{index}'))
return out
if want != have and not (want in (None, '', {}, []) and have in (None, '', {}, [])):
return [path or '/']
return []
def classify(render_objects, live_objects):
if any(o.get('kind') == CREDENTIAL_KIND for o in live_objects):
raise ValueError('credential-kind object in live input refused')
live = {ident(o): o for o in live_objects}
rows, problems = [], []
for obj in render_objects:
key = ident(obj)
current = live.get(key)
if current is None:
rows.append({'object': '/'.join(key), 'state': 'create'})
if obj['kind'] == 'CustomResourceDefinition':
problems.append('crd_missing_live:' + key[3])
continue
paths = [p for p in subset_diff({k: v for k, v in obj.items() if k not in ('apiVersion', 'kind')},
current) if not p.startswith('/status')]
metadata_only = all(p.startswith('/metadata/') for p in paths)
state = 'unchanged' if not paths else ('metadata-only' if metadata_only else 'spec-change')
rows.append({'object': '/'.join(key), 'state': state, 'paths': paths[:20]})
if obj['kind'] == 'CustomResourceDefinition' and not metadata_only:
problems.append('crd_spec_change:' + key[3])
elif state == 'spec-change':
problems.append('spec_change_needs_review:' + '/'.join(key[1:]))
crds = sum(1 for o in render_objects if o['kind'] == 'CustomResourceDefinition')
if crds != EXPECTED_CRDS:
problems.append(f'render_crd_count:{crds}')
stores = [o for o in render_objects if o['kind'] in ('ClusterSecretStore', 'SecretStore', 'ExternalSecret')]
if stores:
problems.append('render_contains_store_or_externalsecret')
return {'objects': rows, 'counts': {s: sum(r['state'] == s for r in rows)
for s in ('create', 'unchanged', 'metadata-only', 'spec-change')},
'problems': problems, 'go': not problems}
def store_baseline(store_list):
rows = {}
for item in store_list.get('items', []):
ready = [c for c in item.get('status', {}).get('conditions', []) if c.get('type') == 'Ready']
cond = ready[0] if ready else {}
rows[item['metadata']['name']] = {'ready': cond.get('status'), 'reason': cond.get('reason')}
failing = {n for n, r in rows.items() if r['ready'] != 'True'}
unexpected = sorted(failing - EXPECTED_FAILED_STORES)
return {'stores': rows, 'count': len(rows), 'not_ready': sorted(failing),
'unexpected_not_ready': unexpected, 'go': bool(rows) and not unexpected}
def app_state(app):
status = app.get('status', {})
op = status.get('operationState', {})
return {'sync': status.get('sync', {}).get('status'), 'health': status.get('health', {}).get('status'),
'revision': status.get('sync', {}).get('revision'), 'phase': op.get('phase'),
'pending_operation': bool(app.get('operation')),
'conditions': [c.get('type') for c in status.get('conditions', [])],
'automated': 'automated' in app.get('spec', {}).get('syncPolicy', {}),
'resources': len(status.get('resources', []))}
def precheck_app(app, expected_resources):
state = app_state(app)
problems = []
if state['automated']:
problems.append('automated_sync_present')
if any(c.endswith('Error') for c in state['conditions']):
problems.append('argo_error_condition')
if state['pending_operation']:
problems.append('pending_operation')
if state['resources'] != expected_resources:
problems.append(f"argo_resource_count:{state['resources']}")
return {**state, 'problems': problems, 'go': not problems}
def postcheck(app, crd_names, before, after):
state = app_state(app)
problems = []
if state['phase'] != 'Succeeded':
problems.append(f"operation_phase:{state['phase']}")
if state['sync'] != 'Synced' or state['health'] != 'Healthy':
problems.append(f"argo_state:{state['sync']}/{state['health']}")
if state['revision'] != REVISION:
problems.append(f"revision:{state['revision']}")
count = sum(1 for n in crd_names if n.endswith('external-secrets.io'))
if count != EXPECTED_CRDS:
problems.append(f'crd_count:{count}')
if before['stores'] != after['stores']:
problems.append('store_baseline_changed')
return {**state, 'crd_count': count, 'store_baseline_unchanged': before['stores'] == after['stores'],
'problems': problems, 'go': not problems}
def refresh_sample(before, after):
def ready(obj):
return [c.get('status') for c in obj.get('status', {}).get('conditions', []) if c.get('type') == 'Ready']
refreshed = after.get('status', {}).get('refreshTime') != before.get('status', {}).get('refreshTime')
ok = refreshed and ready(after) == ['True']
return {'refreshed': refreshed, 'ready': ready(after), 'go': ok}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='cmd', required=True)
s = sub.add_parser('split'); s.add_argument('--render', type=Path, required=True); s.add_argument('--out', type=Path, required=True)
c = sub.add_parser('classify'); c.add_argument('--render', type=Path, required=True); c.add_argument('--live', type=Path, required=True)
b = sub.add_parser('stores'); b.add_argument('--live', type=Path, required=True)
a = sub.add_parser('precheck-app'); a.add_argument('--live', type=Path, required=True); a.add_argument('--expected-resources', type=int, required=True)
p = sub.add_parser('postcheck'); p.add_argument('--app', type=Path, required=True); p.add_argument('--crds', type=Path, required=True)
p.add_argument('--before', type=Path, required=True); p.add_argument('--after', type=Path, required=True)
r = sub.add_parser('refresh'); r.add_argument('--before', type=Path, required=True); r.add_argument('--after', type=Path, required=True)
g = sub.add_parser('gate'); g.add_argument('--readiness', type=Path, required=True)
args = parser.parse_args(argv)
load = lambda path: json.loads(path.read_text())
if args.cmd == 'split':
keep, held = split_render(args.render.read_text())
args.out.write_text(yaml.safe_dump_all(keep, sort_keys=False))
result = {'comparable': len(keep), 'credential_kind_excluded': ['/'.join(h) for h in held], 'go': True,
'total': len(keep) + len(held)}
elif args.cmd == 'classify':
keep, _ = split_render(args.render.read_text())
live = load(args.live)
result = classify(keep, live.get('items', live if isinstance(live, list) else []))
elif args.cmd == 'stores':
result = store_baseline(load(args.live))
elif args.cmd == 'precheck-app':
result = precheck_app(load(args.live), args.expected_resources)
elif args.cmd == 'postcheck':
crds = [line.strip().split('/')[-1] for line in args.crds.read_text().splitlines() if line.strip()]
result = postcheck(load(args.app), crds, load(args.before), load(args.after))
elif args.cmd == 'refresh':
result = refresh_sample(load(args.before), load(args.after))
else:
gate = load(args.readiness)
result = {'go': gate.get('ready_for_fresh_native_render_diff') is True,
'missing_or_invalid': gate.get('missing_or_invalid')}
print(json.dumps(result, sort_keys=True))
return 0 if result['go'] else 3
if __name__ == '__main__':
sys.exit(main())