From cd265b84a48fa641bf3f2cb79b2a02ebee9b40b9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 6 Nov 2026 00:34:00 +0100 Subject: [PATCH] NK-WP-0058-T07: conventions lint, server-side applies, one cluster route 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 943911@bnt-lap001 Assistant-Session: fc322940-1c8e-4bed-ab39-86cec99f23a0 --- Makefile | 3 + docs/keycape-approval-clients-rollout.md | 2 +- sso-mfa/k8s/authelia/README.md | 8 +- .../k8s/bounded-executor/T06-PILOT-RUNBOOK.md | 4 +- sso-mfa/k8s/keycape/portal-client-rollout.py | 9 +- sso-mfa/k8s/lldap/BIND-SPLIT-RUNBOOK.md | 10 +- sso-mfa/k8s/lldap/README.md | 8 +- .../lldap/identity-provisioner-reconcile.py | 6 +- sso-mfa/k8s/postgresql/README.md | 4 +- sso-mfa/k8s/privacyidea/README.md | 12 +- tools/contract-tests/run.sh | 1 + tools/conventions/lint.py | 116 ++++++++++++++++++ tools/conventions/tests/test_lint.py | 71 +++++++++++ 13 files changed, 226 insertions(+), 28 deletions(-) create mode 100644 tools/conventions/lint.py create mode 100644 tools/conventions/tests/test_lint.py diff --git a/Makefile b/Makefile index 580f2fa..c5adf31 100644 --- a/Makefile +++ b/Makefile @@ -212,6 +212,9 @@ bounded-execution-admission-check: ## Validate the example admissions (read-only execution-attribution-receipt-test: ## Receipt schema v0.1 validator; fixtures match the executor (NK-WP-0056-T15) python3 -m pytest tools/execution-attribution-receipt/tests +conventions-lint: ## Runbook and tooling conventions from the 2026-11-04/05 pilot (NK-WP-0058-T07), read-only + python3 tools/conventions/lint.py + contract-test: ## Bounded-execution contract suite against the owner checkouts next to this repo; a skip fails (NK-WP-0058-T02) bash tools/contract-tests/run.sh diff --git a/docs/keycape-approval-clients-rollout.md b/docs/keycape-approval-clients-rollout.md index 10fe9f4..d91b403 100644 --- a/docs/keycape-approval-clients-rollout.md +++ b/docs/keycape-approval-clients-rollout.md @@ -22,7 +22,7 @@ native scope/auth/namespace denials and reader revocation. It invokes this repository's `sso-mfa/k8s/keycape/approval-clients-rollout.py` for image/config replacement and service acceptance. Do not run the platform child directly. On this workstation the verified Railiance kubeconfig is -`/home/worsch/.kube/config-railiance01`; the default config targets another endpoint. +`~/.kube/config-railiance01` (`NK_KUBECONFIG` overrides it); the default config targets another endpoint. Use `activate` only for a first provision with absent custody and delivery. `resume-activate --prior-receipt ` requires a completed compatible diff --git a/sso-mfa/k8s/authelia/README.md b/sso-mfa/k8s/authelia/README.md index fa7304b..bd327aa 100644 --- a/sso-mfa/k8s/authelia/README.md +++ b/sso-mfa/k8s/authelia/README.md @@ -25,10 +25,10 @@ chmod +x create-secrets.sh ./create-secrets.sh # 2. Apply manifests (order matters) -kubectl apply -f pvc.yaml -kubectl apply -f configmap.yaml -kubectl apply -f deployment.yaml -kubectl apply -f ingress.yaml +kubectl apply --server-side -f pvc.yaml +kubectl apply --server-side -f configmap.yaml +kubectl apply --server-side -f deployment.yaml +kubectl apply --server-side -f ingress.yaml # 3. Wait for pod to be ready # The startup probe allows 90 s for the initial LLDAP connection. diff --git a/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md b/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md index 6fca5c4..0952325 100644 --- a/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md +++ b/sso-mfa/k8s/bounded-executor/T06-PILOT-RUNBOOK.md @@ -30,7 +30,7 @@ is not in its file. Applying the whole file would therefore be a broader change K="kubectl --kubeconfig $HOME/.kube/config-railiance01" cd ~/approval-engine && git pull --ff-only python3 -c "import yaml,sys; yaml.safe_dump([d for d in yaml.safe_load_all(open('deploy/networkpolicies.yaml')) if d and d['metadata']['name']=='approval-engine-bounded-executor'][0], sys.stdout)" > /tmp/ae-bx.yaml -$K apply --dry-run=server -f /tmp/ae-bx.yaml && $K apply -f /tmp/ae-bx.yaml +$K apply --server-side --dry-run=server -f /tmp/ae-bx.yaml && $K apply --server-side -f /tmp/ae-bx.yaml ``` audit-core: run steps 1 and 2 of audit-core `docs/bounded-executor-receipts.md`, "Founder runbook — @@ -86,7 +86,7 @@ C=$(mktemp); python3 sso-mfa/k8s/keycape/keycape-client.py client-from-case secu B=$HOME/.local/share/key-cape/verified-bin/e2b5fa1 # check-config binary from the deployed image (T09 runbook) test -x "$B/keycape" || { mkdir -p "$B"; cid=$(docker create forgejo.coulomb.social/coulomb/key-cape@sha256:9e082dca78f65a4af3e92d3de3705b99e49ca9b298d9562ad0d37511c4306f8d); docker cp "$cid:/keycape" "$B/keycape"; docker rm "$cid"; } -# keycape-client.py uses its built-in kubeconfig through 127.0.0.1:16444, exactly as in the T09 runbook; +# keycape-client.py uses the same route as $K (NK_KUBECONFIG, default ~/.kube/config-railiance01; NK-WP-0058-T07); # it pins the cluster identity and refuses another cluster. $K -n sso rollout pause deployment/keycape $K -n sso patch deployment keycape --type=json -p '[ diff --git a/sso-mfa/k8s/keycape/portal-client-rollout.py b/sso-mfa/k8s/keycape/portal-client-rollout.py index da4ee2d..83c2380 100644 --- a/sso-mfa/k8s/keycape/portal-client-rollout.py +++ b/sso-mfa/k8s/keycape/portal-client-rollout.py @@ -8,6 +8,7 @@ import copy import importlib.util import json from pathlib import Path +import os import subprocess import yaml @@ -91,9 +92,13 @@ def replacement(secret): return base64.b64encode(updated.encode()).decode(), True +# The runbooks' cluster route (NK-WP-0058-T07): NK_KUBECONFIG, else the admin kubeconfig they name. No second file and +# no --server override: until 2026-11-06 this used a second kubeconfig file with a server override to the same API. +KUBECONFIG = os.environ.get('NK_KUBECONFIG', os.path.expanduser('~/.kube/config-railiance01')) + + def kubectl(arguments, payload=None): - args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', - '--server', 'https://127.0.0.1:16444', '--request-timeout=20s', *arguments] + args = ['kubectl', '--kubeconfig', KUBECONFIG, '--request-timeout=20s', *arguments] result = subprocess.run(args, input=None if payload is None else json.dumps(payload), text=True, capture_output=True, timeout=30) require(result.returncode == 0, 'kubernetes_operation_failed') diff --git a/sso-mfa/k8s/lldap/BIND-SPLIT-RUNBOOK.md b/sso-mfa/k8s/lldap/BIND-SPLIT-RUNBOOK.md index a5689a3..0fd2f8e 100644 --- a/sso-mfa/k8s/lldap/BIND-SPLIT-RUNBOOK.md +++ b/sso-mfa/k8s/lldap/BIND-SPLIT-RUNBOOK.md @@ -23,7 +23,7 @@ receipts under `~/nk54/`; they are value-free and go to `docs/evidence/` afterwa ```bash cd ~/net-kingdom && git pull --ff-only bridge up k3s-api-railiance01 -K="kubectl --kubeconfig $HOME/.kube/config-hosteurope --server https://127.0.0.1:16444" +K="kubectl --kubeconfig $HOME/.kube/config-railiance01" # one route for runbooks and tools (NK-WP-0058-T07) # The Secret scripts call plain kubectl; the default config is the limited ops-mason-builder. export KUBECONFIG=$HOME/.kube/config-railiance01 kubectl auth can-i create secrets -n sso # must print yes @@ -94,7 +94,7 @@ python3 sso-mfa/k8s/lldap/bind-split.py verify --endpoint https://lldap.coulomb. ```bash bash sso-mfa/k8s/identity-provisioner/create-secrets.sh "$CUSTODY" -$K apply -f sso-mfa/k8s/identity-provisioner/deployment.yaml +$K apply --server-side -f sso-mfa/k8s/identity-provisioner/deployment.yaml $K -n sso rollout status deploy/identity-provisioner --timeout=180s ``` Its readiness includes a directory login, so `rollout status` succeeding means `svc-provisioner` @@ -104,13 +104,13 @@ works. Rollback: `$K -n sso rollout undo deploy/identity-provisioner`. ```bash bash sso-mfa/k8s/authelia/create-secrets.sh "$CUSTODY" --rotate-key LLDAP_SVC_AUTHELIA_PASS -$K apply -f sso-mfa/k8s/authelia/configmap.yaml +$K apply --server-side -f sso-mfa/k8s/authelia/configmap.yaml $K -n sso rollout restart deploy/authelia && $K -n sso rollout status deploy/authelia --timeout=180s ``` Check: sign in at `https://auth.coulomb.social` as yourself (password and OTP). Rollback (the `admin` password still works until step 10): ```bash -git show 145ca8e~1:sso-mfa/k8s/authelia/configmap.yaml | $K apply -f - +git show 145ca8e~1:sso-mfa/k8s/authelia/configmap.yaml | $K apply --server-side -f - read -rsp 'admin password: ' P; echo printf '[{"op":"replace","path":"/data/ldap_password","value":"%s"}]' "$(printf %s "$P" | base64 -w0)" \ | $K -n sso patch secret authelia-secrets --type=json --patch-file=/dev/stdin; unset P @@ -148,7 +148,7 @@ automated rollback; the old bind is the `admin` account, which still works, so t ```bash bash sso-mfa/k8s/lldap/create-secrets.sh "$CUSTODY" --rotate-key LLDAP_LDAP_USER_PASS -$K apply -f sso-mfa/k8s/lldap/deployment.yaml +$K apply --server-side -f sso-mfa/k8s/lldap/deployment.yaml $K -n sso rollout status deploy/lldap --timeout=180s ``` The configured super-admin is now `ld-admin`. Check: sign in to the LLDAP UI as `ld-admin` once, then diff --git a/sso-mfa/k8s/lldap/README.md b/sso-mfa/k8s/lldap/README.md index 527ea79..24daef9 100644 --- a/sso-mfa/k8s/lldap/README.md +++ b/sso-mfa/k8s/lldap/README.md @@ -24,10 +24,10 @@ chmod +x create-secrets.sh ./create-secrets.sh # 3. Apply manifests (order matters) -kubectl apply -f pvc.yaml -kubectl apply -f middleware.yaml -kubectl apply -f deployment.yaml -kubectl apply -f ingress.yaml +kubectl apply --server-side -f pvc.yaml +kubectl apply --server-side -f middleware.yaml +kubectl apply --server-side -f deployment.yaml +kubectl apply --server-side -f ingress.yaml # 4. Wait for pod to be ready kubectl rollout status deployment/lldap -n sso --timeout=120s diff --git a/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py b/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py index f47897c..f24c254 100644 --- a/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py +++ b/sso-mfa/k8s/lldap/identity-provisioner-reconcile.py @@ -8,14 +8,16 @@ from __future__ import annotations import argparse import base64 import getpass +import os import json import re import subprocess import sys CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' -KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', - '--server', 'https://127.0.0.1:16444', '--request-timeout=20s'] +# The runbooks' cluster route (NK-WP-0058-T07): NK_KUBECONFIG, else the admin kubeconfig they name; no override. +KUBE = ['kubectl', '--kubeconfig', os.environ.get('NK_KUBECONFIG', os.path.expanduser('~/.kube/config-railiance01')), + '--request-timeout=20s'] NAMESPACE = 'sso' # The provisioner's own directory account (NK-WP-0054-T05); needs the image with LLDAP_ADMIN_USERNAME. SECRET = 'identity-provisioner-ldap' diff --git a/sso-mfa/k8s/postgresql/README.md b/sso-mfa/k8s/postgresql/README.md index 98d60e1..b53df34 100644 --- a/sso-mfa/k8s/postgresql/README.md +++ b/sso-mfa/k8s/postgresql/README.md @@ -54,7 +54,7 @@ kubectl create secret generic net-kingdom-pg-privacyidea-app \ ### 3. Deploy the cluster ```bash -kubectl apply -f cluster.yaml +kubectl apply --server-side -f cluster.yaml ``` Wait for cluster to become ready (this provisions PVCs and runs initdb — allow 2–3 minutes): @@ -100,7 +100,7 @@ kubectl create secret generic net-kingdom-pg-backup-s3 \ Apply the updated cluster.yaml, then: ```bash -kubectl apply -f scheduled-backup.yaml +kubectl apply --server-side -f scheduled-backup.yaml ``` ### 6. Run the restore drill diff --git a/sso-mfa/k8s/privacyidea/README.md b/sso-mfa/k8s/privacyidea/README.md index cb40011..3446b34 100644 --- a/sso-mfa/k8s/privacyidea/README.md +++ b/sso-mfa/k8s/privacyidea/README.md @@ -34,11 +34,11 @@ PI_SQLALCHEMY_DATABASE_URI). ```bash # From sso-mfa/k8s/privacyidea/ -kubectl apply -f pvc.yaml -kubectl apply -f configmap.yaml -kubectl apply -f middleware.yaml -kubectl apply -f deployment.yaml -kubectl apply -f ingress.yaml +kubectl apply --server-side -f pvc.yaml +kubectl apply --server-side -f configmap.yaml +kubectl apply --server-side -f middleware.yaml +kubectl apply --server-side -f deployment.yaml +kubectl apply --server-side -f ingress.yaml ``` **Wait for the pod to reach Running state** (DB migrations run on first start — @@ -185,7 +185,7 @@ Update `middleware.yaml` `privacyidea-admin-allowlist.spec.ipWhiteList.sourceRan to your actual VPN/office CIDRs and reapply: ```bash -kubectl apply -f middleware.yaml +kubectl apply --server-side -f middleware.yaml ``` ### Self-service portal diff --git a/tools/contract-tests/run.sh b/tools/contract-tests/run.sh index b6061d0..fd3db81 100755 --- a/tools/contract-tests/run.sh +++ b/tools/contract-tests/run.sh @@ -15,6 +15,7 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -rs -p no:cacheprovider \ tools/execution-attribution-receipt/tests \ sso-mfa/k8s/bounded-executor \ tools/contract-tests/tests \ + tools/conventions/tests \ "$@" | tee "$out" if grep -q '^SKIPPED' "$out"; then echo "contract-test: skipped tests above; every contract must run (are the owner checkouts present?)" >&2 diff --git a/tools/conventions/lint.py b/tools/conventions/lint.py new file mode 100644 index 0000000..c5d2575 --- /dev/null +++ b/tools/conventions/lint.py @@ -0,0 +1,116 @@ +#!/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()) diff --git a/tools/conventions/tests/test_lint.py b/tools/conventions/tests/test_lint.py new file mode 100644 index 0000000..0100f39 --- /dev/null +++ b/tools/conventions/tests/test_lint.py @@ -0,0 +1,71 @@ +"""tools/conventions/lint.py (NK-WP-0058-T07): each rule on synthetic lines, and this repository is clean.""" +import importlib.util +from pathlib import Path + +import pytest +import yaml + +REPO = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location('conventions_lint', REPO / 'tools/conventions/lint.py') +lint = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lint) + + +def rules(rel, line, owned=frozenset()): + return [rule for _, _, rule in lint.check_file(rel, line + '\n', owned)] + + +@pytest.mark.parametrize('line, found', [ + ('kubectl apply -f deployment.yaml', ['server-side-apply']), + ('$K apply --dry-run=server -f x.yaml && $K apply -f x.yaml', ['server-side-apply']), + ('$(KUBECTL) apply -f manifests/rbac.yaml', ['server-side-apply']), + ('kubectl apply --server-side -f deployment.yaml', []), + ('kubectl get pods # apply nothing', []), +]) +def test_server_side_apply(line, found): + assert rules('runbooks/x.md', line) == found + + +def test_one_wait_condition(): + assert rules('x.sh', 'kubectl wait --for=condition=complete --for=condition=failed job/x') == [ + 'one-wait-condition'] + assert rules('x.sh', 'kubectl wait --for=condition=available deploy/x') == [] + + +@pytest.mark.parametrize('line, found', [ + ("args = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope']", True), + ("args = ['kubectl', '--server', 'https://127.0.0.1:16444']", True), + ("KUBE = '/etc/rancher/k3s/k3s.yaml'", True), + ("KUBECONFIG = os.environ.get('NK_KUBECONFIG', os.path.expanduser('~/.kube/config-railiance01'))", False), +]) +def test_one_cluster_route(line, found): + assert ('one-cluster-route' in rules('tools/x.py', line)) is found + assert rules('tools/tests/test_x.py', line) == [] # tests may build synthetic routes + + +def test_tool_numbered_receipts(): + assert rules('runbooks/x.md', 'python3 lane.py setup --receipt "$E/01-setup.json"') == ['tool-numbered-receipts'] + assert rules('runbooks/x.md', 'python3 bx.py run') == [] + + +def test_managing_controller(tmp_path): + app = tmp_path / 'railiance-platform/argocd/apps/x.yaml' + app.parent.mkdir(parents=True) + app.write_text(yaml.safe_dump({'kind': 'Application', 'spec': {'source': { + 'repoURL': 'https://forgejo.coulomb.social/coulomb/informed-decision.git', 'path': 'deploy/lifecycle'}}})) + owned = lint.argo_paths(tmp_path / 'railiance-platform') + assert owned == {('informed-decision', 'deploy/lifecycle')} + line = 'kubectl apply --server-side -f ~/informed-decision/deploy/lifecycle/review-groups.yaml' + assert rules('runbooks/x.md', line, owned) == ['managing-controller'] + assert rules('runbooks/x.md', 'kubectl apply --server-side -f ~/approval-engine/deploy/np.yaml', owned) == [] + + +def test_records_and_exceptions_are_not_findings(): + assert lint.RECORDS.search('docs/evidence/2026-11-05-x/README.md') + assert rules('sso-mfa/WORKPLAN.md', 'kubectl apply -f a.yaml') == [] # a listed exception with its reason + assert all(reason for reason in lint.EXCEPTIONS.values()) + + +def test_this_repository_is_clean(): + findings = lint.lint(REPO, lint.argo_paths(REPO.parent / 'railiance-platform')) + assert findings == [], findings