| #!/usr/bin/env python3 |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| """ |
| End-to-end smoke test for a built Burr wheel. |
| |
| Installs the wheel into a fresh venv (outside the source tree), starts the |
| `burr` tracking server, runs a simple tracked application, and verifies the |
| server observes it via the HTTP API. |
| |
| Fails fast with clear output if any step breaks. Designed to run in CI. |
| |
| Usage: |
| python scripts/ci_smoke_server.py --wheel dist/apache_burr-0.42.0-py3-none-any.whl |
| |
| The `burr.examples.hello-world-counter` bug (missing module at server import |
| time) would be caught here because starting the server triggers the module- |
| level importlib.import_module calls in burr/tracking/server/run.py. |
| """ |
| |
| import argparse |
| import json |
| import os |
| import shutil |
| import signal |
| import socket |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| import urllib.error |
| import urllib.request |
| from pathlib import Path |
| from typing import Optional |
| |
| |
| def _free_port() -> int: |
| """Pick an available localhost port.""" |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| s.bind(("127.0.0.1", 0)) |
| return s.getsockname()[1] |
| |
| |
| def _log(msg: str) -> None: |
| print(f"[smoke] {msg}", flush=True) |
| |
| |
| def _fail(msg: str) -> "None": |
| print(f"[smoke] FAIL: {msg}", flush=True) |
| sys.exit(1) |
| |
| |
| def _poll_url(url: str, timeout_s: int = 30, server_proc: "subprocess.Popen | None" = None) -> bool: |
| """Poll URL until 200 or timeout. Fails fast if server process dies.""" |
| deadline = time.time() + timeout_s |
| while time.time() < deadline: |
| if server_proc is not None and server_proc.poll() is not None: |
| return False |
| try: |
| with urllib.request.urlopen(url, timeout=2) as resp: |
| if resp.status == 200: |
| return True |
| except (urllib.error.URLError, ConnectionResetError, TimeoutError): |
| pass |
| time.sleep(1) |
| return False |
| |
| |
| def _poll_projects( |
| base_url: str, |
| project_name: str, |
| timeout_s: int = 30, |
| server_proc: "subprocess.Popen | None" = None, |
| ) -> bool: |
| """Poll /api/v0/projects until project_name appears or timeout. |
| |
| The Burr server discovers tracking data from the filesystem on demand, so |
| there is a short lag between a tracked app writing its data and the server |
| reporting the project over the API. Polling is more reliable than a fixed |
| sleep because it succeeds as soon as the data is visible and bails early |
| if the server process has already died. |
| """ |
| deadline = time.time() + timeout_s |
| while time.time() < deadline: |
| if server_proc is not None and server_proc.poll() is not None: |
| return False |
| try: |
| with urllib.request.urlopen(f"{base_url}/api/v0/projects", timeout=2) as resp: |
| if resp.status == 200: |
| data = json.loads(resp.read().decode("utf-8")) |
| if project_name in [p.get("name") for p in data]: |
| return True |
| except (urllib.error.URLError, ConnectionResetError, TimeoutError): |
| pass |
| time.sleep(1) |
| return False |
| |
| |
| def _should_cleanup(explicit: Optional[bool]) -> bool: |
| """Return True if the work directory should be removed after the run. |
| |
| Priority: explicit flag > GITHUB_ACTIONS env var > default (clean locally). |
| In GitHub Actions the workspace is preserved so the upload-artifact step |
| can capture it on failure; locally it is cleaned up by default. |
| """ |
| if explicit is not None: |
| return explicit |
| return os.environ.get("GITHUB_ACTIONS") != "true" |
| |
| |
| def _build_parser() -> argparse.ArgumentParser: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--wheel", required=True, help="Path to the wheel to smoke-test") |
| parser.add_argument( |
| "--python", |
| default=sys.executable, |
| help="Python interpreter to use for the venv (default: current)", |
| ) |
| parser.add_argument( |
| "--port", |
| type=int, |
| default=0, |
| help="Port for the burr server (0 = auto-pick free port)", |
| ) |
| parser.add_argument( |
| "--timeout", |
| type=int, |
| default=45, |
| help="Seconds to wait for the server to become ready", |
| ) |
| parser.add_argument( |
| "--cleanup", |
| action=argparse.BooleanOptionalAction, |
| default=None, |
| help=( |
| "Remove work directory after run. " |
| "Defaults to True locally and False in GitHub Actions " |
| "(so CI can upload the workspace as a debug artifact)." |
| ), |
| ) |
| return parser |
| |
| |
| def main() -> None: |
| args = _build_parser().parse_args() |
| |
| wheel_path = Path(args.wheel).resolve() |
| if not wheel_path.is_file(): |
| _fail(f"Wheel not found: {wheel_path}") |
| |
| port = args.port if args.port else _free_port() |
| should_cleanup = _should_cleanup(args.cleanup) |
| |
| # Fresh working dirs, outside of any source tree |
| work_dir = Path(tempfile.mkdtemp(prefix="burr-smoke-")) |
| venv_dir = work_dir / "venv" |
| burr_data_dir = work_dir / "burr-data" |
| burr_data_dir.mkdir() |
| server_log = work_dir / "server.log" |
| app_script = work_dir / "tracked_app.py" |
| |
| _log(f"Workspace: {work_dir}") |
| _log(f"Python: {args.python}") |
| _log(f"Wheel: {wheel_path}") |
| _log(f"Cleanup after run: {should_cleanup}") |
| |
| server_proc = None |
| try: |
| # 1. Create venv |
| _log("Creating venv...") |
| subprocess.run([args.python, "-m", "venv", str(venv_dir)], check=True) |
| |
| venv_py = venv_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") |
| venv_burr = venv_dir / ("Scripts/burr.exe" if os.name == "nt" else "bin/burr") |
| |
| # 2. Install wheel |
| _log("Installing wheel (with [learn] extras)...") |
| subprocess.run( |
| [str(venv_py), "-m", "pip", "install", "--upgrade", "pip", "--quiet"], check=True |
| ) |
| subprocess.run( |
| [str(venv_py), "-m", "pip", "install", f"{wheel_path}[learn]", "--quiet"], |
| check=True, |
| ) |
| |
| # 3. Smoke check: import the server module. This is the minimal check that |
| # would have caught the hello-world-counter regression without needing to |
| # actually start uvicorn. |
| _log("Importing burr.tracking.server.run (catches missing-example bugs)...") |
| subprocess.run( |
| [str(venv_py), "-c", "import burr.tracking.server.run"], |
| check=True, |
| cwd=str(work_dir), |
| ) |
| |
| # 4. Start server from outside the source tree so CWD can't shadow the install. |
| # start_new_session=True puts the server and all its children (uvicorn) into a |
| # dedicated process group. This lets us send SIGTERM to the entire group on |
| # teardown, preventing orphaned uvicorn processes from holding the port. |
| _log(f"Starting burr server on port {port}...") |
| env = os.environ.copy() |
| env["burr_path"] = str(burr_data_dir) |
| env["PYTHONUNBUFFERED"] = "1" |
| with open(server_log, "w", encoding="utf-8") as log_fh: |
| server_proc = subprocess.Popen( |
| [str(venv_burr), "--port", str(port), "--no-open"], |
| cwd=str(work_dir), |
| env=env, |
| stdout=log_fh, |
| stderr=subprocess.STDOUT, |
| start_new_session=True, |
| ) |
| |
| base_url = f"http://127.0.0.1:{port}" |
| _log(f"Waiting up to {args.timeout}s for {base_url}/ready ...") |
| if not _poll_url(f"{base_url}/ready", timeout_s=args.timeout, server_proc=server_proc): |
| if server_proc.poll() is not None: |
| _log(f"Server process exited with code {server_proc.returncode}") |
| _log("--- server log ---") |
| print(server_log.read_text(encoding="utf-8"), flush=True) |
| _log("--- end server log ---") |
| _fail("Server did not become ready") |
| _log("Server is up") |
| |
| # 5. Verify the UI is served at the web root. If the frontend build is |
| # missing from the wheel, GET / returns 404 even though the API works. |
| _log("Checking UI is served at GET /...") |
| with urllib.request.urlopen(f"{base_url}/", timeout=5) as resp: |
| if resp.status != 200: |
| _fail( |
| f"GET / returned HTTP {resp.status}, expected 200 — UI may be missing from wheel" |
| ) |
| _log("UI served correctly") |
| |
| # 6. Run a tracked Burr app as a separate process using the venv. |
| _log("Running tracked Burr app...") |
| app_script.write_text( |
| f"""\ |
| from burr.core import ApplicationBuilder, State, default |
| from burr.core.action import action |
| from burr.tracking import LocalTrackingClient |
| |
| |
| @action(reads=["count"], writes=["count"]) |
| def inc(state: State) -> State: |
| return state.update(count=state["count"] + 1) |
| |
| |
| tracker = LocalTrackingClient(project="ci-smoke-test", storage_dir={str(burr_data_dir)!r}) |
| |
| app = ( |
| ApplicationBuilder() |
| .with_actions(inc) |
| .with_transitions(("inc", "inc", default)) |
| .with_state(count=0) |
| .with_entrypoint("inc") |
| .with_tracker(tracker) |
| .build() |
| ) |
| |
| for _ in range(3): |
| app.step() |
| |
| print(f"count={{app.state['count']}} app_id={{app.uid}}") |
| """, |
| encoding="utf-8", |
| ) |
| subprocess.run([str(venv_py), str(app_script)], check=True, cwd=str(work_dir), env=env) |
| |
| # 7. Poll until the server reports the project. The server discovers tracking |
| # data from the filesystem on demand, so there is a short lag after the app |
| # writes its data. Polling is preferable to a fixed sleep: it succeeds as soon |
| # as the data appears and gives a clear failure message on timeout. |
| _log("Waiting for server to report project 'ci-smoke-test'...") |
| if not _poll_projects(base_url, "ci-smoke-test", timeout_s=30, server_proc=server_proc): |
| if server_proc.poll() is not None: |
| _log(f"Server process exited with code {server_proc.returncode}") |
| _log("--- server log ---") |
| print(server_log.read_text(), flush=True) |
| _log("--- end server log ---") |
| _fail("Project 'ci-smoke-test' never appeared in /api/v0/projects") |
| |
| _log("SUCCESS") |
| finally: |
| if server_proc is not None and server_proc.poll() is None: |
| _log("Stopping server (sending SIGTERM to process group)...") |
| try: |
| # Kill the entire process group so uvicorn (a child of burr) is also |
| # terminated. Without this, uvicorn becomes an orphan that holds the |
| # port and consumes resources after the script exits. |
| os.killpg(os.getpgid(server_proc.pid), signal.SIGTERM) |
| except ProcessLookupError: |
| pass # process group already gone |
| try: |
| server_proc.wait(timeout=10) |
| except subprocess.TimeoutExpired: |
| server_proc.kill() |
| |
| if should_cleanup: |
| _log(f"Cleaning up workspace {work_dir} ...") |
| shutil.rmtree(work_dir, ignore_errors=True) |
| else: |
| _log(f"Workspace preserved at {work_dir} (upload as CI artifact if needed)") |
| |
| |
| if __name__ == "__main__": |
| main() |