diff --git a/.env.example b/.env.example index b610c2b..248ebb8 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ FACTORY_SOURCE_MODE=external_demo_factory FACTORY_CONNECTOR_MODE=read_only NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 FIP_API_URL=http://localhost:8000 +FIP_PUBLIC_API_URL=http://localhost:8000 +FIP_API_PORT=8000 +FIP_WEB_PORT=3000 FACTORY_CONNECTION_PROFILES_STORE=.local/storage/connection_profiles.json DEMO_FACTORY_HOST=host.docker.internal DEMO_FACTORY_OPCUA_ENDPOINT=opc.tcp://host.docker.internal:4840/freeopcua/server/ diff --git a/Makefile b/Makefile index eb8e1cb..5ddc919 100644 --- a/Makefile +++ b/Makefile @@ -23,10 +23,14 @@ OPCUA_POLL_COUNT ?= 6 OPCUA_POLL_INTERVAL ?= 1 OPCUA_EVENTS_STORE ?= .local/storage/opcua_demo_events.jsonl COMPOSE_FILE ?= infra/docker/docker-compose.yml +COMPOSE_SMOKE_API_PORT ?= 18000 +COMPOSE_SMOKE_WEB_PORT ?= 13000 +COMPOSE_SMOKE_API_URL ?= http://localhost:$(COMPOSE_SMOKE_API_PORT) +COMPOSE_SMOKE_WEB_URL ?= http://localhost:$(COMPOSE_SMOKE_WEB_PORT) PYTHONPATH := packages/factory-events:services/simulator:services/ingestion:services/process-sentinel:services/api export PYTHONPATH -.PHONY: help setup dev dev-db compose-up compose-down compose-ps compose-logs compose-config simulate ingest opcua-demo-ingest sentinel-run demo demo-reset demo-data demo-ingest demo-sentinel-run demo-api-smoke api api-reload test test-unit test-integration test-contract test-e2e lint typecheck docs +.PHONY: help setup dev dev-db compose-up compose-down compose-ps compose-logs compose-config compose-smoke simulate ingest opcua-demo-ingest sentinel-run demo demo-reset demo-data demo-ingest demo-sentinel-run demo-api-smoke api api-reload test test-unit test-integration test-contract test-e2e lint typecheck docs help: @echo "Factory Intelligence Platform" @@ -39,6 +43,7 @@ help: @echo " make compose-ps List FIP Compose services" @echo " make compose-logs Follow FIP Compose logs" @echo " make compose-config Validate the FIP Compose file" + @echo " make compose-smoke Smoke test FIP Compose against Demo-Factory" @echo " make simulate Generate simulator JSONL events" @echo " make ingest Validate and ingest simulator events" @echo " make opcua-demo-ingest Poll the local demo OPC UA server into FactoryEvents" @@ -91,6 +96,9 @@ compose-logs: compose-config: docker compose -f $(COMPOSE_FILE) config +compose-smoke: + FIP_API_PORT=$(COMPOSE_SMOKE_API_PORT) FIP_WEB_PORT=$(COMPOSE_SMOKE_WEB_PORT) FIP_PUBLIC_API_URL=$(COMPOSE_SMOKE_API_URL) $(PYTHON) -m factory_api.compose_smoke --compose-file $(COMPOSE_FILE) --api-url $(COMPOSE_SMOKE_API_URL) --web-url $(COMPOSE_SMOKE_WEB_URL) + simulate: $(PYTHON) -m factory_simulator.cli --scenario $(SCENARIO) --seed $(SEED) $(if $(DURATION_MINUTES),--duration-minutes $(DURATION_MINUTES),--count $(COUNT)) --output $(OUTPUT) diff --git a/README.md b/README.md index 6d29965..3dbadf7 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,15 @@ curl http://localhost:8000/health make compose-down ``` +Smoke test the composed runtime before opening Docker runtime PRs: + +```bash +make compose-smoke +``` + +The smoke target uses FIP host ports `18000` and `13000` so it can run while +the full Demo-Factory stack owns ports `8000` and `3000`. + The wrappers call `docker compose -f infra/docker/docker-compose.yml ...`. The equivalent direct commands are: @@ -171,6 +180,7 @@ Run these before opening a pull request: ```bash docker compose -f infra/docker/docker-compose.yml config make compose-config +make compose-smoke make lint make typecheck make test diff --git a/apps/web/README.md b/apps/web/README.md index 3076958..29be1fe 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -68,6 +68,17 @@ curl http://localhost:8000/health docker compose -f infra/docker/docker-compose.yml down ``` +Before opening a runtime PR, use the composed smoke path to verify that the API +and Workbench render against the Docker stack: + +```bash +make compose-smoke +``` + +The smoke target uses `http://localhost:18000` for the FIP API and +`http://localhost:13000` for the FIP Workbench so it can run beside the full +Demo-Factory stack. + The Workbench expects the API health endpoint to describe the active runtime with `source_mode`, `storage_backend`, and `connector_mode`. In the Docker path, that means external Demo-Factory/local protocol sources, Postgres-backed state, diff --git a/docs/LEARNING_LOG.md b/docs/LEARNING_LOG.md index 875bd01..c4f373e 100644 --- a/docs/LEARNING_LOG.md +++ b/docs/LEARNING_LOG.md @@ -5058,3 +5058,53 @@ make typecheck Run the full Compose smoke path from issue #241 and confirm the visible Workbench recovery copy matches the real failure modes for Demo-Factory, connector ingestion, Process Sentinel, API, and web startup. + +## 2026-05-27 - Compose smoke test + +### What changed + +Added the issue #241 Compose smoke path. `make compose-smoke` now starts or +verifies the FIP Docker Compose stack, checks expected services, validates API +runtime metadata, confirms connector-ingested FactoryEvents, verifies readable +Process Sentinel state, and confirms the Workbench renders. The smoke command +uses FIP host ports `18000` and `13000` so it can run beside the full +Demo-Factory stack. + +### Why it was built that way + +The smoke path is a small Python helper under the existing API service package +so it can reuse the repo-local Python environment and Makefile conventions +without adding dependencies. It assumes Demo-Factory is already running as the +external local source fixture and keeps all checks read-only. + +### How data flows through it + +Demo-Factory protocol sources are consumed by the FIP read-only connector +worker through checked-in connection fixtures. Events are written to the +configured Postgres event store, Process Sentinel reads those events and writes +state, the API exposes health/events/detections, and the smoke helper verifies +the Workbench can render against the composed API. + +### How to run it + +```bash +cd ../Demo-Factory +docker compose up -d --build +cd ../Factory-Intelligence-Platform +make compose-smoke +``` + +### How to test it + +```bash +.venv/bin/python -m pytest services/api/tests/test_compose_smoke.py +make test +make lint +make typecheck +``` + +### What to learn next + +Run the smoke path with Demo-Factory intentionally stopped once and compare the +failure output against the runtime troubleshooting guide. Tighten any recovery +copy that does not lead directly to the missing source, service, or log output. diff --git a/docs/TESTING.md b/docs/TESTING.md index 2d708ad..915a9a9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -69,22 +69,31 @@ make test-integration make test-contract make test-e2e make demo-api-smoke +make compose-smoke make lint make typecheck ``` `make test-e2e` runs the local Operations Workbench Playwright smoke test. The -current test still exercises the direct local API and Workbench path while the -Dockerized runtime smoke path is being introduced under #241. +current test still exercises the direct local API and Workbench path. Use +`make compose-smoke` for the Dockerized Demo-Factory plus FIP runtime path. The Playwright smoke test is local-only for now. CI integration is intentionally tracked separately in #165 so this issue can keep the first browser workflow reliable before hardening it for GitHub Actions. `make demo-api-smoke` is a legacy backend-only smoke test for generated local -state. It is retained for compatibility while the Compose smoke path is being -introduced. Prefer the Docker/Demo-Factory runtime path for new documentation -and issue work. +state. It is retained for compatibility. Prefer the Docker/Demo-Factory runtime +path for new documentation and issue work. + +`make compose-smoke` starts or verifies the FIP Compose stack after +Demo-Factory is already running separately. It checks expected services, API +runtime metadata, connector-ingested FactoryEvents, readable Process Sentinel +state, and Workbench rendering. + +The smoke target uses `http://localhost:18000` for the FIP API and +`http://localhost:13000` for the FIP Workbench so it can run alongside the full +Demo-Factory stack, which may use ports `8000` and `3000`. ## Docker Compose Runtime Checks @@ -115,6 +124,15 @@ curl http://localhost:8000/health docker compose -f infra/docker/docker-compose.yml down ``` +Run the composed smoke path before opening Docker runtime PRs: + +```bash +make compose-smoke +``` + +If no FactoryEvents are visible through the API, the smoke command exits +non-zero with missing-source recovery guidance and Compose log snippets. + Use `docker compose down` for normal shutdown. Use `docker compose -f infra/docker/docker-compose.yml down -v` only when a test or manual reset intentionally deletes FIP named volumes. diff --git a/docs/runtime/DOCKER_COMPOSE.md b/docs/runtime/DOCKER_COMPOSE.md index 51a699e..e9501c8 100644 --- a/docs/runtime/DOCKER_COMPOSE.md +++ b/docs/runtime/DOCKER_COMPOSE.md @@ -18,9 +18,8 @@ Demo-Factory protocols The Dockerized runtime is being built across epic #232. The FIP Compose stack now defines the API, Workbench, connector worker, Process Sentinel worker, and -Postgres service shape. Follow-up issues continue to harden the connector -runtime, scheduled Sentinel behavior, API/Workbench wording, and Compose smoke -tests. +Postgres service shape. `make compose-smoke` provides the current local +end-to-end smoke path for this composed runtime. ## Dependency Order @@ -49,7 +48,9 @@ The default Compose services use explicit runtime variables: | --- | --- | --- | | `DATABASE_URL` | `postgresql://postgres:postgres@postgres:5432/factory_intelligence` | API, connector worker, Sentinel worker | | `FACTORY_CONNECTION_PROFILES_STORE` | `.local/storage/connection_profiles.json` | API and connector profile loading | -| `NEXT_PUBLIC_API_BASE_URL` | `http://localhost:8000` | Workbench browser bundle | +| `FIP_API_PORT` | `8000` | Host port for the FIP API | +| `FIP_WEB_PORT` | `3000` | Host port for the FIP Workbench | +| `FIP_PUBLIC_API_URL` | `http://localhost:8000` | Workbench browser bundle API target | | `FIP_API_URL` | `http://api:8000` | Container-to-container API target | | `DEMO_FACTORY_HOST` | `host.docker.internal` | Connector worker source host note | | `DEMO_FACTORY_OPCUA_ENDPOINT` | `opc.tcp://host.docker.internal:4840/freeopcua/server/` | Demo-Factory OPC-UA source endpoint | @@ -138,6 +139,55 @@ make compose-config make compose-down ``` +## Compose Smoke Test + +Before opening a PR that touches the Docker runtime, run the Compose smoke test +with Demo-Factory already running separately: + +```bash +cd ../Demo-Factory +docker compose up -d --build + +cd ../Factory-Intelligence-Platform +make compose-smoke +``` + +The smoke target uses host ports `18000` for the FIP API and `13000` for the +FIP Workbench so it can run while the full Demo-Factory stack owns ports `8000` +and `3000`. Override `COMPOSE_SMOKE_API_PORT` and `COMPOSE_SMOKE_WEB_PORT` if +those ports are busy. + +The smoke command starts or verifies the FIP Compose stack with the checked-in +Demo-Factory connection profile fixtures, short connector/Sentinel polling +intervals, and Postgres-backed runtime state. It verifies: + +- `docker compose -f infra/docker/docker-compose.yml ps` includes `postgres`, + `api`, `web`, `connector-worker`, and `sentinel-worker`. +- `GET http://localhost:18000/health` returns healthy runtime metadata, + including `source_mode`, `storage_backend`, and `connector_mode`. +- Connector ingestion has produced FactoryEvents through the composed API, and + `connector-worker` logs show emitted events during the smoke run. +- Process Sentinel detection state is readable. No detections is an acceptable + state when the external-source data has not crossed an advisory rule; inspect + `sentinel-worker` logs if detections were expected. +- The Workbench renders at `http://localhost:3000`. + +If no FactoryEvents are available, or if the event store appears to contain +stale data without current connector emissions, the smoke command exits +non-zero with missing-source guidance and prints useful `docker compose ps`, +API, connector-worker, sentinel-worker, and web log output. Start with these +recovery commands: + +```bash +cd ../Demo-Factory +docker compose ps + +cd ../Factory-Intelligence-Platform +docker compose -f infra/docker/docker-compose.yml ps +docker compose -f infra/docker/docker-compose.yml logs --tail=100 connector-worker +curl http://localhost:18000/health +``` + ## Load Demo-Factory Connection Fixtures Checked-in read-only Demo-Factory connection profile fixtures live in: diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 75929b9..86faaf4 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -43,7 +43,7 @@ services: - --port - "8000" ports: - - "8000:8000" + - "${FIP_API_PORT:-8000}:8000" healthcheck: test: [ @@ -61,15 +61,15 @@ services: context: ../../apps/web dockerfile: Dockerfile args: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000} + NEXT_PUBLIC_API_BASE_URL: ${FIP_PUBLIC_API_URL:-http://localhost:8000} environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000} + NEXT_PUBLIC_API_BASE_URL: ${FIP_PUBLIC_API_URL:-http://localhost:8000} FIP_API_URL: http://api:8000 depends_on: api: condition: service_healthy ports: - - "3000:3000" + - "${FIP_WEB_PORT:-3000}:3000" healthcheck: test: [ diff --git a/services/api/factory_api/compose_smoke.py b/services/api/factory_api/compose_smoke.py new file mode 100644 index 0000000..16e92f0 --- /dev/null +++ b/services/api/factory_api/compose_smoke.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +EXPECTED_SERVICES = ( + "postgres", + "api", + "web", + "connector-worker", + "sentinel-worker", +) +DEFAULT_COMPOSE_FILE = Path("infra/docker/docker-compose.yml") +DEFAULT_API_URL = "http://localhost:18000" +DEFAULT_WEB_URL = "http://localhost:13000" +DEFAULT_TIMEOUT_SECONDS = 120.0 +DEFAULT_POLL_INTERVAL_SECONDS = 5.0 +DEFAULT_CONNECTION_PROFILE_STORE = ( + "/app/packages/test-fixtures/demo-factory-connection-profiles/connection_profiles.json" +) + + +class ComposeSmokeError(Exception): + """Raised when the Compose smoke path cannot prove the runtime is healthy.""" + + +@dataclass(frozen=True) +class ComposeSmokeConfig: + compose_file: Path = DEFAULT_COMPOSE_FILE + api_url: str = DEFAULT_API_URL + web_url: str = DEFAULT_WEB_URL + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS + poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS + start_stack: bool = True + build_images: bool = True + connection_profiles_store: str = DEFAULT_CONNECTION_PROFILE_STORE + expected_services: tuple[str, ...] = EXPECTED_SERVICES + environment: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ComposeSmokeResult: + services: tuple[str, ...] + health: dict[str, Any] + event_count: int + detection_count: int + web_status: int + + +CommandRunner = Callable[[Sequence[str], dict[str, str] | None], str] +HttpJsonGetter = Callable[[str], Any] +HttpTextGetter = Callable[[str], tuple[int, str]] + + +def run_smoke( + config: ComposeSmokeConfig, + *, + command_runner: CommandRunner | None = None, + get_json: HttpJsonGetter | None = None, + get_text: HttpTextGetter | None = None, +) -> ComposeSmokeResult: + runner = command_runner or _run_command + json_getter = get_json or _get_json + text_getter = get_text or _get_text + compose_env = build_compose_environment(config) + + if config.start_stack: + up_command = _compose_command(config, "up", "-d") + if config.build_images: + up_command.append("--build") + runner(up_command, compose_env) + + ps_output = runner(_compose_command(config, "ps"), compose_env) + print("FIP Compose services:") + print(ps_output.strip()) + + services = tuple( + _parse_services(runner(_compose_command(config, "ps", "--services"), compose_env)) + ) + missing_services = sorted(set(config.expected_services) - set(services)) + if missing_services: + raise ComposeSmokeError( + "FIP Compose stack is missing expected services: " + f"{', '.join(missing_services)}.\n" + "Run: docker compose -f infra/docker/docker-compose.yml ps" + ) + + health = _wait_for( + description="API health", + timeout_seconds=config.timeout_seconds, + poll_interval_seconds=config.poll_interval_seconds, + probe=lambda: _healthy_runtime_metadata(json_getter(f"{config.api_url}/health")), + ) + print( + "API health ok: " + f"source_mode={health.get('source_mode')} " + f"storage_backend={health.get('storage_backend')} " + f"connector_mode={health.get('connector_mode')}" + ) + + events = _wait_for( + description="connector-ingested events", + timeout_seconds=config.timeout_seconds, + poll_interval_seconds=config.poll_interval_seconds, + probe=lambda: _non_empty_events(json_getter(f"{config.api_url}/events")), + failure_message=( + "No FactoryEvents were returned by the composed API. " + "Start Demo-Factory separately, confirm the connection profile fixture path, " + "then inspect connector logs:\n" + " cd ../Demo-Factory && docker compose ps\n" + " docker compose -f infra/docker/docker-compose.yml logs --tail=100 " + "connector-worker" + ), + ) + print(f"Connector ingestion ok: {len(events)} FactoryEvents available.") + + connector_logs = _wait_for( + description="connector worker emitted-event logs", + timeout_seconds=config.timeout_seconds, + poll_interval_seconds=config.poll_interval_seconds, + probe=lambda: _connector_emitted_events( + runner(_compose_command(config, "logs", "--tail=200", "connector-worker"), compose_env) + ), + failure_message=( + "The API has FactoryEvents, but connector-worker logs did not show " + "new emitted events during this smoke run. This can indicate stale " + "Postgres volume data or an unreachable Demo-Factory source.\n" + "Recovery:\n" + " cd ../Demo-Factory && docker compose ps\n" + " docker compose -f infra/docker/docker-compose.yml logs --tail=100 " + "connector-worker" + ), + ) + print(f"Connector worker emitted events in this run: {connector_logs.strip()}") + + detections = _wait_for( + description="Process Sentinel readable state", + timeout_seconds=config.timeout_seconds, + poll_interval_seconds=config.poll_interval_seconds, + probe=lambda: _readable_collection(json_getter(f"{config.api_url}/sentinel/detections")), + ) + if detections: + print(f"Process Sentinel state ok: {len(detections)} detections available.") + else: + print( + "Process Sentinel state readable: no detections yet. " + "This is acceptable when external-source data has not crossed an advisory rule. " + "If detections were expected, inspect sentinel-worker logs." + ) + + web_status, _web_body = _wait_for( + description="Workbench render", + timeout_seconds=config.timeout_seconds, + poll_interval_seconds=config.poll_interval_seconds, + probe=lambda: _rendered_workbench(text_getter(config.web_url)), + ) + print(f"Workbench render ok: HTTP {web_status} from {config.web_url}.") + + return ComposeSmokeResult( + services=services, + health=health, + event_count=len(events), + detection_count=len(detections), + web_status=web_status, + ) + + +def build_compose_environment(config: ComposeSmokeConfig) -> dict[str, str]: + env = os.environ.copy() + env.update(config.environment) + env.setdefault("FACTORY_CONNECTION_PROFILES_STORE", config.connection_profiles_store) + env.setdefault("CONNECTOR_POLL_INTERVAL_SECONDS", "5") + env.setdefault("CONNECTOR_INTERVAL_SECONDS", env["CONNECTOR_POLL_INTERVAL_SECONDS"]) + env.setdefault("SENTINEL_RUN_INTERVAL_SECONDS", "5") + env.setdefault("SENTINEL_INTERVAL_SECONDS", env["SENTINEL_RUN_INTERVAL_SECONDS"]) + env.setdefault("FACTORY_SOURCE_MODE", "external_demo_factory") + env.setdefault("FACTORY_CONNECTOR_MODE", "read_only") + env.setdefault("FIP_API_PORT", _port_from_url(config.api_url)) + env.setdefault("FIP_WEB_PORT", _port_from_url(config.web_url)) + env.setdefault("FIP_PUBLIC_API_URL", config.api_url) + return env + + +def parse_config(argv: Sequence[str] | None = None) -> ComposeSmokeConfig: + parser = argparse.ArgumentParser( + description=( + "Smoke test the composed FIP runtime. Start Demo-Factory separately " + "before running this command." + ) + ) + parser.add_argument("--compose-file", type=Path, default=DEFAULT_COMPOSE_FILE) + parser.add_argument("--api-url", default=DEFAULT_API_URL) + parser.add_argument("--web-url", default=DEFAULT_WEB_URL) + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SECONDS) + parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL_SECONDS) + parser.add_argument( + "--connection-profiles-store", + default=DEFAULT_CONNECTION_PROFILE_STORE, + help="Path visible inside FIP containers for Demo-Factory connection fixtures.", + ) + parser.add_argument( + "--no-start", + action="store_true", + help="Verify an already-running FIP Compose stack instead of starting it.", + ) + parser.add_argument( + "--no-build", + action="store_true", + help="Start the Compose stack without rebuilding images.", + ) + args = parser.parse_args(argv) + + return ComposeSmokeConfig( + compose_file=args.compose_file, + api_url=args.api_url.rstrip("/"), + web_url=args.web_url.rstrip("/"), + timeout_seconds=args.timeout, + poll_interval_seconds=args.poll_interval, + start_stack=not args.no_start, + build_images=not args.no_build, + connection_profiles_store=args.connection_profiles_store, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + config = parse_config(argv) + try: + result = run_smoke(config) + except ComposeSmokeError as exc: + print(f"compose smoke failed: {exc}", file=sys.stderr) + _print_failure_diagnostics(config) + return 1 + except (OSError, subprocess.SubprocessError) as exc: + print(f"compose smoke failed while running a local command: {exc}", file=sys.stderr) + _print_failure_diagnostics(config) + return 1 + + print( + "compose smoke passed: " + f"services={len(result.services)} events={result.event_count} " + f"detections={result.detection_count} web_status={result.web_status}" + ) + return 0 + + +def _compose_command(config: ComposeSmokeConfig, *args: str) -> list[str]: + return ["docker", "compose", "-f", str(config.compose_file), *args] + + +def _run_command(args: Sequence[str], env: dict[str, str] | None = None) -> str: + completed = subprocess.run( + list(args), + check=False, + env=env, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + text=True, + ) + if completed.returncode != 0: + command = " ".join(args) + raise ComposeSmokeError( + f"Command failed with exit code {completed.returncode}: {command}\n" + f"{completed.stdout.strip()}" + ) + return completed.stdout + + +def _get_json(url: str) -> Any: + status, body = _get_text(url) + if status < 200 or status >= 300: + raise ComposeSmokeError(f"GET {url} returned HTTP {status}: {body[:500]}") + return json.loads(body) + + +def _get_text(url: str) -> tuple[int, str]: + request = urllib.request.Request(url, headers={"accept": "application/json,text/html"}) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, response.read().decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + + +def _wait_for( + *, + description: str, + timeout_seconds: float, + poll_interval_seconds: float, + probe: Callable[[], Any | None], + failure_message: str | None = None, +) -> Any: + deadline = time.monotonic() + timeout_seconds + last_error: str | None = None + + while time.monotonic() <= deadline: + try: + result = probe() + if result is not None: + return result + except Exception as exc: + last_error = str(exc) + time.sleep(poll_interval_seconds) + + message = failure_message or f"Timed out waiting for {description}." + if last_error: + message = f"{message}\nLast error: {last_error}" + raise ComposeSmokeError(message) + + +def _healthy_runtime_metadata(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + raise ComposeSmokeError("API health response is not an object.") + required = ("status", "source_mode", "storage_backend", "connector_mode") + missing = [field for field in required if not value.get(field)] + if missing: + raise ComposeSmokeError(f"API health response is missing fields: {missing}") + if value["status"] != "ok": + return None + return value + + +def _non_empty_events(value: Any) -> list[Any] | None: + events = _readable_collection(value) + if events: + return events + return None + + +def _readable_collection(value: Any) -> list[Any]: + if not isinstance(value, list): + raise ComposeSmokeError("Expected API response to be a JSON array.") + return value + + +def _rendered_workbench(value: tuple[int, str]) -> tuple[int, str] | None: + status, body = value + if status < 200 or status >= 300: + return None + if "Factory Intelligence Platform" not in body and "__next" not in body: + return None + return status, body + + +def _connector_emitted_events(logs: str) -> str | None: + matches = re.findall(r"emitted_events=(\d+)", logs) + if any(int(match) > 0 for match in matches): + return f"emitted_events={max(int(match) for match in matches)}" + return None + + +def _parse_services(value: str) -> list[str]: + return [line.strip() for line in value.splitlines() if line.strip()] + + +def _port_from_url(url: str) -> str: + parsed = urllib.parse.urlparse(url) + if parsed.port is None: + return "443" if parsed.scheme == "https" else "80" + return str(parsed.port) + + +def _print_failure_diagnostics(config: ComposeSmokeConfig) -> None: + commands = [ + _compose_command(config, "ps"), + _compose_command(config, "logs", "--tail=100", "api"), + _compose_command(config, "logs", "--tail=100", "connector-worker"), + _compose_command(config, "logs", "--tail=100", "sentinel-worker"), + _compose_command(config, "logs", "--tail=100", "web"), + ] + print("Useful recovery commands:", file=sys.stderr) + print(" cd ../Demo-Factory && docker compose up -d --build", file=sys.stderr) + print(" docker compose -f infra/docker/docker-compose.yml ps", file=sys.stderr) + print(" curl http://localhost:8000/health", file=sys.stderr) + for command in commands: + try: + output = _run_command(command, None) + except Exception as exc: + output = f"diagnostic command failed: {exc}" + print(f"\n$ {' '.join(command)}\n{output}", file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/api/tests/test_compose_smoke.py b/services/api/tests/test_compose_smoke.py new file mode 100644 index 0000000..9759bbd --- /dev/null +++ b/services/api/tests/test_compose_smoke.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from factory_api.compose_smoke import ( + ComposeSmokeConfig, + ComposeSmokeError, + _connector_emitted_events, + _healthy_runtime_metadata, + _non_empty_events, + _parse_services, + _rendered_workbench, + build_compose_environment, + run_smoke, +) + + +def test_compose_smoke_environment_points_at_demo_factory_fixtures() -> None: + config = ComposeSmokeConfig() + + env = build_compose_environment(config) + + assert env["FACTORY_CONNECTION_PROFILES_STORE"].endswith( + "/packages/test-fixtures/demo-factory-connection-profiles/connection_profiles.json" + ) + assert env["CONNECTOR_POLL_INTERVAL_SECONDS"] == "5" + assert env["SENTINEL_RUN_INTERVAL_SECONDS"] == "5" + assert env["FACTORY_SOURCE_MODE"] == "external_demo_factory" + assert env["FACTORY_CONNECTOR_MODE"] == "read_only" + assert env["FIP_API_PORT"] == "18000" + assert env["FIP_WEB_PORT"] == "13000" + assert env["FIP_PUBLIC_API_URL"] == "http://localhost:18000" + + +def test_health_validation_requires_runtime_metadata() -> None: + health = _healthy_runtime_metadata( + { + "status": "ok", + "source_mode": "external_demo_factory", + "storage_backend": "postgres", + "connector_mode": "read_only", + } + ) + + assert health is not None + assert health["storage_backend"] == "postgres" + + with pytest.raises(ComposeSmokeError, match="missing fields"): + _healthy_runtime_metadata({"status": "ok"}) + + +def test_compose_smoke_accepts_readable_no_detection_state() -> None: + commands: list[tuple[str, ...]] = [] + + def runner(args, _env): + commands.append(tuple(args)) + if args[-1] == "--services": + return "\n".join( + ["postgres", "api", "web", "connector-worker", "sentinel-worker"] + ) + if args[-1] == "ps": + return "NAME SERVICE STATUS\nfip-api api running\n" + if "logs" in args: + return "connector worker poll complete: poll_index=0 emitted_events=1 errors=0" + return "" + + def get_json(url: str): + if url.endswith("/health"): + return { + "status": "ok", + "source_mode": "external_demo_factory", + "storage_backend": "postgres", + "connector_mode": "read_only", + } + if url.endswith("/events"): + return [{"event_id": "evt_1"}] + if url.endswith("/sentinel/detections"): + return [] + raise AssertionError(f"unexpected URL: {url}") + + result = run_smoke( + ComposeSmokeConfig(compose_file=Path("compose.yml"), start_stack=False), + command_runner=runner, + get_json=get_json, + get_text=lambda _url: (200, "Factory Intelligence Platform"), + ) + + assert result.event_count == 1 + assert result.detection_count == 0 + assert ("docker", "compose", "-f", "compose.yml", "ps", "--services") in commands + + +def test_compose_smoke_treats_empty_events_as_missing_source_state() -> None: + assert _non_empty_events([]) is None + + +def test_parse_services_and_workbench_render_checks_are_tolerant() -> None: + assert _parse_services("api\n\nweb\n") == ["api", "web"] + assert _rendered_workbench((200, "Factory Intelligence Platform")) is not None + assert _rendered_workbench((503, "starting")) is None + assert _connector_emitted_events("emitted_events=0\nemitted_events=2") == ( + "emitted_events=2" + ) + assert _connector_emitted_events("emitted_events=0") is None diff --git a/services/simulator/tests/test_docker_compose_runtime_docs.py b/services/simulator/tests/test_docker_compose_runtime_docs.py index 5018220..5e25667 100644 --- a/services/simulator/tests/test_docker_compose_runtime_docs.py +++ b/services/simulator/tests/test_docker_compose_runtime_docs.py @@ -40,6 +40,7 @@ def test_docker_compose_runtime_doc_includes_required_commands() -> None: "docker compose -f infra/docker/docker-compose.yml down", "docker compose -f infra/docker/docker-compose.yml down -v", "docker compose -f infra/docker/docker-compose.yml config", + "make compose-smoke", ] for command in required_commands: @@ -52,6 +53,7 @@ def test_docker_compose_runtime_doc_includes_required_commands() -> None: "curl http://localhost:8000/health", "docker compose -f infra/docker/docker-compose.yml down", "docker compose -f infra/docker/docker-compose.yml down -v", + "make compose-smoke", ]: assert command in readme @@ -100,6 +102,9 @@ def test_docker_compose_runtime_defines_fip_services_without_simulator() -> None "connector-worker:", "sentinel-worker:", "NEXT_PUBLIC_API_BASE_URL", + "${FIP_API_PORT:-8000}:8000", + "${FIP_WEB_PORT:-3000}:3000", + "FIP_PUBLIC_API_URL", "FIP_API_URL: http://api:8000", "FACTORY_CONNECTION_PROFILES_STORE", "DEMO_FACTORY_HOST", @@ -141,6 +146,7 @@ def test_makefile_includes_compose_wrappers() -> None: "compose-ps:", "compose-logs:", "compose-config:", + "compose-smoke:", ]: assert target in makefile @@ -150,10 +156,33 @@ def test_makefile_includes_compose_wrappers() -> None: "make compose-logs", "make compose-config", "make compose-down", + "make compose-smoke", ]: assert command in readme or command in _read(DOCKER_COMPOSE_DOC) +def test_compose_smoke_docs_describe_runtime_checks() -> None: + content = _read(DOCKER_COMPOSE_DOC) + "\n" + _read(REPO_ROOT / "docs" / "TESTING.md") + makefile = _read(REPO_ROOT / "Makefile") + + required_terms = [ + "make compose-smoke", + "Demo-Factory already running separately", + "`GET http://localhost:18000/health` returns healthy runtime metadata", + "ports `18000` for the FIP API and `13000`", + "Connector ingestion has produced FactoryEvents", + "Process Sentinel detection state is readable", + "The Workbench renders at `http://localhost:3000`", + "missing-source guidance", + "docker compose -f infra/docker/docker-compose.yml logs --tail=100 connector-worker", + ] + + for term in required_terms: + assert term in content + + assert "$(PYTHON) -m factory_api.compose_smoke" in makefile + + def test_runtime_troubleshooting_covers_expected_failure_modes() -> None: content = _read(TROUBLESHOOTING_DOC)