Publish tenancy posture draft-8 contract
This commit is contained in:
parent
3efd5866ff
commit
cced59d3aa
4 changed files with 630 additions and 112 deletions
73
tools/tenancy-posture/test_validate.py
Normal file
73
tools/tenancy-posture/test_validate.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
|
||||
MODULE_PATH = pathlib.Path(__file__).with_name("validate.py")
|
||||
SPEC = importlib.util.spec_from_file_location("tenancy_posture_validate", MODULE_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
VALIDATE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(VALIDATE)
|
||||
|
||||
|
||||
def declaration() -> dict:
|
||||
reasons = {axis: "floor explained" for axis in VALIDATE.AXES}
|
||||
return {
|
||||
"schema_version": "0.1",
|
||||
"framework": "netkingdom-tenancy-posture",
|
||||
"service": "example",
|
||||
"role": "test",
|
||||
"tenancy": {
|
||||
"current": {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0},
|
||||
"target": {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0},
|
||||
"reviewed": "2026-08-17",
|
||||
"review_due": "2027-02-17",
|
||||
"service_class": "interactive",
|
||||
"reason": reasons,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class SemanticValidationTests(unittest.TestCase):
|
||||
def validate(self, document: dict) -> list[str]:
|
||||
return VALIDATE.validate_semantics(document, pathlib.Path("tenancy.yaml"))
|
||||
|
||||
def test_floor_vector_with_reasons_is_valid(self) -> None:
|
||||
self.assertEqual([], self.validate(declaration()))
|
||||
|
||||
def test_level_above_floor_requires_exact_evidence_key(self) -> None:
|
||||
document = declaration()
|
||||
document["tenancy"]["current"]["A"] = 2
|
||||
self.assertIn("current A2 has no evidence entry", self.validate(document)[0])
|
||||
document["evidence"] = {"A2": "docs/evidence/authorization.md"}
|
||||
self.assertEqual([], self.validate(document))
|
||||
|
||||
def test_implemented_level_must_be_above_current(self) -> None:
|
||||
document = declaration()
|
||||
document["tenancy"]["implemented"] = {"E": 0}
|
||||
self.assertIn("implemented E0 must be above current E0", self.validate(document)[0])
|
||||
|
||||
def test_provider_available_cannot_exceed_maximum(self) -> None:
|
||||
document = declaration()
|
||||
document["provider"] = {"axes": {"V": {"available": 2, "maximum": 1}}}
|
||||
self.assertIn("provider V available 2 exceeds maximum 1", self.validate(document)[0])
|
||||
|
||||
def test_review_due_cannot_precede_review(self) -> None:
|
||||
document = declaration()
|
||||
document["tenancy"]["review_due"] = "2026-08-16"
|
||||
self.assertIn("review_due precedes reviewed", self.validate(document)[0])
|
||||
|
||||
def test_service_names_are_unique(self) -> None:
|
||||
entry = declaration()
|
||||
document = {
|
||||
"schema_version": "0.1",
|
||||
"framework": "netkingdom-tenancy-posture",
|
||||
"services": [entry, entry],
|
||||
}
|
||||
self.assertIn("service names must be unique", self.validate(document)[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
112
tools/tenancy-posture/validate.py
Normal file
112
tools/tenancy-posture/validate.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = ["jsonschema>=4.23,<5", "PyYAML>=6,<7"]
|
||||
# ///
|
||||
"""Validate tenancy.yaml files against the canonical syntax and semantics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import jsonschema
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
SCHEMA = ROOT / "canon/schemas/tenancy-posture_v0.1.schema.json"
|
||||
AXES = ("I", "A", "E", "P", "R", "V")
|
||||
FLOORS = {"I": 1, "A": 1, "E": 0, "P": 0, "R": 1, "V": 0}
|
||||
|
||||
|
||||
def services(document: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if "services" in document:
|
||||
return document["services"]
|
||||
return [document]
|
||||
|
||||
|
||||
def validate_semantics(document: dict[str, Any], path: pathlib.Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
entries = services(document)
|
||||
names = [entry["service"] for entry in entries]
|
||||
if len(names) != len(set(names)):
|
||||
errors.append("service names must be unique")
|
||||
|
||||
for entry in entries:
|
||||
name = entry["service"]
|
||||
posture = entry["tenancy"]
|
||||
current = posture["current"]
|
||||
reason = posture.get("reason", {})
|
||||
gap = posture.get("gap", {})
|
||||
evidence = entry.get("evidence", {})
|
||||
|
||||
reviewed = dt.date.fromisoformat(posture["reviewed"])
|
||||
review_due = dt.date.fromisoformat(posture["review_due"])
|
||||
if review_due < reviewed:
|
||||
errors.append(f"{name}: review_due precedes reviewed")
|
||||
|
||||
for axis in AXES:
|
||||
level = current[axis]
|
||||
explained = bool(reason.get(axis) or gap.get(axis))
|
||||
if level == "n/a" or (isinstance(level, int) and level <= FLOORS[axis]):
|
||||
if not explained:
|
||||
errors.append(f"{name}: {axis}{level} needs reason or gap text")
|
||||
continue
|
||||
key = f"{axis}{level}"
|
||||
if key not in evidence:
|
||||
errors.append(f"{name}: current {key} has no evidence entry")
|
||||
|
||||
implemented = posture.get("implemented", {})
|
||||
for axis, level in implemented.items():
|
||||
current_level = current[axis]
|
||||
if current_level != "n/a" and level != "n/a" and level <= current_level:
|
||||
errors.append(
|
||||
f"{name}: implemented {axis}{level} must be above current {axis}{current_level}"
|
||||
)
|
||||
|
||||
provider = entry.get("provider", {})
|
||||
for axis, reach in provider.get("axes", {}).items():
|
||||
available = reach["available"]
|
||||
maximum = reach["maximum"]
|
||||
if available != "n/a" and maximum != "n/a" and available > maximum:
|
||||
errors.append(
|
||||
f"{name}: provider {axis} available {available} exceeds maximum {maximum}"
|
||||
)
|
||||
|
||||
return [f"{path}: {error}" for error in errors]
|
||||
|
||||
|
||||
def validate(path: pathlib.Path, schema: dict[str, Any]) -> list[str]:
|
||||
document = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
validator = jsonschema.Draft202012Validator(
|
||||
schema, format_checker=jsonschema.FormatChecker()
|
||||
)
|
||||
errors = [
|
||||
f"{path}: {'/'.join(str(part) for part in error.absolute_path) or '<root>'}: {error.message}"
|
||||
for error in sorted(validator.iter_errors(document), key=lambda item: list(item.path))
|
||||
]
|
||||
if not errors:
|
||||
errors.extend(validate_semantics(document, path))
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("declarations", nargs="+", type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
errors = [error for path in args.declarations for error in validate(path, schema)]
|
||||
if errors:
|
||||
print("\n".join(errors), file=sys.stderr)
|
||||
return 1
|
||||
for path in args.declarations:
|
||||
print(f"{path}: valid")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue