diff --git a/README.md b/README.md index f37a672..84fd5a7 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,12 @@ test "$(docker ps --filter name=^/ai-ict$ --format '{{.Names}}' | wc -l | tr -d test "$(docker inspect -f '{{.State.Health.Status}}' ai-ict)" = "healthy" ``` +- Offline local demo readiness check, which does not invoke Docker: + +```bash +.venv/bin/python scripts/check_local_demo_readiness.py +``` + ## Runtime utility scripts - `python3 scripts/run_dev.py` - run the scaffold entrypoint diff --git a/docs/training_case_engine_m1_m6_product_readme.md b/docs/training_case_engine_m1_m6_product_readme.md index 9dee580..faefb9d 100644 --- a/docs/training_case_engine_m1_m6_product_readme.md +++ b/docs/training_case_engine_m1_m6_product_readme.md @@ -45,6 +45,12 @@ test "$(docker ps --filter name=^/ai-ict$ --format '{{.Names}}' | wc -l | tr -d test "$(docker inspect -f '{{.State.Health.Status}}' ai-ict)" = "healthy" ``` +Offline local demo readiness check, which does not invoke Docker: + +```bash +.venv/bin/python scripts/check_local_demo_readiness.py +``` + ## API Surface All endpoints use JSON responses. List endpoints accept `limit` with an upper bound of `100`. diff --git a/scripts/check_local_demo_readiness.py b/scripts/check_local_demo_readiness.py new file mode 100644 index 0000000..9b888fd --- /dev/null +++ b/scripts/check_local_demo_readiness.py @@ -0,0 +1,129 @@ +from dataclasses import dataclass +from pathlib import Path +import re +import sys + + +ROOT = Path(__file__).resolve().parents[1] +LOCAL_DEMO_DEPLOY_DIR = "/root/deploy/ai-ict" +LOCAL_DEMO_COMPOSE_PROJECT = "ai-ict" +LOCAL_DEMO_CONTAINER = "ai-ict" +LOCAL_DEMO_COMPOSE_FILE = f"{LOCAL_DEMO_DEPLOY_DIR}/docker-compose.yml" +LOCAL_DEMO_COMPOSE_PREFIX = ( + f"docker compose -p {LOCAL_DEMO_COMPOSE_PROJECT} -f {LOCAL_DEMO_COMPOSE_FILE}" +) +LOCAL_DEMO_DOCS = ( + ROOT / "README.md", + ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md", +) +LOCAL_DEMO_REBUILD_COMMANDS = ( + f"{LOCAL_DEMO_COMPOSE_PREFIX} down --remove-orphans", + f"{LOCAL_DEMO_COMPOSE_PREFIX} up -d --build", +) +LOCAL_DEMO_SMOKE_COMMANDS = ( + f"{LOCAL_DEMO_COMPOSE_PREFIX} ps", + "test \"$(docker ps --filter label=com.docker.compose.project=ai-ict --format '{{.Names}}' | wc -l | tr -d ' ')\" = \"1\"", + "test \"$(docker ps --filter name=^/ai-ict$ --format '{{.Names}}' | wc -l | tr -d ' ')\" = \"1\"", + "test \"$(docker inspect -f '{{.State.Health.Status}}' ai-ict)\" = \"healthy\"", +) +LOCAL_DEMO_REQUIRED_SNIPPETS = ( + "single-set local Docker deployment", + f"deploy directory `{LOCAL_DEMO_DEPLOY_DIR}`", + f"compose project `{LOCAL_DEMO_COMPOSE_PROJECT}`", + f"container `{LOCAL_DEMO_CONTAINER}`", + "exactly one active deployment set", + f"one active `{LOCAL_DEMO_CONTAINER}` container", + "do not create a second local stack", +) + + +@dataclass(frozen=True) +class LocalDemoReadinessReport: + checked_docs: tuple[Path, ...] + command_lines_by_doc: tuple[tuple[Path, tuple[str, ...]], ...] + errors: tuple[str, ...] + + +def check_local_demo_readiness( + docs: tuple[Path, ...] = LOCAL_DEMO_DOCS, +) -> LocalDemoReadinessReport: + expected_commands = LOCAL_DEMO_REBUILD_COMMANDS + LOCAL_DEMO_SMOKE_COMMANDS + command_lines_by_doc = [] + errors = [] + + for path in docs: + if not path.is_file(): + command_lines_by_doc.append((path, ())) + errors.append(f"{_display_path(path)} is missing") + continue + + text = path.read_text(encoding="utf-8") + try: + section = _local_docker_deployment_section(text) + except ValueError as exc: + command_lines_by_doc.append((path, ())) + errors.append(f"{_display_path(path)} {exc}") + continue + + command_lines = _local_docker_command_lines(section) + command_lines_by_doc.append((path, command_lines)) + + for snippet in LOCAL_DEMO_REQUIRED_SNIPPETS: + if snippet not in section: + errors.append(f"{_display_path(path)} missing local demo snippet: {snippet}") + if command_lines != expected_commands: + errors.append( + f"{_display_path(path)} local demo commands drifted from the locked single-set shape" + ) + + return LocalDemoReadinessReport( + checked_docs=tuple(docs), + command_lines_by_doc=tuple(command_lines_by_doc), + errors=tuple(errors), + ) + + +def main() -> int: + report = check_local_demo_readiness() + if report.errors: + print("Local demo readiness check failed") + for error in report.errors: + print(f"- {error}") + return 1 + + command_count = len(LOCAL_DEMO_REBUILD_COMMANDS) + len(LOCAL_DEMO_SMOKE_COMMANDS) + print( + "Local demo readiness check passed: " + f"{len(report.checked_docs)} docs, {command_count} locked commands." + ) + return 0 + + +def _local_docker_deployment_section(text: str) -> str: + match = re.search( + r"^## Local Docker [Dd]eployment\n(?P
.*?)(?:\n## |\Z)", + text, + re.DOTALL | re.MULTILINE, + ) + if match is None: + raise ValueError("is missing the Local Docker deployment section") + return match.group("section") + + +def _local_docker_command_lines(text: str) -> tuple[str, ...]: + return tuple( + line.strip() + for line in text.splitlines() + if line.strip().startswith(("docker ", "test ")) + ) + + +def _display_path(path: Path) -> str: + try: + return str(path.relative_to(ROOT)) + except ValueError: + return str(path) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_local_demo_readiness.py b/tests/unit/test_local_demo_readiness.py new file mode 100644 index 0000000..0415081 --- /dev/null +++ b/tests/unit/test_local_demo_readiness.py @@ -0,0 +1,89 @@ +import ast +import subprocess +import sys +import unittest +from pathlib import Path + +from scripts import check_local_demo_readiness as readiness +from scripts import run_offline_checks + + +ROOT = Path(__file__).resolve().parents[2] +CHECK_COMMAND = ".venv/bin/python scripts/check_local_demo_readiness.py" +EXPECTED_DOC_PATHS = ( + ROOT / "README.md", + ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md", +) +EXPECTED_REBUILD_COMMANDS = ( + "docker compose -p ai-ict -f /root/deploy/ai-ict/docker-compose.yml down --remove-orphans", + "docker compose -p ai-ict -f /root/deploy/ai-ict/docker-compose.yml up -d --build", +) +EXPECTED_SMOKE_COMMANDS = ( + "docker compose -p ai-ict -f /root/deploy/ai-ict/docker-compose.yml ps", + "test \"$(docker ps --filter label=com.docker.compose.project=ai-ict --format '{{.Names}}' | wc -l | tr -d ' ')\" = \"1\"", + "test \"$(docker ps --filter name=^/ai-ict$ --format '{{.Names}}' | wc -l | tr -d ' ')\" = \"1\"", + "test \"$(docker inspect -f '{{.State.Health.Status}}' ai-ict)\" = \"healthy\"", +) + + +class LocalDemoReadinessTests(unittest.TestCase): + def test_checker_locks_single_set_local_demo_constants(self) -> None: + self.assertEqual(readiness.LOCAL_DEMO_DEPLOY_DIR, "/root/deploy/ai-ict") + self.assertEqual(readiness.LOCAL_DEMO_COMPOSE_PROJECT, "ai-ict") + self.assertEqual(readiness.LOCAL_DEMO_CONTAINER, "ai-ict") + self.assertEqual(readiness.LOCAL_DEMO_DOCS, EXPECTED_DOC_PATHS) + self.assertEqual(readiness.LOCAL_DEMO_REBUILD_COMMANDS, EXPECTED_REBUILD_COMMANDS) + self.assertEqual(readiness.LOCAL_DEMO_SMOKE_COMMANDS, EXPECTED_SMOKE_COMMANDS) + + def test_checker_verifies_documented_demo_smoke_commands(self) -> None: + report = readiness.check_local_demo_readiness() + + self.assertEqual(report.errors, ()) + self.assertEqual(report.checked_docs, EXPECTED_DOC_PATHS) + for path, command_lines in report.command_lines_by_doc: + with self.subTest(path=path.name): + self.assertEqual(command_lines, EXPECTED_REBUILD_COMMANDS + EXPECTED_SMOKE_COMMANDS) + + def test_checker_command_is_documented_and_offline(self) -> None: + for path in EXPECTED_DOC_PATHS: + text = path.read_text(encoding="utf-8") + with self.subTest(path=path.name): + self.assertIn(CHECK_COMMAND, text) + self.assertIn("does not invoke Docker", text) + + process = subprocess.run( + [sys.executable, "scripts/check_local_demo_readiness.py"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + self.assertEqual(process.returncode, 0, process.stderr) + self.assertIn("Local demo readiness check passed", process.stdout) + + def test_checker_source_has_no_process_launches(self) -> None: + source = Path(readiness.__file__).read_text(encoding="utf-8") + tree = ast.parse(source) + imported_modules = { + alias.name + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + called_attributes = { + node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute) + } + + self.assertNotIn("subprocess", imported_modules) + self.assertNotIn("system", called_attributes) + + def test_offline_checks_discover_local_demo_readiness_tests(self) -> None: + check_names = tuple(name for name, _command in run_offline_checks.build_check_commands(env={})) + + self.assertIn("unit_tests", check_names) + self.assertTrue((ROOT / "tests" / "unit" / "test_local_demo_readiness.py").is_file()) + + +if __name__ == "__main__": + unittest.main()