diff --git a/docs/LINUX_SERVER_DEPLOYMENT.md b/docs/LINUX_SERVER_DEPLOYMENT.md index ae5e77a..e0df364 100644 --- a/docs/LINUX_SERVER_DEPLOYMENT.md +++ b/docs/LINUX_SERVER_DEPLOYMENT.md @@ -172,6 +172,7 @@ The default compose deployment binds localhost only, uses runtime/log volumes, a ```bash python -m pip check python -m unittest discover -s tests -v +python scripts/qa/server_deployment_smoke.py cd frontend && npm ci && npm run test:ui && npm run build cd ../desktop/electron && npm ci && npm audit --omit=dev cd ../.. @@ -193,4 +194,3 @@ These checks do not require root, Docker daemon access, live capture, Nginx, Cad - Nginx/Caddy proxy issue: verify backend health on `127.0.0.1:8000`. - High CPU/RAM pressure: run the CI-safe benchmark and inspect `/api/monitoring/metrics`. - Benchmark degraded: treat it as sizing guidance, then lower capture/load or increase CPU/RAM. - diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 90472ed..ae0962d 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -102,6 +102,8 @@ Use this checklist before tagging or publishing a release candidate. `python benchmarks/soak_test_pipeline.py --duration-sec 10 --events-per-sec 200 --flows 20 --ci-safe --output .runtime/benchmarks/release-hardening-smoke` - [ ] Run the CI-safe real-load benchmark: `python benchmarks/long_soak_runner.py --profile light_desktop --duration-sec 5 --events-per-sec 100 --flows 10 --websocket-clients 1 --sample-interval-sec 0.25 --max-cpu-avg-percent 100 --max-cpu-peak-percent 1000 --ci-safe --output .runtime/benchmarks/release-real-load-smoke` +- [ ] Run server deployment static smoke: + `python scripts/qa/server_deployment_smoke.py` - [ ] For release candidates, run at least one manual local profile from `docs/REAL_LOAD_TESTING.md` on representative hardware. - [ ] Confirm bounded queues and buffers remain visible in Ops Snapshot. diff --git a/scripts/qa/server_deployment_smoke.py b/scripts/qa/server_deployment_smoke.py new file mode 100644 index 0000000..6bcf631 --- /dev/null +++ b/scripts/qa/server_deployment_smoke.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +REQUIRED_TEXT_CHECKS = ( + ("systemd_service_user", "deploy/systemd/netbotpro.service", "User=netbotpro"), + ( + "systemd_environment_file", + "deploy/systemd/netbotpro.service", + "EnvironmentFile=/etc/netbotpro/netbotpro.env", + ), + ( + "server_profile_env", + "deploy/systemd/netbotpro.env.example", + "NETBOT_PROFILE=server", + ), + ( + "live_capture_disabled_by_default", + "deploy/systemd/netbotpro.env.example", + "NETBOT_ENABLE_LIVE_CAPTURE=false", + ), + ("docker_non_root_user", "Dockerfile", "USER netbotpro"), + ("docker_healthcheck", "Dockerfile", "HEALTHCHECK"), + ("compose_localhost_bind", "docker-compose.yml", '"127.0.0.1:8000:8000"'), + ( + "linux_docs_safe_boundary", + "docs/LINUX_SERVER_DEPLOYMENT.md", + "not command/control", + ), +) + + +def _read(relative_path: str) -> str: + return (REPO_ROOT / relative_path).read_text(encoding="utf-8") + + +def run_checks() -> dict[str, Any]: + results: list[dict[str, Any]] = [] + for name, relative_path, expected in REQUIRED_TEXT_CHECKS: + content = _read(relative_path) + results.append({"name": name, "file": relative_path, "ok": expected in content}) + + systemd = _read("deploy/systemd/netbotpro.service") + results.append( + { + "name": "systemd_not_root", + "file": "deploy/systemd/netbotpro.service", + "ok": "User=root" not in systemd, + } + ) + + compose = _read("docker-compose.yml").lower() + results.append( + { + "name": "compose_not_privileged", + "file": "docker-compose.yml", + "ok": "privileged: true" not in compose, + } + ) + + dockerignore = _read(".dockerignore") + for ignored in (".runtime", "node_modules", "*.pcap", "*.pcapng", ".env"): + results.append( + { + "name": f"dockerignore_{ignored}", + "file": ".dockerignore", + "ok": ignored in dockerignore, + } + ) + + failed = [item for item in results if not item["ok"]] + return { + "ok": not failed, + "checks_total": len(results), + "checks_failed": len(failed), + "failed": failed, + } + + +def main() -> int: + result = run_checks() + print(json.dumps(result, indent=2, ensure_ascii=True)) + return 0 if result["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_server_deployment_smoke.py b/tests/test_server_deployment_smoke.py new file mode 100644 index 0000000..3ad01b6 --- /dev/null +++ b/tests/test_server_deployment_smoke.py @@ -0,0 +1,16 @@ +import unittest + +from scripts.qa.server_deployment_smoke import run_checks + + +class ServerDeploymentSmokeTests(unittest.TestCase): + def test_server_deployment_smoke_checks_pass(self): + result = run_checks() + + self.assertTrue(result["ok"], result["failed"]) + self.assertGreaterEqual(result["checks_total"], 10) + self.assertEqual(result["checks_failed"], 0) + + +if __name__ == "__main__": + unittest.main()