#!/usr/bin/env python3 """Runbook and tooling conventions from the 2026-11-04/05 pilot, as checks (NK-WP-0058-T07). Read-only. python3 tools/conventions/lint.py [ROOT ...] # default: this repository; the rapp CI passes its own root server-side-apply every `kubectl apply` in a runbook or script is server-side. A client-side apply leaves a last-applied annotation; ESO copied it and the Secret admission guard refused (2026-11-04). one-wait-condition no `kubectl wait` with more than one --for: they are ANDed, and the completed run hung it (2026-11-05). Poll the status instead. one-cluster-route tools and runbooks reach the cluster through one route: NK_KUBECONFIG, default ~/.kube/config-railiance01. No other kubeconfig file, absolute home path or --server override. keycape-client.py used a second, hidden route (2026-11-04). tool-numbered-receipts runbooks never number receipts by hand (`--receipt .../NN-...`); the tool numbers them. managing-controller no runbook applies a path that an Argo CD Application owns (railiance-platform argocd/): a manual write there is reverted or drifts (informed-decision, 2026-11-05). Records (evidence, history, proposals, workplans, wiki) are not instructions and are not checked. A known exception is listed in EXCEPTIONS with its reason. Exit 0 when clean; otherwise one line per finding: file:line rule. """ import re import sys from pathlib import Path import yaml HERE = Path(__file__).resolve() RECORDS = re.compile(r'(^|/)(docs/evidence|docs/proposals|history|workplans|wiki|\.git)/|WORK-RECORDS|HISTORICAL') RUNBOOK = {'.md', '.sh', '.mk'} TOOL = {'.py', '.sh'} KUBECTL = r'(kubectl|\$K\b|\$\{K\}|\$\(KUBECTL\)|\$KUBECTL|\$\{KUBECTL\})' APPLY = re.compile(KUBECTL + r'[^|;&\n]*\sapply(\s|$)') WAIT = re.compile(KUBECTL + r'[^|;&\n]*\swait\s') ROUTE = re.compile(r'config-hosteurope|/etc/rancher/k3s/k3s\.yaml|/home/[^/\s\'"]+/\.kube/|' r'--server[ =,\'"]+[\'"]?https?://127\.0\.0\.1:(16443|16444|6443)') RECEIPT = re.compile(r'--receipt[ =]+["\']?[^ "\']*/\d{2}-') SOURCE = re.compile(r'(?:~|\$HOME|/home/[^/\s]+)/([a-z0-9-]+)/([^\s"\']+)') # (path relative to the root, rule): reason. Keep this short; each entry is a decision. EXCEPTIONS = { ('sso-mfa/k8s/tenant-engine/README.md', 'server-side-apply'): "quotes tenant-engine's own deploy command; the owner's runbook, not ours", ('sso-mfa/WORKPLAN.md', 'server-side-apply'): 'legacy checklist from the first SSO setup, not a runbook', ('sso-mfa/k8s/keycape/openbao-negative-session.py', 'one-cluster-route'): "runs on railiance01 itself: /etc/rancher/k3s/k3s.yaml is the node's own cluster config", ('tools/bootstrap-recovery/install-identity-backup.py', 'one-cluster-route'): "installs the node's backup job on railiance01: the node's own cluster config", ('sso-mfa/k8s/keycape/openbao-negative-node-route.md', 'one-cluster-route'): 'the command runs on railiance01 itself (the node route of openbao-negative-session.py)', ('docs/bounded-execution-contract-table.md', 'one-cluster-route'): 'the interface inventory describes the old routes on purpose', ('sso-mfa/k8s/bounded-executor/T10-LANES-RUNBOOK.md', 'tool-numbered-receipts'): 'one-time lane setup, completed 2026-10-31 (NK-WP-0056-T10)', ('sso-mfa/k8s/lldap/BIND-SPLIT-RUNBOOK.md', 'tool-numbered-receipts'): 'one-time bind split, completed (NK-WP-0054)', ('sso-mfa/k8s/synthetic-journey/T09-RUNBOOK.md', 'tool-numbered-receipts'): 'one-time journey lane setup, completed (NK-WP-0056-T09)', } def argo_paths(platform): """(repository, path) pairs that an Argo CD Application in railiance-platform deploys.""" owned = set() for app in sorted((platform / 'argocd').rglob('*.yaml')) if platform.is_dir() else []: for doc in yaml.safe_load_all(app.read_text()): if not isinstance(doc, dict) or doc.get('kind') != 'Application': continue for source in [doc['spec'].get('source')] + list(doc['spec'].get('sources') or []): if source and source.get('path'): repo = source['repoURL'].rstrip('/').rsplit('/', 1)[-1].removesuffix('.git') owned.add((repo, source['path'].strip('/'))) return owned def check_file(rel, text, owned=frozenset()): findings, suffix = [], Path(rel).suffix or Path(rel).name runbook = suffix in RUNBOOK or Path(rel).name == 'Makefile' tool = suffix in TOOL and '/tests/' not in rel and not Path(rel).name.startswith('test_') for n, full in enumerate(text.splitlines(), 1): line = full.split(' #')[0] # a trailing shell comment is prose, not part of the command if runbook and APPLY.search(line) and '--server-side' not in line: findings.append((rel, n, 'server-side-apply')) if WAIT.search(line) and line.count('--for') > 1: findings.append((rel, n, 'one-wait-condition')) if (tool or runbook) and ROUTE.search(line): findings.append((rel, n, 'one-cluster-route')) if suffix == '.md' and RECEIPT.search(line): findings.append((rel, n, 'tool-numbered-receipts')) if runbook and APPLY.search(line): for repo, path in SOURCE.findall(line): if any(repo == r and (path == p or path.startswith(p + '/')) for r, p in owned): findings.append((rel, n, 'managing-controller')) return [f for f in findings if (f[0], f[2]) not in EXCEPTIONS] def lint(root, owned): findings = [] for path in sorted(root.rglob('*')): rel = path.relative_to(root).as_posix() if not path.is_file() or RECORDS.search(rel) or path == HERE or HERE.parent / 'tests' in path.parents: continue if path.suffix in RUNBOOK | TOOL or path.name == 'Makefile': findings += check_file(rel, path.read_text(errors='replace'), owned) return findings def main(argv=None): roots = [Path(a).resolve() for a in (argv if argv is not None else sys.argv[1:])] or [HERE.parents[2]] owned = argo_paths(roots[0].parent / 'railiance-platform') findings = [(root.name, *f) for root in roots for f in lint(root, owned)] for repo, rel, n, rule in findings: print(f'{repo}/{rel}:{n} {rule}') return 1 if findings else 0 if __name__ == '__main__': sys.exit(main())