Log analysis functionality for self-assessment

This commit is contained in:
tegwick 2026-05-14 02:47:00 +02:00
parent 97a4a1fa37
commit cd43c7cfec
12 changed files with 1573 additions and 2 deletions

View file

@ -0,0 +1,99 @@
"""Durable archive helpers for guide-board OpenCMIS assessment runs."""
from __future__ import annotations
import hashlib
import json
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
MANIFEST_NAME = "archive-manifest.json"
def archive_run(
run_dir: Path,
archive_root: Path,
*,
target_id: str | None = None,
archive_name: str | None = None,
) -> dict[str, Any]:
"""Copy a guide-board run directory into a durable archive path."""
source = run_dir.resolve()
if not source.exists() or not source.is_dir():
raise FileNotFoundError(f"run directory does not exist: {source}")
run_metadata = _load_json(source / "run.json")
target_profile = _load_json(source / "target-profile.snapshot.json")
resolved_target = target_id or target_profile.get("id") or run_metadata.get("target_profile_ref") or "unknown-target"
resolved_run_id = archive_name or run_metadata.get("id") or source.name
archive_dir = archive_root.resolve() / _safe_segment(str(resolved_target)) / _safe_segment(str(resolved_run_id))
if archive_dir.exists():
raise FileExistsError(f"archive directory already exists: {archive_dir}")
archive_dir.mkdir(parents=True)
copied_files = _copy_tree(source, archive_dir)
files = [_file_manifest_entry(archive_dir, relative_path) for relative_path in copied_files]
manifest = {
"id": f"opencmis-run-archive:{resolved_run_id}",
"created_at": _now(),
"source_run_dir": str(source),
"archive_dir": str(archive_dir),
"run_id": run_metadata.get("id") or source.name,
"target_profile_ref": run_metadata.get("target_profile_ref") or target_profile.get("id"),
"assessment_profile_ref": run_metadata.get("assessment_profile_ref"),
"file_count": len(files),
"total_bytes": sum(item["size_bytes"] for item in files),
"files": files,
}
(archive_dir / MANIFEST_NAME).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
return manifest
def _copy_tree(source: Path, destination: Path) -> list[Path]:
copied: list[Path] = []
for path in sorted(source.rglob("*")):
if not path.is_file():
continue
relative_path = path.relative_to(source)
target = destination / relative_path
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
copied.append(relative_path)
return copied
def _file_manifest_entry(root: Path, relative_path: Path) -> dict[str, Any]:
path = root / relative_path
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return {
"path": relative_path.as_posix(),
"size_bytes": path.stat().st_size,
"sha256": digest.hexdigest(),
}
def _load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
def _safe_segment(value: str) -> str:
safe = "".join(char if char.isalnum() or char in {"-", "_", "."} else "-" for char in value.strip())
safe = "-".join(part for part in safe.split("-") if part)
return safe or "unknown"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()

View file

