Repository navigation
build(make): add integration, wheel-smoke and verify targets #844
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6fe2974
build(make): add integration, wheel-smoke and verify targets
yeongseon 4beb6af
test(make): read the verify recipe instead of dry-running it
yeongseon ab88f1a
Merge remote-tracking branch 'origin/main' into build/make-integratio…
yeongseon dde5eda
build(make): fail integration on a dead Azurite and clean up on INT/T…
yeongseon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
|
|
||
| root=$(pwd) | ||
| python=${PYTHON:-python3} | ||
| fail() { echo "Error: $*" >&2; exit 1; } | ||
| command -v "$python" >/dev/null || fail "Python >=3.11 is required (PYTHON=$python)" | ||
| "$python" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || fail "Python >=3.11 is required" | ||
|
|
||
| tmp=$(mktemp -d "${TMPDIR:-/tmp}/openapi-smoke.XXXXXXXX") | ||
| pids=() | ||
| cleanup() { | ||
| status=$? | ||
| trap - EXIT | ||
| trap '' INT TERM | ||
| for pid in "${pids[@]}"; do kill -TERM -- "-$pid" 2>/dev/null || true; done | ||
| if ((${#pids[@]})); then | ||
| sleep 1 | ||
| for pid in "${pids[@]}"; do kill -KILL -- "-$pid" 2>/dev/null || true; done | ||
| for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done | ||
| fi | ||
| if ((status != 0)); then | ||
| for log in "$tmp"/*.log; do | ||
| if [[ -f "$log" ]]; then echo "--- $log"; cat "$log"; fi | ||
| done | ||
| fi | ||
| rm -rf "$tmp" | ||
| exit "$status" | ||
| } | ||
| trap cleanup EXIT | ||
| trap 'exit 130' INT | ||
| trap 'exit 143' TERM | ||
| # Job control gives each service its own process group, including npm/worker descendants. | ||
| set -m | ||
|
|
||
| # Run a blocking command as a tracked background job and wait for it with the builtin `wait`. | ||
| # Under job control a foreground command would sit in its own process group, so Bash would defer | ||
| # the INT/TERM trap until it finished; waiting on a job lets the trap fire at once and cleanup | ||
| # then terminates the command's process group together with the services. | ||
| run() { | ||
| "$@" & | ||
| local job=$! | ||
| pids+=("$job") | ||
| local status=0 | ||
| wait "$job" || status=$? | ||
| # Finished: drop it so cleanup never signals a recycled PID. | ||
| local kept=() pid | ||
| for pid in "${pids[@]}"; do [[ "$pid" == "$job" ]] || kept+=("$pid"); done | ||
| pids=("${kept[@]}") | ||
| return "$status" | ||
| } | ||
|
|
||
| case "${1:-}" in | ||
| integration) | ||
| for tool in func curl; do command -v "$tool" >/dev/null || fail "$tool is required for integration"; done | ||
| azurite=(azurite) | ||
| if ! command -v azurite >/dev/null; then | ||
| command -v npx >/dev/null || fail "azurite or npx is required for integration" | ||
| azurite=(npx --no-install azurite) | ||
| fi | ||
| smoke="$root/tests/host_smoke/.venv/bin/python" | ||
| [[ -x "$smoke" ]] || fail "smoke venv missing: create tests/host_smoke/.venv and install tests/host_smoke/requirements.txt, pytest, pytest-cov, requests (see docs/development.md)" | ||
| "$smoke" -c 'import sys; sys.exit(sys.version_info < (3, 11))' || fail "smoke venv needs Python >=3.11" | ||
| "$smoke" -c 'import azure_functions_openapi, pytest, pytest_cov, requests' || fail "smoke venv dependencies are missing; see docs/development.md" | ||
| read -r host_port blob_port queue_port table_port < <("$python" - <<'PY' | ||
| from contextlib import ExitStack | ||
| import socket | ||
|
|
||
| with ExitStack() as stack: | ||
| sockets = [stack.enter_context(socket.socket()) for _ in range(4)] | ||
| for sock in sockets: | ||
| sock.bind(("127.0.0.1", 0)) | ||
| print(*(sock.getsockname()[1] for sock in sockets)) | ||
| PY | ||
| ) | ||
| base="http://127.0.0.1:$host_port" | ||
| export HOST_SMOKE_BASE_URL="$base" | ||
| # Public Azurite development key, not an Azure credential. Override local.settings.json. | ||
| export AzureWebJobsStorage="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:$blob_port/devstoreaccount1;QueueEndpoint=http://127.0.0.1:$queue_port/devstoreaccount1;TableEndpoint=http://127.0.0.1:$table_port/devstoreaccount1;" | ||
| export FUNCTIONS_WORKER_RUNTIME=python FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT=1 | ||
| "${azurite[@]}" --silent --location "$tmp/azurite" \ | ||
| --blobHost 127.0.0.1 --queueHost 127.0.0.1 --tableHost 127.0.0.1 \ | ||
| --blobPort "$blob_port" --queuePort "$queue_port" --tablePort "$table_port" >"$tmp/azurite.log" 2>&1 & | ||
| azurite_pid=$! | ||
| pids+=("$azurite_pid") | ||
| (cd tests/host_smoke; export PATH="$PWD/.venv/bin:$PATH"; exec func start --port "$host_port" --verbose) >"$tmp/host.log" 2>&1 & | ||
| host_pid=$! | ||
| pids+=("$host_pid") | ||
| echo "Host smoke: $base (Azurite $blob_port/$queue_port/$table_port)" | ||
| ready=false | ||
| for ((attempt=0; attempt<60; attempt++)); do | ||
| if curl --noproxy '*' --fail --silent --max-time 2 "$base/api/custom_name" --output "$tmp/custom.json"; then ready=true; break; fi | ||
| kill -0 "$host_pid" 2>/dev/null || break | ||
| kill -0 "$azurite_pid" 2>/dev/null || break | ||
| sleep 2 | ||
| done | ||
| [[ "$ready" == true ]] || fail "The custom-name route did not become ready" | ||
| # The routes below are HTTP-only and never touch storage, so a dead Azurite would not fail | ||
| # them. Check it explicitly before and after the route tests. | ||
| kill -0 "$azurite_pid" 2>/dev/null || fail "Azurite is not running; check $tmp/azurite.log" | ||
| "$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/custom.json" | ||
| curl --noproxy '*' --fail --silent --max-time 10 "$base/api/openapi.json" --output "$tmp/openapi.json" | ||
| documented_path=$("$smoke" - "$tmp/openapi.json" <<'PY' | ||
| import json | ||
| import sys | ||
|
|
||
| with open(sys.argv[1]) as source: | ||
| spec = json.load(source) | ||
| assert spec["openapi"] | ||
| paths = [path for path in spec["paths"] if path not in ("/api/openapi.json", "/api/docs")] | ||
| assert paths == ["/api/custom_name"], paths | ||
| print(paths[0]) | ||
| PY | ||
| ) | ||
| curl --noproxy '*' --fail --silent --max-time 10 "$base$documented_path" --output "$tmp/documented.json" | ||
| "$smoke" -c 'import json, sys; assert json.load(open(sys.argv[1])) == {"function_name": "custom_name"}' "$tmp/documented.json" | ||
| curl --noproxy '*' --fail --silent --max-time 10 "$base/api/docs" --output "$tmp/swagger.html" | ||
| grep -q 'SwaggerUIBundle' "$tmp/swagger.html" | ||
| run "$smoke" -m pytest tests/host_smoke -m host_smoke --no-cov -rs | ||
| kill -0 "$azurite_pid" 2>/dev/null || fail "Azurite stopped while the route tests ran; check $tmp/azurite.log" | ||
| if grep -Eq "0 functions (loaded|found \(Worker\))|No job functions found|Executed 'Functions\..*' \(Failed" "$tmp/host.log"; then | ||
| fail "The Functions host reported indexing or invocation errors" | ||
| fi | ||
| ;; | ||
| wheel-smoke) | ||
| : "${HATCH:?HATCH must name the Hatch executable}" | ||
| run "$HATCH" build -t wheel "$tmp/dist" | ||
| "$python" -m venv "$tmp/venv" | ||
| run "$tmp/venv/bin/python" -m pip install --disable-pip-version-check "$tmp"/dist/*.whl | ||
| run "$tmp/venv/bin/python" -m pip check | ||
| (cd "$tmp"; CHECKOUT="$root" "$tmp/venv/bin/python" -I - <<'PY' | ||
| import os | ||
| from pathlib import Path | ||
|
|
||
| import azure.functions as func | ||
| import azure_functions_openapi | ||
| from azure_functions_openapi import generate_openapi_spec, openapi, scan_endpoint_metadata | ||
|
|
||
| module_path = Path(azure_functions_openapi.__file__).resolve() | ||
| print(f"imported module: {module_path}") | ||
| assert not module_path.is_relative_to(Path(os.environ["CHECKOUT"]).resolve()) | ||
| app = func.FunctionApp() | ||
|
|
||
| @openapi(summary="Artifact smoke", responses={200: {"description": "OK"}}) | ||
| @app.route(route="artifact-smoke", methods=["GET"]) | ||
| def artifact_smoke(req: func.HttpRequest) -> func.HttpResponse: | ||
| return func.HttpResponse("ok") | ||
|
|
||
| scan_endpoint_metadata(app) | ||
| spec = generate_openapi_spec() | ||
| assert spec["paths"]["/api/artifact-smoke"]["get"] | ||
| PY | ||
| ) | ||
| ;; | ||
| *) fail "usage: local-smoke.sh integration|wheel-smoke" ;; | ||
| esac | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| """Local smoke targets remain discoverable, ordered and fail-fast (#811).""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from pathlib import Path | ||
| import re | ||
| import shutil | ||
| import subprocess | ||
|
|
||
| import pytest | ||
|
|
||
| ROOT = Path(__file__).resolve().parents[1] | ||
| MAKE = shutil.which("make") or "make" | ||
| LANES = ("check", "test", "integration", "docs", "wheel-smoke") | ||
| pytestmark = pytest.mark.skipif(shutil.which("make") is None, reason="requires make") | ||
|
|
||
|
|
||
| def test_verify_recipe_lists_lanes_in_order() -> None: | ||
| # Read the recipe instead of running `make -n verify`: make executes any recipe line that | ||
| # contains $(MAKE) even under -n, and `docs` would then really run `pip install -e` in CI. | ||
| # Given | ||
| makefile = (ROOT / "Makefile").read_text(encoding="utf-8") | ||
| # When | ||
| match = re.search(r"^verify:[^\n]*\n((?:\t[^\n]*\n)+)", makefile, flags=re.MULTILINE) | ||
| # Then | ||
| assert match is not None, "verify target not found" | ||
| recipe = [line.strip() for line in match.group(1).splitlines()] | ||
| assert recipe == [f"@$(MAKE) {lane}" for lane in LANES] | ||
|
|
||
|
|
||
| def test_smoke_targets_are_phony_and_visible_in_help() -> None: | ||
| # Given | ||
| targets = {"integration", "wheel-smoke", "verify"} | ||
| makefile = (ROOT / "Makefile").read_text(encoding="utf-8") | ||
| # When | ||
| phony = { | ||
| target | ||
| for line in makefile.splitlines() | ||
| if line.startswith(".PHONY:") | ||
| for target in line.partition(":")[2].split() | ||
| } | ||
| result = subprocess.run( | ||
| [MAKE, "--no-print-directory", "help"], | ||
| cwd=ROOT, | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| ) | ||
| # Then | ||
| assert targets <= phony | ||
| assert all(f"make {target}" in result.stdout for target in targets) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("failed_lane", LANES) | ||
| def test_verify_stops_at_the_first_failed_lane(tmp_path: Path, failed_lane: str) -> None: | ||
| # Given: real make recursion, replacing only the expensive lane recipes. | ||
| source = (ROOT / "Makefile").read_text(encoding="utf-8") | ||
| source = re.sub(r"^(check|test|wheel-smoke):.*$", r"\1:", source, flags=re.MULTILINE) | ||
| (tmp_path / "Makefile").write_text(source, encoding="utf-8") | ||
| with (tmp_path / "Makefile").open("a", encoding="utf-8") as makefile: | ||
| makefile.write("\nensure-hatch: ; @:\nbootstrap: ; @:\n") | ||
| makefile.write( | ||
| "".join(f"{lane}: ; @echo {lane}; exit {int(lane == failed_lane)}\n" for lane in LANES) | ||
| ) | ||
| # When | ||
| result = subprocess.run( | ||
| [MAKE, "--no-print-directory", "verify", "MAKE=make"], | ||
| cwd=tmp_path, | ||
| capture_output=True, | ||
| text=True, | ||
| check=False, | ||
| env={key: value for key, value in os.environ.items() if not key.startswith("MAKE")}, | ||
| ) | ||
| # Then | ||
| assert result.returncode != 0 | ||
| assert result.stdout.splitlines() == list(LANES[: LANES.index(failed_lane) + 1]), result.stderr | ||
|
|
||
|
|
||
| SMOKE_SCRIPT = ROOT / "scripts" / "local-smoke.sh" | ||
|
|
||
|
|
||
| def test_smoke_script_checks_azurite_before_and_after_the_route_tests() -> None: | ||
| # The routes are HTTP-only, so a dead Azurite would not fail them. The script must check its | ||
| # liveness itself, both before running them and after (#811 review). | ||
| # Given | ||
| lines = SMOKE_SCRIPT.read_text(encoding="utf-8").splitlines() | ||
| checks = [ | ||
| i for i, line in enumerate(lines) if 'kill -0 "$azurite_pid"' in line and "fail" in line | ||
| ] | ||
| pytest_run = next(i for i, line in enumerate(lines) if "-m pytest tests/host_smoke" in line) | ||
| # Then | ||
| assert any(i < pytest_run for i in checks), "no Azurite liveness check before the route tests" | ||
| assert any(i > pytest_run for i in checks), "no Azurite liveness check after the route tests" | ||
|
|
||
|
|
||
| @pytest.mark.skipif(os.name != "posix", reason="process groups") | ||
| def test_term_during_a_blocking_command_cleans_up_promptly(tmp_path: Path) -> None: | ||
| # Under job control a foreground command sits in its own process group, so Bash would defer | ||
| # the TERM trap until it finished. A hung Hatch must not block cancellation or leak its | ||
| # scratch directory. | ||
| # Given: a `hatch` that hangs, and a private TMPDIR so only this run's scratch dir is seen. | ||
| import signal | ||
| import sys | ||
| import time | ||
|
|
||
| hatch = tmp_path / "hatch" | ||
| hatch.write_text("#!/bin/sh\nexec sleep 300\n", encoding="utf-8") | ||
| hatch.chmod(0o755) | ||
| scratch = tmp_path / "tmp" | ||
| scratch.mkdir() | ||
| env = {**os.environ, "PYTHON": sys.executable, "HATCH": str(hatch), "TMPDIR": str(scratch)} | ||
| # When | ||
| proc = subprocess.Popen( # noqa: S603 | ||
| ["bash", str(SMOKE_SCRIPT), "wheel-smoke"], # noqa: S607 | ||
| cwd=ROOT, | ||
| env=env, | ||
| stdout=subprocess.DEVNULL, | ||
| stderr=subprocess.DEVNULL, | ||
| start_new_session=True, | ||
| ) | ||
| try: | ||
| deadline = time.monotonic() + 20 | ||
| while time.monotonic() < deadline and not any(scratch.glob("openapi-smoke.*")): | ||
| time.sleep(0.1) | ||
| time.sleep(0.5) # let the blocking command start | ||
| proc.send_signal(signal.SIGTERM) | ||
| # Then | ||
| assert proc.wait(timeout=10) == 143 | ||
| assert not list(scratch.glob("openapi-smoke.*")), "scratch directory was not removed" | ||
| finally: | ||
| if proc.poll() is None: | ||
| os.killpg(proc.pid, signal.SIGKILL) | ||
| proc.wait() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.