tools/conventions/lint.py (make conventions-lint, contract suite): server-side-apply, one-wait-condition, one-cluster-route, tool-numbered-receipts, managing-controller (Argo-owned paths from railiance-platform), with reasoned exceptions. 21 runbook applies made server-side. portal-client-rollout.py (and so keycape-client.py and the rollout tools) and identity-provisioner-reconcile.py use NK_KUBECONFIG, default ~/.kube/config-railiance01, without a second kubeconfig or --server override. 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
116 lines
6.4 KiB
Python
116 lines
6.4 KiB
Python
#!/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())
|