http service with health, extension listing, profile validation, run planning, async run jobs, job inspection, and report retrieval

This commit is contained in:
2026-05-07 22:19:10 +02:00
parent 3ae6fd4140
commit a3ea11139c
12 changed files with 1028 additions and 13 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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
<extension-id>/
INTENT.md
extension.json
evidence-requests/
<request-set-id>.json
mappings/
<mapping-set-id>.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.

View File

@@ -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`.

View File

@@ -21,6 +21,7 @@ extensions/<extension-id>/
src/
docs/
schemas/
evidence-requests/
checks/
mappings/
profiles/
@@ -157,6 +158,32 @@ to extension-owned mappings and writes normalized mapping records to:
runs/<run-id>/normalized/mappings.json
```
## Evidence Request Sets
Procedural and hybrid compliance extensions may include evidence request sets
under:
```text
evidence-requests/<request-set-id>.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:

114
docs/LOCAL-SERVICE-API.md Normal file
View File

@@ -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.

View File

@@ -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" }
}
}
}
}
}

View File

@@ -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
}
}
]
}

View File

@@ -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 {

451
src/guide_board/service.py Normal file
View File

@@ -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()

View File

@@ -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(

View File

@@ -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`.