test: add local demo readiness smoke guard
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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<section>.*?)(?:\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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user