@ -0,0 +1,562 @@
"""OpenCMIS TCK run log review and warning policy classification."""
from __future__ import annotations
import json
import re
from collections import Counter
from datetime import datetime, timezone
from ipaddress import ip_address
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
DEFAULT_POLICY_PATH = Path(__file__).resolve().parents[2] / "profiles" / "expectations" / "opencmis-warning-policy.json"
ERROR_TERMS = ("warn", "warning", "error", "severe", "exception", "caused by", "failed")
SKIP_BOUNDARY_RULES = [
{
"id": "relationship-type-not-creatable",
"message_contains": "Relationship type 'cmis:relationship' is not creatable",
"required_capability": "cmis.relationships",
"classification": "declared_type_creatability_boundary",
},
{
"id": "policy-type-not-creatable",
"message_contains": "Policy type 'cmis:policy' is not creatable",
"required_capability": "cmis.policy-mutation",
"classification": "declared_type_creatability_boundary",
},
{
"id": "item-type-not-creatable",
"message_contains": "Item type 'cmis:item' is not creatable",
"required_capability": "cmis.item-services",
"classification": "declared_type_creatability_boundary",
},
{
"id": "document-subtype-not-creatable",
"message_contains": "Test document type doesn't allow creating a sub-type",
"required_capability": "cmis.type-mutability",
"classification": "declared_type_mutability_boundary",
},
{
"id": "folder-name-change-not-supported",
"message_contains": "Folder name can't be changed",
"required_capability": "cmis.folder-name-mutation",
"classification": "declared_folder_mutation_boundary",
},
]
def build_log_review(
run_dir: Path,
*,
policy_path: Path | None = None,
previous_run_dir: Path | None = None,
server_log_paths: list[Path] | None = None,
) -> dict[str, Any]:
"""Build a compact review of warnings, stderr, skips, and hard errors."""
run = _load_run(run_dir)
policy = _load_policy(policy_path)
previous = _load_run(previous_run_dir) if previous_run_dir is not None else None
warning_reviews = [
_review_warning(case, run, policy)
for case in run["cases"]
if case.get("status") == "warning"
]
hard_errors = [
_case_summary(case, run)
for case in run["cases"]
if case.get("status") in {"fail", "infrastructure_error", "blocked"}
]
skip_reviews = [
_review_skip(case, run)
for case in run["cases"]
if case.get("status") == "skipped"
]
stderr_files = _collect_stderr_files(run["run_dir"])
server_log_findings = _scan_server_logs(server_log_paths or [], run["run_dir"])
previous_warning_signatures = {
_case_signature(case)
for case in (previous or {}).get("cases", [])
if case.get("status") == "warning"
}
current_warning_signatures = {
_case_signature(case)
for case in run["cases"]
if case.get("status") == "warning"
}
closed_warnings = [
_case_summary(case, previous or {})
for case in (previous or {}).get("cases", [])
if case.get("status") == "warning"
and _case_signature(case) not in current_warning_signatures
]
new_warnings = [
item
for item in warning_reviews
if item["signature"] not in previous_warning_signatures
]
unexpected_findings = [
finding
for finding in run["findings"]
if not finding.get("expected")
]
unaccepted_warnings = [item for item in warning_reviews if not item["accepted"]]
unexpected_skips = [item for item in skip_reviews if not item["expected"]]
nonempty_stderr = [item for item in stderr_files if item["size_bytes"] > 0]
status = _review_status(
hard_errors=hard_errors,
unexpected_findings=unexpected_findings,
unaccepted_warnings=unaccepted_warnings,
unexpected_skips=unexpected_skips,
nonempty_stderr=nonempty_stderr,
warning_reviews=warning_reviews,
skip_reviews=skip_reviews,
)
summary = {
"status": status,
"case_count": len(run["cases"]),
"case_status_counts": dict(sorted(Counter(case.get("status", "unknown") for case in run["cases"]).items())),
"warning_count": len(warning_reviews),
"accepted_warnings": sum(1 for item in warning_reviews if item["accepted"]),
"unaccepted_warnings": len(unaccepted_warnings),
"new_warnings": len(new_warnings),
"closed_warnings": len(closed_warnings),
"hard_error_count": len(hard_errors),
"stderr_files": len(stderr_files),
"nonempty_stderr_files": len(nonempty_stderr),
"skipped_cases": len(skip_reviews),
"expected_skips": sum(1 for item in skip_reviews if item["expected"]),
"unexpected_skips": len(unexpected_skips),
"unexpected_findings": len(unexpected_findings),
"server_log_findings": len(server_log_findings),
}
return {
"id": f"opencmis-log-review:{run['run_id']}",
"created_at": _now(),
"run": {
"run_id": run["run_id"],
"run_dir": str(run["run_dir"]),
"target_profile_ref": run["target_profile_ref"],
"assessment_profile_ref": run["assessment_profile_ref"],
"target_environment": run["target_environment"],
"browser_binding_url": run["browser_binding_url"],
"declared_capabilities": run["declared_capabilities"],
},
"policy": {
"id": policy.get("id"),
"path": str(policy.get("_path")) if policy.get("_path") else None,
},
"summary": summary,
"warnings": warning_reviews,
"new_warnings": new_warnings,
"closed_warnings": closed_warnings,
"hard_errors": hard_errors,
"stderr": stderr_files,
"skips": skip_reviews,
"unexpected_findings": unexpected_findings,
"server_log_findings": server_log_findings,
"certification_boundary": "This log review supports preparation and operational readiness only; it does not certify CMIS conformance.",
}
def write_log_review(
run_dir: Path,
*,
output_dir: Path | None = None,
policy_path: Path | None = None,
previous_run_dir: Path | None = None,
server_log_paths: list[Path] | None = None,
) -> dict[str, str]:
output = output_dir or run_dir / "reports"
output.mkdir(parents=True, exist_ok=True)
review = build_log_review(
run_dir,
policy_path=policy_path,
previous_run_dir=previous_run_dir,
server_log_paths=server_log_paths,
)
json_path = output / "opencmis-log-review.json"
markdown_path = output / "opencmis-log-review.md"
json_path.write_text(json.dumps(review, indent=2, sort_keys=True) + "\n", encoding="utf-8")
markdown_path.write_text(markdown_log_review(review), encoding="utf-8")
return {
"status": "written",
"json": str(json_path),
"markdown": str(markdown_path),
}
def markdown_log_review(review: dict[str, Any]) -> str:
summary = review["summary"]
lines = [
f"# OpenCMIS Log Review: {review['run']['run_id']}",
"",
f"Target: {review['run']['target_profile_ref']}",
f"Assessment: {review['run']['assessment_profile_ref']}",
f"Status: {summary['status']}",
"",
"## Summary",
"",
f"- cases: {summary['case_count']}",
f"- warnings: {summary['warning_count']} ({summary['accepted_warnings']} accepted, {summary['unaccepted_warnings']} unaccepted)",
f"- hard errors: {summary['hard_error_count']}",
f"- stderr files: {summary['nonempty_stderr_files']} non-empty / {summary['stderr_files']} scanned",
f"- skipped cases: {summary['skipped_cases']} ({summary['expected_skips']} expected, {summary['unexpected_skips']} needs review)",
f"- unexpected findings: {summary['unexpected_findings']}",
f"- server log findings: {summary['server_log_findings']}",
"",
"## Warnings",
"",
]
if review["warnings"]:
for item in review["warnings"]:
lines.extend(
[
f"- {item['severity']}: {item['classification']} ({item.get('policy_id') or 'no-policy'})",
f" {item['selected_check_group']} / {item['test_name']}: {item['message']}",
]
)
else:
lines.append("- none")
lines.extend(["", "## Skips", ""])
if review["skips"]:
for item in review["skips"]:
expected = "expected" if item["expected"] else "needs review"
lines.append(
f"- {expected}: {item['classification']} / {item['selected_check_group']} / {item['test_name']}: {item['message']}"
)
else:
lines.append("- none")
lines.extend(["", "## Hard Errors And Stderr", ""])
if review["hard_errors"]:
for item in review["hard_errors"]:
lines.append(f"- {item['status']}: {item['selected_check_group']} / {item['test_name']}: {item['message']}")
else:
lines.append("- no hard OpenCMIS case errors")
for item in review["stderr"]:
if item["size_bytes"] > 0:
lines.append(f"- non-empty stderr: {item['path']} ({item['size_bytes']} bytes)")
lines.extend(["", "## Closed Warnings", ""])
if review["closed_warnings"]:
for item in review["closed_warnings"]:
lines.append(f"- {item['selected_check_group']} / {item['test_name']}: {item['message']}")
else:
lines.append("- none")
lines.extend(["", "## Boundary", "", review["certification_boundary"], ""])
return "\n".join(lines)
def _load_run(run_dir: Path | None) -> dict[str, Any]:
if run_dir is None:
return {}
resolved = run_dir.resolve()
run_metadata = _load_json(resolved / "run.json")
target_profile = _load_json(resolved / "target-profile.snapshot.json")
assessment_profile = _load_json(resolved / "assessment-profile.snapshot.json")
evidence = _load_json(resolved / "normalized" / "evidence.json").get("evidence", [])
findings = _load_json(resolved / "normalized" / "findings.json").get("findings", [])
endpoint = _browser_binding_url(target_profile, evidence)
cases = _cases_from_evidence(evidence)
return {
"run_dir": resolved,
"run_id": run_metadata.get("id") or resolved.name,
"target_profile_ref": run_metadata.get("target_profile_ref") or target_profile.get("id"),
"assessment_profile_ref": run_metadata.get("assessment_profile_ref") or assessment_profile.get("id"),
"target_environment": target_profile.get("environment"),
"target_profile": target_profile,
"assessment_profile": assessment_profile,
"browser_binding_url": endpoint,
"declared_capabilities": sorted(target_profile.get("declared_capabilities") or []),
"cases": cases,
"findings": findings if isinstance(findings, list) else [],
}
def _cases_from_evidence(evidence: list[dict[str, Any]]) -> list[dict[str, Any]]:
cases: list[dict[str, Any]] = []
for item in evidence:
facts = item.get("facts") or {}
for case in facts.get("cases") or []:
if not isinstance(case, dict):
continue
enriched = dict(case)
enriched.setdefault("selected_check_group", facts.get("selected_check_group") or facts.get("check_group"))
enriched.setdefault("check_id", item.get("check_id"))
enriched.setdefault("evidence_id", item.get("id"))
cases.append(enriched)
return cases
def _review_warning(case: dict[str, Any], run: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any]:
policy_item = _matching_warning_policy(case, policy)
accepted = _warning_is_accepted(policy_item, run) if policy_item else False
if not policy_item:
classification = "unclassified_warning"
severity = "warning"
reason = "No warning policy matched this OpenCMIS warning."
policy_id = None
elif accepted:
classification = policy_item.get("classification", "accepted_warning")
severity = policy_item.get("severity", "info")
reason = policy_item.get("reason", "")
policy_id = policy_item.get("id")
else:
classification = policy_item.get("unaccepted_classification", "unaccepted_warning")
severity = policy_item.get("unaccepted_severity", "warning")
reason = policy_item.get("unaccepted_reason", policy_item.get("reason", "Warning policy matched but acceptance conditions were not met."))
policy_id = policy_item.get("id")
summary = _case_summary(case, run)
summary.update(
{
"accepted": accepted,
"classification": classification,
"severity": severity,
"reason": reason,
"policy_id": policy_id,
"signature": _case_signature(case),
}
)
return summary
def _matching_warning_policy(case: dict[str, Any], policy: dict[str, Any]) -> dict[str, Any] | None:
for item in policy.get("warning_policies") or []:
if _policy_matches_case(item.get("match") or {}, case):
return item
return None
def _policy_matches_case(match: dict[str, Any], case: dict[str, Any]) -> bool:
message = str(case.get("message") or "")
if match.get("message_contains") and str(match["message_contains"]) not in message:
return False
if match.get("test_name_contains") and str(match["test_name_contains"]) not in str(case.get("test_name") or ""):
return False
if match.get("selected_check_group") and match["selected_check_group"] != case.get("selected_check_group"):
return False
source_match = match.get("source_location") or {}
source_location = case.get("source_location") or {}
if source_match.get("file") and source_match["file"] != source_location.get("file"):
return False
if source_match.get("line") and source_match["line"] != source_location.get("line"):
return False
return True
def _warning_is_accepted(policy_item: dict[str, Any] | None, run: dict[str, Any]) -> bool:
if not policy_item:
return False
accepted_when = policy_item.get("accepted_when") or {}
target_refs = accepted_when.get("target_profile_refs")
if target_refs and run.get("target_profile_ref") not in target_refs:
return False
environments = accepted_when.get("environments")
if environments and run.get("target_environment") not in environments:
return False
scheme = accepted_when.get("scheme")
parsed = urlparse(str(run.get("browser_binding_url") or ""))
if scheme and parsed.scheme != scheme:
return False
if accepted_when.get("host_scope") == "loopback" and not _is_loopback_host(parsed.hostname):
return False
return True
def _review_skip(case: dict[str, Any], run: dict[str, Any]) -> dict[str, Any]:
message = str(case.get("message") or "")
declared = set(run.get("declared_capabilities") or [])
for rule in SKIP_BOUNDARY_RULES:
if rule["message_contains"] not in message:
continue
required = rule["required_capability"]
expected = required not in declared
classification = (
rule["classification"]
if expected
else "advertised_capability_not_exercised"
)
summary = _case_summary(case, run)
summary.update(
{
"expected": expected,
"classification": classification,
"required_capability": required,
"rule_id": rule["id"],
}
)
return summary
summary = _case_summary(case, run)
summary.update(
{
"expected": False,
"classification": "unclassified_skip",
"required_capability": None,
"rule_id": None,
}
)
return summary
def _case_summary(case: dict[str, Any], run: dict[str, Any]) -> dict[str, Any]:
source_location = case.get("source_location") or {}
return {
"id": case.get("id"),
"status": case.get("status"),
"status_native": case.get("status_native"),
"selected_check_group": case.get("selected_check_group"),
"group_name": case.get("group_name"),
"test_name": case.get("test_name"),
"message": case.get("message"),
"source_location": source_location,
"evidence_id": case.get("evidence_id"),
"run_id": run.get("run_id"),
}
def _case_signature(case: dict[str, Any]) -> str:
source = case.get("source_location") or {}
parts = [
str(case.get("selected_check_group") or ""),
str(case.get("test_name") or ""),
str(case.get("message") or ""),
str(source.get("file") or ""),
str(source.get("line") or ""),
]
return "|".join(parts)
def _collect_stderr_files(run_dir: Path) -> list[dict[str, Any]]:
patterns = [
"artifacts/open-cmis-tck/tck/**/console-runner-stderr.txt",
"artifacts/open-cmis-tck/tck/**/stderr.log",
]
files: list[Path] = []
seen: set[Path] = set()
for pattern in patterns:
for path in sorted(run_dir.glob(pattern)):
resolved = path.resolve()
if path.is_file() and resolved not in seen:
files.append(path)
seen.add(resolved)
return [
{
"path": _relative(path, run_dir),
"size_bytes": path.stat().st_size,
"excerpt": _excerpt(path) if path.stat().st_size else "",
}
for path in files
]
def _scan_server_logs(paths: list[Path], run_dir: Path) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
for root in paths:
for path in _expand_log_paths(root):
for line_number, line in _matching_log_lines(path):
findings.append(
{
"path": _relative(path, run_dir),
"line": line_number,
"message": line.strip(),
}
)
return findings
def _expand_log_paths(path: Path) -> list[Path]:
if path.is_file():
return [path]
if path.is_dir():
return sorted(item for item in path.rglob("*") if item.is_file())
return []
def _matching_log_lines(path: Path) -> list[tuple[int, str]]:
matches: list[tuple[int, str]] = []
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for index, line in enumerate(handle, start=1):
lowered = line.lower()
if any(term in lowered for term in ERROR_TERMS):
matches.append((index, line))
if len(matches) >= 50:
break
except OSError:
return []
return matches
def _review_status(
*,
hard_errors: list[dict[str, Any]],
unexpected_findings: list[dict[str, Any]],
unaccepted_warnings: list[dict[str, Any]],
unexpected_skips: list[dict[str, Any]],
nonempty_stderr: list[dict[str, Any]],
warning_reviews: list[dict[str, Any]],
skip_reviews: list[dict[str, Any]],
) -> str:
if hard_errors or unexpected_findings or unaccepted_warnings or unexpected_skips or nonempty_stderr:
return "review_required"
if warning_reviews or skip_reviews:
return "pass_with_review_notes"
return "pass"
def _browser_binding_url(target_profile: dict[str, Any], evidence: list[dict[str, Any]]) -> str | None:
for endpoint in target_profile.get("endpoints") or []:
if endpoint.get("binding") == "cmis-browser":
return endpoint.get("url")
for item in evidence:
facts = item.get("facts") or {}
value = facts.get("browser_binding_url") or facts.get("url")
if value:
return str(value)
return None
def _load_policy(policy_path: Path | None) -> dict[str, Any]:
path = policy_path or DEFAULT_POLICY_PATH
if not path.exists():
return {"id": "none", "warning_policies": [], "_path": None}
policy = _load_json(path)
policy["_path"] = path
return policy
def _load_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
def _is_loopback_host(host: str | None) -> bool:
if not host:
return False
if host in {"localhost"}:
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
def _excerpt(path: Path, limit: int = 500) -> str:
return path.read_text(encoding="utf-8", errors="replace")[:limit]
def _relative(path: Path, root: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve()))
except ValueError:
return str(path.resolve())
def _now() -> str:
return datetime.now(timezone.utc).isoformat()