From a3ea11139c5bf5634ef0e078f2f1f8a42e4ce954 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 7 May 2026 22:19:10 +0200 Subject: [PATCH] http service with health, extension listing, profile validation, run planning, async run jobs, job inspection, and report retrieval --- README.md | 7 +- docs/ARCHITECTURE-BLUEPRINT.md | 14 + docs/COMPLIANCE-EVIDENCE-PACKS.md | 141 ++++++ docs/CONTAINER.md | 19 +- docs/EXTENSION-SDK.md | 27 ++ docs/LOCAL-SERVICE-API.md | 114 +++++ docs/schemas/evidence-request-set.schema.json | 65 +++ .../_template/evidence-request-set.json | 43 ++ src/guide_board/cli.py | 19 + src/guide_board/service.py | 451 ++++++++++++++++++ tests/test_core.py | 115 ++++- .../GUIDE-BOARD-WP-0001-bootstrapping.md | 26 +- 12 files changed, 1028 insertions(+), 13 deletions(-) create mode 100644 docs/COMPLIANCE-EVIDENCE-PACKS.md create mode 100644 docs/LOCAL-SERVICE-API.md create mode 100644 docs/schemas/evidence-request-set.schema.json create mode 100644 extensions/_template/evidence-request-set.json create mode 100644 src/guide_board/service.py diff --git a/README.md b/README.md index 9b0067a..62a5d7b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ PYTHONPATH=src python3 -m guide_board run \ PYTHONPATH=src python3 -m guide_board runs list PYTHONPATH=src python3 -m guide_board runs trend PYTHONPATH=src python3 -m guide_board runs gate +PYTHONPATH=src python3 -m guide_board serve --host 127.0.0.1 --port 8080 PYTHONPATH=src python3 -m unittest discover -s tests ``` @@ -38,7 +39,9 @@ PYTHONPATH=src python3 -m guide_board --extension-dir ../open-cmis-tck plan \ ``` The same CLI contracts are packaged by the container baseline. See -[docs/CONTAINER.md](docs/CONTAINER.md). +[docs/CONTAINER.md](docs/CONTAINER.md). The dependency-light local API wraps +those contracts for service and container operation; see +[docs/LOCAL-SERVICE-API.md](docs/LOCAL-SERVICE-API.md). The `sample-noop` extension exercises the guide-board contracts without invoking an external harness. `open-cmis-tck` is the first real seed extension. @@ -47,7 +50,9 @@ See: - [INTENT.md](INTENT.md) - [docs/ARCHITECTURE-BLUEPRINT.md](docs/ARCHITECTURE-BLUEPRINT.md) +- [docs/COMPLIANCE-EVIDENCE-PACKS.md](docs/COMPLIANCE-EVIDENCE-PACKS.md) - [docs/CONTAINER.md](docs/CONTAINER.md) - [docs/EXTENSION-SDK.md](docs/EXTENSION-SDK.md) +- [docs/LOCAL-SERVICE-API.md](docs/LOCAL-SERVICE-API.md) - [extensions/CANDIDATES.md](extensions/CANDIDATES.md) - [workplans/GUIDE-BOARD-WP-0001-bootstrapping.md](workplans/GUIDE-BOARD-WP-0001-bootstrapping.md) diff --git a/docs/ARCHITECTURE-BLUEPRINT.md b/docs/ARCHITECTURE-BLUEPRINT.md index efe8f95..9125799 100644 --- a/docs/ARCHITECTURE-BLUEPRINT.md +++ b/docs/ARCHITECTURE-BLUEPRINT.md @@ -301,6 +301,15 @@ cluster, product, API, data archive, host, organization, process, or policy set. Assessment profiles select frameworks, controls, check groups, expectations, waivers, output policies, and retention policies. +### Local Service Facade + +Wraps the CLI/core contracts in a dependency-light local HTTP API. The service +can list extensions, validate profiles, build plans, start assessment jobs, +inspect job status, and fetch generated reports. + +The first implementation stores job status in memory and leaves durable evidence +in the normal run directory. It does not introduce separate execution semantics. + ### Assessment Planner Resolves an assessment profile into an executable run plan: @@ -446,6 +455,11 @@ executable harness exists. Examples: GDPR, SOC 2, HIPAA, NF Z 42-013, NF 461, ISO 14641, ISO 15489. +Procedural packs use evidence request sets to describe artifact collection, +review roles, acceptance criteria, confidentiality, renewal expectations, and +waiver paths without reproducing restricted standard text. See +`docs/COMPLIANCE-EVIDENCE-PACKS.md`. + ### Hybrid Extension Combines automated checks, manual evidence, external auditor review, and imported diff --git a/docs/COMPLIANCE-EVIDENCE-PACKS.md b/docs/COMPLIANCE-EVIDENCE-PACKS.md new file mode 100644 index 0000000..23de1e4 --- /dev/null +++ b/docs/COMPLIANCE-EVIDENCE-PACKS.md @@ -0,0 +1,141 @@ +# Compliance Evidence Pack Strategy + +Status: draft +Created: 2026-05-07 + +## Purpose + +Compliance evidence packs cover frameworks where guide-board cannot rely on an +official executable harness. They help prepare and perform assessments by +organizing evidence requests, expected artifacts, reviewer workflow, waivers, +and run reports. They do not replace auditors, accredited certification bodies, +legal counsel, or official standard text. + +Examples include GDPR, SOC 2, HIPAA, NF Z 42-013, NF 461, ISO 14641, ISO 15489, +and similar procedural or control-oriented frameworks. + +## Core Boundary + +Guide-board should keep four layers separate: + +- Official source metadata: authority, framework version, source URL, access + constraints, and citation references. +- Internal interpretation: locally authored control themes, evidence request + wording, review roles, and acceptance criteria. +- Target evidence: artifacts, attestations, screenshots, logs, policy files, + architecture documents, operational records, and interview notes. +- Assessment result: normalized evidence, findings, waivers, trend summaries, + and quality gates. + +The core may store references to official clauses, controls, or articles, but it +must not redistribute proprietary standard text. Extension authors are +responsible for confirming source licensing and citation posture. + +## Extension Shape + +Compliance packs are ordinary extensions with `extension_type` set to +`procedural_evidence` or `hybrid`. + +Recommended extension layout: + +```text +/ + INTENT.md + extension.json + evidence-requests/ + .json + mappings/ + .json + profiles/ + reports/ + workplans/ +``` + +The manifest should declare: + +- framework IDs in `supported_frameworks`, +- authority IDs in `authorities`, +- manual or procedural `check_groups`, +- mapping sets that map request requirement refs to controls or assessment + themes, +- a certification boundary that states the pack is preparation support only. + +## Evidence Request Sets + +An evidence request set is a reviewable catalog of questions and artifacts. It +validates against: + +```text +docs/schemas/evidence-request-set.schema.json +``` + +Each request should include: + +- stable request ID, +- requirement refs or internal control refs, +- request type, such as document, interview, configuration sample, operational + record, attestation, system export, or mixed evidence, +- requested artifact classes, +- reviewer roles, +- acceptance criteria, +- confidentiality level, +- renewal or freshness expectations. + +Requests should be phrased as collection guidance, not as legal conclusions. + +## Waivers And Expected Gaps + +Evidence packs use the same expectation and waiver model as executable +extensions. + +Use expectations for: + +- not-applicable scope boundaries, +- unsupported-by-design target posture, +- evidence that is intentionally deferred until a later assessment phase. + +Use waivers for: + +- approved exceptions, +- compensating-control situations, +- temporary missing evidence, +- auditor-reviewed deviations. + +Every waiver should include owner, reason, approval status, and expiry. + +## Framework Notes + +GDPR packs should emphasize processing inventory, lawful basis records, data +subject rights, subprocessors, transfer posture, breach response, and privacy +governance without providing legal advice. + +SOC 2 packs should organize evidence by trust-service criteria references, +control ownership, system boundaries, change management, security operations, +vendor management, and incident response. + +HIPAA packs should separate administrative, physical, and technical safeguard +evidence from policy interpretation and should clearly mark protected-health +information handling constraints. + +NF Z 42-013, NF 461, ISO 14641, and ISO 15489 packs should focus on records +management, archival integrity, traceability, retention, reversibility, +auditability, and governance evidence. Proprietary standard text should be +referenced only by stable IDs or user-provided licensed material. + +## Output Model + +Compliance packs should produce the same guide-board outputs as harness +extensions: + +- normalized evidence, +- findings, +- mapping records, +- assessment packages, +- retention summaries, +- trend summaries, +- gate summaries. + +Manual requests can initially result in `manual`, `blocked`, `not_applicable`, +or `expected_gap` evidence. Later pack-specific runners may import spreadsheets, +document inventories, ticket exports, cloud configuration snapshots, or auditor +review files and normalize them into the same evidence model. diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md index f657958..75679b1 100644 --- a/docs/CONTAINER.md +++ b/docs/CONTAINER.md @@ -102,14 +102,17 @@ preflight runner, command wrapper, and mappings. It does not include the final Apache Chemistry TCK dependency graph. A future CMIS image should add Java/Maven and document how the OpenCMIS TCK artifacts are resolved or mounted. -## Service Path +## Service Mode -A service image should call the same CLI contracts used here: +The current core image can run the local service API through the same entrypoint: -- validate profiles, -- build run plans, -- execute runs, -- read run metadata, evidence, reports, retention summaries, trends, and gates. +```sh +podman run --rm -p 8080:8080 \ + -v "$PWD/runs:/runs" \ + guide-board-core:local \ + --root /opt/guide-board serve --host 0.0.0.0 --port 8080 +``` -The service layer may add job tracking and HTTP transport, but it should not -create separate execution semantics. +The service layer adds in-memory job tracking and HTTP transport. Execution +semantics remain the CLI/core semantics documented in +`docs/LOCAL-SERVICE-API.md`. diff --git a/docs/EXTENSION-SDK.md b/docs/EXTENSION-SDK.md index f551e8c..80c8971 100644 --- a/docs/EXTENSION-SDK.md +++ b/docs/EXTENSION-SDK.md @@ -21,6 +21,7 @@ extensions// src/ docs/ schemas/ + evidence-requests/ checks/ mappings/ profiles/ @@ -157,6 +158,32 @@ to extension-owned mappings and writes normalized mapping records to: runs//normalized/mappings.json ``` +## Evidence Request Sets + +Procedural and hybrid compliance extensions may include evidence request sets +under: + +```text +evidence-requests/.json +``` + +These files validate against: + +```text +docs/schemas/evidence-request-set.schema.json +``` + +Evidence request sets are for collection guidance and review workflow. They +should reference official requirements by stable IDs or user-held licensed +material, but they must not redistribute proprietary standard text. A starter +template lives at: + +```text +extensions/_template/evidence-request-set.json +``` + +See `docs/COMPLIANCE-EVIDENCE-PACKS.md` for the compliance-pack strategy. + ## Expectations And Waivers Assessment profiles may reference expectation and waiver sets: diff --git a/docs/LOCAL-SERVICE-API.md b/docs/LOCAL-SERVICE-API.md new file mode 100644 index 0000000..fb9bf8b --- /dev/null +++ b/docs/LOCAL-SERVICE-API.md @@ -0,0 +1,114 @@ +# Guide Board Local Service API + +Status: draft +Created: 2026-05-07 + +## Purpose + +The local service API is a thin HTTP facade over the same discovery, validation, +planning, execution, and report contracts used by the CLI. It is intended for +local development, containerized operation, and future UI integration. + +It does not change certification boundaries: guide-board prepares structured +evidence and assessment packages, but it does not issue certifications or audit +assurance. + +## Start + +```sh +PYTHONPATH=src python3 -m guide_board serve --host 127.0.0.1 --port 8080 +``` + +External extension repositories use the same top-level option as the CLI: + +```sh +PYTHONPATH=src python3 -m guide_board \ + --extension-dir ../open-cmis-tck \ + serve --host 127.0.0.1 --port 8080 +``` + +Paths supplied to request bodies are resolved relative to the configured +`--root` unless they are absolute paths. + +## Endpoints + +### `GET /health` + +Returns service status, configured root, and in-memory job counts. + +### `GET /extensions` + +Lists bundled and configured external extensions using the same discovery logic +as `guide-board extensions list`. + +### `POST /profiles/validate` + +Validates a target or assessment profile. + +```json +{ + "kind": "target", + "path": "profiles/targets/sample-repository.json" +} +``` + +Use `"kind": "assessment"` for assessment profiles. + +### `POST /assessments/plan` + +Builds a run plan without starting a run. + +```json +{ + "target": "profiles/targets/sample-repository.json", + "assessment": "profiles/assessments/sample-noop.json" +} +``` + +An optional `extension_dirs` array can add request-specific external extension +locations. + +### `POST /runs` + +Starts an assessment run in a background thread and returns a job record. + +```json +{ + "target": "profiles/targets/sample-repository.json", + "assessment": "profiles/assessments/sample-noop.json", + "output_dir": "runs/sample-noop" +} +``` + +The HTTP job status is `queued`, `running`, `succeeded`, or `failed`. A +`succeeded` job means the guide-board executor completed and wrote its normal +run directory; the assessment result itself is still reported separately as +`completed`, `failed`, `blocked`, or `infrastructure_error`. + +### `GET /runs` + +Lists known in-memory jobs for the current service process. + +### `GET /runs/{job_id}` + +Returns a single job record with request metadata, result paths, or execution +errors. + +### `GET /runs/{job_id}/reports` + +Returns the Markdown report content, assessment package JSON, retention summary, +and their filesystem paths after a job has succeeded. + +## Container Mode + +The core container can run the service through the existing entrypoint: + +```sh +podman run --rm -p 8080:8080 \ + -v "$PWD/runs:/runs" \ + guide-board-core:local \ + --root /opt/guide-board serve --host 0.0.0.0 --port 8080 +``` + +The service keeps job state in memory. Durable run evidence remains in the +mounted output directory. diff --git a/docs/schemas/evidence-request-set.schema.json b/docs/schemas/evidence-request-set.schema.json new file mode 100644 index 0000000..421c0ca --- /dev/null +++ b/docs/schemas/evidence-request-set.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Evidence Request Set", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "extension_id", + "framework_refs", + "source_boundary", + "evidence_requests" + ], + "properties": { + "id": { "type": "string" }, + "extension_id": { "type": "string" }, + "framework_refs": { "type": "array", "items": { "type": "string" } }, + "source_boundary": { + "type": "object", + "additionalProperties": false, + "required": [ + "official_sources", + "interpretation_owner", + "redistribution_policy", + "certification_boundary" + ], + "properties": { + "official_sources": { "type": "array", "items": { "type": "string" } }, + "interpretation_owner": { "type": "string" }, + "redistribution_policy": { "type": "string" }, + "certification_boundary": { "type": "string" } + } + }, + "evidence_requests": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "requirement_refs", + "request_type", + "description", + "requested_artifacts", + "review_roles", + "acceptance_criteria", + "confidentiality", + "renewal" + ], + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "request_type": { "type": "string" }, + "description": { "type": "string" }, + "requested_artifacts": { "type": "array", "items": { "type": "string" } }, + "review_roles": { "type": "array", "items": { "type": "string" } }, + "acceptance_criteria": { "type": "array", "items": { "type": "string" } }, + "confidentiality": { "type": "string" }, + "renewal": { "type": "object" } + } + } + } + } +} diff --git a/extensions/_template/evidence-request-set.json b/extensions/_template/evidence-request-set.json new file mode 100644 index 0000000..2b7a6c3 --- /dev/null +++ b/extensions/_template/evidence-request-set.json @@ -0,0 +1,43 @@ +{ + "id": "replace-with-request-set-id", + "extension_id": "replace-with-extension-id", + "framework_refs": [ + "replace-with-framework-id" + ], + "source_boundary": { + "official_sources": [ + "https://example.invalid/official-source-or-reference" + ], + "interpretation_owner": "replace-with-owner", + "redistribution_policy": "Do not copy restricted standard text into this file; use stable IDs and user-held licensed material.", + "certification_boundary": "This evidence request set supports assessment preparation only." + }, + "evidence_requests": [ + { + "id": "request-001", + "title": "Replace With Evidence Request Title", + "requirement_refs": [ + "replace-with-requirement-ref" + ], + "request_type": "document", + "description": "Describe the evidence needed without reproducing restricted standard text.", + "requested_artifacts": [ + "policy document", + "review record" + ], + "review_roles": [ + "control owner", + "assessor" + ], + "acceptance_criteria": [ + "Artifact is current for the assessment period.", + "Owner and review date are identifiable." + ], + "confidentiality": "internal", + "renewal": { + "freshness": "per assessment cycle", + "review_interval_days": 365 + } + } + ] +} diff --git a/src/guide_board/cli.py b/src/guide_board/cli.py index 3eb9291..e734458 100644 --- a/src/guide_board/cli.py +++ b/src/guide_board/cli.py @@ -20,6 +20,7 @@ from guide_board.planning import ( ) from guide_board.retention import build_trend_summary, list_retained_runs from guide_board.schema import assert_valid +from guide_board.service import build_server def main(argv: list[str] | None = None) -> int: @@ -82,6 +83,11 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--output-dir", type=Path) run.set_defaults(func=cmd_run) + serve = subcommands.add_parser("serve", help="serve the local HTTP API") + serve.add_argument("--host", default="127.0.0.1") + serve.add_argument("--port", type=int, default=8080) + serve.set_defaults(func=cmd_serve) + runs = subcommands.add_parser("runs", help="run history operations") runs_commands = runs.add_subparsers(required=True) list_runs = runs_commands.add_parser("list", help="list retained run summaries") @@ -160,6 +166,19 @@ def cmd_run(args: argparse.Namespace) -> dict[str, Any]: ) +def cmd_serve(args: argparse.Namespace) -> None: + server = build_server(args.root, args.extension_dir, args.host, args.port) + host, port = server.server_address + print(f"guide-board: serving local API on http://{host}:{port}", file=sys.stderr) + try: + server.serve_forever() + except KeyboardInterrupt: + print("guide-board: stopping local API", file=sys.stderr) + finally: + server.server_close() + return None + + def cmd_runs_list(args: argparse.Namespace) -> dict[str, Any]: runs_dir = args.runs_dir or args.root / "runs" return { diff --git a/src/guide_board/service.py b/src/guide_board/service.py new file mode 100644 index 0000000..5ab4a25 --- /dev/null +++ b/src/guide_board/service.py @@ -0,0 +1,451 @@ +"""Dependency-light local HTTP API for guide-board.""" + +from __future__ import annotations + +import json +import threading +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from guide_board.discovery import discover_extensions +from guide_board.errors import GuideBoardError +from guide_board.execution import run_assessment +from guide_board.io import load_json +from guide_board.planning import ( + build_run_plan, + validate_assessment_profile, + validate_target_profile, +) + + +@dataclass(frozen=True) +class ServiceHandle: + """Background service handle for tests and local embedding.""" + + server: "GuideBoardHTTPServer" + thread: threading.Thread + + @property + def host(self) -> str: + return str(self.server.server_address[0]) + + @property + def port(self) -> int: + return int(self.server.server_address[1]) + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}" + + def stop(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + +@dataclass(frozen=True) +class ServiceContext: + root: Path + extension_dirs: list[Path] + jobs: "JobStore" + + +class JobStore: + def __init__(self) -> None: + self._lock = threading.Lock() + self._jobs: dict[str, dict[str, Any]] = {} + + def create(self, request: dict[str, Any]) -> dict[str, Any]: + now = _now() + job = { + "job_id": uuid.uuid4().hex, + "status": "queued", + "created_at": now, + "updated_at": now, + "request": request, + "result": None, + "error": None, + } + with self._lock: + self._jobs[job["job_id"]] = job + return dict(job) + + def update(self, job_id: str, **updates: Any) -> dict[str, Any]: + with self._lock: + if job_id not in self._jobs: + raise HttpProblem(404, f"run job not found: {job_id}") + job = dict(self._jobs[job_id]) + job.update(updates) + job["updated_at"] = _now() + self._jobs[job_id] = job + return dict(job) + + def get(self, job_id: str) -> dict[str, Any]: + with self._lock: + if job_id not in self._jobs: + raise HttpProblem(404, f"run job not found: {job_id}") + return dict(self._jobs[job_id]) + + def list(self) -> list[dict[str, Any]]: + with self._lock: + return [dict(job) for job in self._jobs.values()] + + def summary(self) -> dict[str, int]: + counts: dict[str, int] = {} + with self._lock: + for job in self._jobs.values(): + counts[job["status"]] = counts.get(job["status"], 0) + 1 + return counts + + +class HttpProblem(Exception): + def __init__(self, status_code: int, message: str) -> None: + super().__init__(message) + self.status_code = status_code + self.message = message + + +class GuideBoardHTTPServer(ThreadingHTTPServer): + context: ServiceContext + daemon_threads = True + + +class GuideBoardRequestHandler(BaseHTTPRequestHandler): + server: GuideBoardHTTPServer + server_version = "GuideBoardLocalAPI/0.1" + + def do_GET(self) -> None: + self._handle("GET") + + def do_POST(self) -> None: + self._handle("POST") + + def log_message(self, format: str, *args: Any) -> None: + return + + def _handle(self, method: str) -> None: + parsed = urlparse(self.path) + try: + response, status_code = self._route(method, parsed.path) + except HttpProblem as exc: + response = _error_response(exc.message, exc.__class__.__name__, exc.status_code) + status_code = exc.status_code + except GuideBoardError as exc: + response = _error_response(str(exc), exc.__class__.__name__, 400) + status_code = 400 + except (OSError, ValueError, json.JSONDecodeError) as exc: + response = _error_response(str(exc), exc.__class__.__name__, 400) + status_code = 400 + except Exception as exc: + response = _error_response(str(exc), exc.__class__.__name__, 500) + status_code = 500 + + self._send_json(status_code, response) + + def _route(self, method: str, path: str) -> tuple[dict[str, Any], int]: + if method == "GET" and path == "/health": + return self._health(), 200 + if method == "GET" and path == "/extensions": + return self._extensions(), 200 + if method == "POST" and path == "/profiles/validate": + return self._validate_profile(), 200 + if method == "POST" and path == "/assessments/plan": + return self._plan_assessment(), 200 + if method == "GET" and path == "/runs": + return {"runs": self.server.context.jobs.list()}, 200 + if method == "POST" and path == "/runs": + return self._start_run(), 202 + + run_match = _match_run_path(path) + if method == "GET" and run_match is not None: + job_id, suffix = run_match + if suffix is None: + return self.server.context.jobs.get(job_id), 200 + if suffix == "reports": + return self._run_reports(job_id), 200 + + raise HttpProblem(404, f"endpoint not found: {method} {path}") + + def _health(self) -> dict[str, Any]: + return { + "status": "ok", + "service": "guide-board", + "root": str(self.server.context.root), + "jobs": self.server.context.jobs.summary(), + } + + def _extensions(self) -> dict[str, Any]: + root = self.server.context.root + return { + "extensions": [ + { + "id": extension.id, + "name": extension.manifest["name"], + "version": extension.manifest["version"], + "type": extension.manifest["extension_type"], + "path": _display_path(root, extension.path), + "source": extension.source, + } + for extension in discover_extensions(root, self.server.context.extension_dirs) + ] + } + + def _validate_profile(self) -> dict[str, Any]: + payload = self._read_payload() + kind = _required_string(payload, "kind") + path = _path_from_payload(self.server.context.root, payload, "path") + if kind == "target": + profile = validate_target_profile(path) + return { + "status": "valid", + "profile_kind": "target", + "profile_id": profile["id"], + "path": str(path), + } + if kind == "assessment": + profile = validate_assessment_profile(path) + return { + "status": "valid", + "profile_kind": "assessment", + "profile_id": profile["id"], + "path": str(path), + } + raise HttpProblem(400, "kind must be 'target' or 'assessment'") + + def _plan_assessment(self) -> dict[str, Any]: + payload = self._read_payload() + target = _path_from_payload(self.server.context.root, payload, "target") + assessment = _path_from_payload(self.server.context.root, payload, "assessment") + extension_dirs = _extension_dirs_from_payload(self.server.context, payload) + return build_run_plan(self.server.context.root, target, assessment, extension_dirs) + + def _start_run(self) -> dict[str, Any]: + payload = self._read_payload() + target = _path_from_payload(self.server.context.root, payload, "target") + assessment = _path_from_payload(self.server.context.root, payload, "assessment") + output_dir = _optional_path_from_payload(self.server.context.root, payload, "output_dir") + extension_dirs = _extension_dirs_from_payload(self.server.context, payload) + request = { + "target": str(target), + "assessment": str(assessment), + "output_dir": str(output_dir) if output_dir is not None else None, + "extension_dirs": [str(path) for path in extension_dirs], + } + job = self.server.context.jobs.create(request) + thread = threading.Thread( + target=_execute_run_job, + args=( + self.server.context, + job["job_id"], + target, + assessment, + output_dir, + extension_dirs, + ), + daemon=True, + ) + thread.start() + return job + + def _run_reports(self, job_id: str) -> dict[str, Any]: + job = self.server.context.jobs.get(job_id) + result = job.get("result") + if job["status"] != "succeeded" or not isinstance(result, dict): + raise HttpProblem(409, f"reports are not available for job status {job['status']}") + + report_path = Path(result["report"]) + package_path = Path(result["assessment_package"]) + retention_path = Path(result["retention_summary"]) + try: + report_markdown = report_path.read_text(encoding="utf-8") + assessment_package = load_json(package_path) + retention_summary = load_json(retention_path) + except OSError as exc: + raise HttpProblem(404, f"run report artifact is missing: {exc}") from exc + + return { + "job_id": job_id, + "run_id": result["run_id"], + "status": job["status"], + "assessment_status": result["status"], + "paths": { + "report": str(report_path), + "assessment_package": str(package_path), + "retention_summary": str(retention_path), + }, + "report": { + "path": str(report_path), + "markdown": report_markdown, + }, + "assessment_package": { + "path": str(package_path), + "json": assessment_package, + }, + "retention_summary": { + "path": str(retention_path), + "json": retention_summary, + }, + } + + def _read_payload(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0") or "0") + if length == 0: + return {} + payload = json.loads(self.rfile.read(length).decode("utf-8")) + if not isinstance(payload, dict): + raise HttpProblem(400, "request body must be a JSON object") + return payload + + def _send_json(self, status_code: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + self.send_response(status_code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "http://127.0.0.1") + self.end_headers() + self.wfile.write(body) + + +def build_server( + root: Path, + extension_dirs: list[Path] | None = None, + host: str = "127.0.0.1", + port: int = 8080, +) -> GuideBoardHTTPServer: + server = GuideBoardHTTPServer((host, port), GuideBoardRequestHandler) + server.context = ServiceContext( + root=root.expanduser().resolve(), + extension_dirs=[path.expanduser().resolve() for path in extension_dirs or []], + jobs=JobStore(), + ) + return server + + +def start_service( + root: Path, + extension_dirs: list[Path] | None = None, + host: str = "127.0.0.1", + port: int = 8080, +) -> ServiceHandle: + server = build_server(root, extension_dirs, host, port) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return ServiceHandle(server=server, thread=thread) + + +def serve_forever( + root: Path, + extension_dirs: list[Path] | None = None, + host: str = "127.0.0.1", + port: int = 8080, +) -> None: + server = build_server(root, extension_dirs, host, port) + try: + server.serve_forever() + finally: + server.server_close() + + +def _execute_run_job( + context: ServiceContext, + job_id: str, + target: Path, + assessment: Path, + output_dir: Path | None, + extension_dirs: list[Path], +) -> None: + context.jobs.update(job_id, status="running", started_at=_now()) + try: + result = run_assessment(context.root, target, assessment, output_dir, extension_dirs) + except (GuideBoardError, OSError, ValueError) as exc: + context.jobs.update( + job_id, + status="failed", + completed_at=_now(), + error={"type": exc.__class__.__name__, "message": str(exc)}, + ) + except Exception as exc: + context.jobs.update( + job_id, + status="failed", + completed_at=_now(), + error={"type": exc.__class__.__name__, "message": str(exc)}, + ) + else: + context.jobs.update(job_id, status="succeeded", completed_at=_now(), result=result) + + +def _path_from_payload(root: Path, payload: dict[str, Any], field: str) -> Path: + return _resolve_path(root, _required_string(payload, field)) + + +def _optional_path_from_payload(root: Path, payload: dict[str, Any], field: str) -> Path | None: + value = payload.get(field) + if value is None: + return None + if not isinstance(value, str) or not value: + raise HttpProblem(400, f"{field} must be a non-empty string") + return _resolve_path(root, value) + + +def _extension_dirs_from_payload( + context: ServiceContext, + payload: dict[str, Any], +) -> list[Path]: + extension_dirs = list(context.extension_dirs) + value = payload.get("extension_dirs") + if value is None: + return extension_dirs + if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value): + raise HttpProblem(400, "extension_dirs must be a list of non-empty strings") + extension_dirs.extend(_resolve_path(context.root, item) for item in value) + return extension_dirs + + +def _required_string(payload: dict[str, Any], field: str) -> str: + value = payload.get(field) + if not isinstance(value, str) or not value: + raise HttpProblem(400, f"{field} must be a non-empty string") + return value + + +def _resolve_path(root: Path, value: str) -> Path: + path = Path(value).expanduser() + if not path.is_absolute(): + path = root / path + return path.resolve() + + +def _match_run_path(path: str) -> tuple[str, str | None] | None: + parts = [part for part in path.split("/") if part] + if len(parts) == 2 and parts[0] == "runs": + return parts[1], None + if len(parts) == 3 and parts[0] == "runs": + return parts[1], parts[2] + return None + + +def _display_path(root: Path, path: Path) -> str: + try: + return str(path.resolve().relative_to(root.resolve())) + except ValueError: + return str(path.resolve()) + + +def _error_response(message: str, error_type: str, status_code: int) -> dict[str, Any]: + return { + "error": { + "type": error_type, + "status": status_code, + "message": message, + } + } + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() diff --git a/tests/test_core.py b/tests/test_core.py index d0fdab1..4df3ebd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,13 +1,16 @@ from __future__ import annotations -import unittest +import http.client import json +import time +import unittest from tempfile import TemporaryDirectory from pathlib import Path from guide_board.discovery import discover_extensions from guide_board.execution import run_assessment from guide_board.gates import evaluate_trend_gates +from guide_board.io import load_json from guide_board.planning import ( build_run_plan, validate_assessment_profile, @@ -15,6 +18,7 @@ from guide_board.planning import ( ) from guide_board.retention import build_trend_summary, list_retained_runs from guide_board.schema import assert_valid +from guide_board.service import ServiceHandle, start_service ROOT = Path(__file__).resolve().parents[1] @@ -35,6 +39,15 @@ class CoreArchitectureTests(unittest.TestCase): self.assertEqual(target["id"], "sample-repository") self.assertEqual(assessment["target_profile_ref"], "sample-repository") + def test_validates_evidence_request_template(self) -> None: + request_set = load_json(ROOT / "extensions" / "_template" / "evidence-request-set.json") + + assert_valid(request_set, "evidence-request-set") + self.assertEqual( + request_set["source_boundary"]["certification_boundary"], + "This evidence request set supports assessment preparation only.", + ) + def test_builds_sample_run_plan(self) -> None: plan = build_run_plan( ROOT, @@ -164,6 +177,70 @@ class CoreArchitectureTests(unittest.TestCase): self.assertEqual(len(mappings), 1) self.assertEqual(mappings[0]["target_id"], "profile-readiness") + def test_serves_local_api_run_lifecycle(self) -> None: + with TemporaryDirectory() as temporary_directory: + service = start_service(ROOT, host="127.0.0.1", port=0) + try: + health = _request_json(service, "GET", "/health") + self.assertEqual(health["status"], "ok") + + extensions = _request_json(service, "GET", "/extensions") + self.assertIn( + "sample-noop", + [extension["id"] for extension in extensions["extensions"]], + ) + + target_validation = _request_json( + service, + "POST", + "/profiles/validate", + { + "kind": "target", + "path": "profiles/targets/sample-repository.json", + }, + ) + self.assertEqual(target_validation["profile_id"], "sample-repository") + + plan = _request_json( + service, + "POST", + "/assessments/plan", + { + "target": "profiles/targets/sample-repository.json", + "assessment": "profiles/assessments/sample-noop.json", + }, + ) + self.assertEqual(plan["target_profile_snapshot"]["id"], "sample-repository") + + job = _request_json( + service, + "POST", + "/runs", + { + "target": "profiles/targets/sample-repository.json", + "assessment": "profiles/assessments/sample-noop.json", + "output_dir": str(Path(temporary_directory) / "api-run"), + }, + expected_status=202, + ) + status = _wait_for_job(service, job["job_id"]) + + self.assertEqual(status["status"], "succeeded") + self.assertEqual(status["result"]["status"], "completed") + + reports = _request_json( + service, + "GET", + f"/runs/{job['job_id']}/reports", + ) + self.assertIn("Guide Board Assessment Report", reports["report"]["markdown"]) + self.assertEqual( + reports["assessment_package"]["json"]["run_id"], + status["result"]["run_id"], + ) + finally: + service.stop() + def test_builds_retained_run_trends(self) -> None: with TemporaryDirectory() as temporary_directory: runs_dir = Path(temporary_directory) @@ -293,6 +370,42 @@ def _write_retention_summary( ) +def _request_json( + service: ServiceHandle, + method: str, + path: str, + payload: dict[str, object] | None = None, + expected_status: int = 200, +) -> dict[str, object]: + connection = http.client.HTTPConnection(service.host, service.port, timeout=5) + body = None + headers = {} + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + try: + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + data = response.read().decode("utf-8") + finally: + connection.close() + if response.status != expected_status: + raise AssertionError(f"expected HTTP {expected_status}, got {response.status}: {data}") + value = json.loads(data) + if not isinstance(value, dict): + raise AssertionError(f"expected JSON object response, got {type(value).__name__}") + return value + + +def _wait_for_job(service: ServiceHandle, job_id: str) -> dict[str, object]: + for _ in range(50): + status = _request_json(service, "GET", f"/runs/{job_id}") + if status["status"] in {"succeeded", "failed"}: + return status + time.sleep(0.05) + raise AssertionError(f"job did not finish: {job_id}") + + def _write_external_extension(extension_dir: Path) -> None: extension_dir.mkdir(parents=True, exist_ok=True) (extension_dir / "extension.json").write_text( diff --git a/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md b/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md index f54ecd7..ef1a44f 100644 --- a/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md +++ b/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md @@ -221,7 +221,7 @@ Acceptance: ```task id: GUIDE-BOARD-WP-0001-T007 -status: in_progress +status: done priority: high state_hub_task_id: "455f92b0-1d2b-43d0-aa61-464d9dc83a62" ``` @@ -246,6 +246,8 @@ Progress: - The core now supports external extension repositories via `--extension-dir` and `GUIDE_BOARD_EXTENSION_PATHS`; `open-cmis-tck` has been split into its own extension repo. +- Split-repo guide-board CLI discovery, planning, and run smoke tests work with + `open-cmis-tck` as an external extension. ## D1.9 - Containerized Execution Design @@ -278,7 +280,7 @@ Progress: ```task id: GUIDE-BOARD-WP-0001-T009 -status: todo +status: done priority: medium state_hub_task_id: "58bd6ec6-2cf3-450f-95a7-b695aaf80609" ``` @@ -290,11 +292,21 @@ Acceptance: - Long-running jobs are tracked without blocking the API process. - CLI remains the source of truth for execution semantics. +Progress: + +- Added dependency-light local HTTP service module and `guide-board serve`. +- Added endpoints for health, extension listing, profile validation, planning, + asynchronous run jobs, job inspection, and report retrieval. +- Documented service and container modes in `docs/LOCAL-SERVICE-API.md` and + `docs/CONTAINER.md`. +- Added lifecycle tests that start the API, run the sample assessment as a + background job, and fetch generated reports. + ## D1.11 - Compliance Evidence Pack Strategy ```task id: GUIDE-BOARD-WP-0001-T010 -status: todo +status: done priority: medium state_hub_task_id: "2f845860-ade9-4d31-91c7-cb1c69dc4e1b" ``` @@ -308,6 +320,14 @@ Acceptance: - Provide a reviewable evidence-request and waiver model suitable for auditor collaboration. +Progress: + +- Added `docs/COMPLIANCE-EVIDENCE-PACKS.md`. +- Added `docs/schemas/evidence-request-set.schema.json`. +- Added `extensions/_template/evidence-request-set.json`. +- Documented evidence request sets in the extension SDK and architecture + blueprint. + ## Definition Of Done - The repository identity is `guide-board`.