From 9681f19bec16c5a406584c61bbaf4192b22263e9 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Mon, 24 Aug 2026 14:31:38 +0800 Subject: [PATCH] test(ci): require dependency-aware readiness (#6491) * test(e2e): fail closed on runner readiness * test(ci): require dependency-aware readiness --- .github/workflows/e2e-s3tests.yml | 2 +- .github/workflows/mint.yml | 2 +- scripts/check_test_wiring.py | 215 +++++++++++++++++++++++++++++- scripts/s3-tests/run.sh | 25 ++-- 4 files changed, 222 insertions(+), 22 deletions(-) diff --git a/.github/workflows/e2e-s3tests.yml b/.github/workflows/e2e-s3tests.yml index 6429d68f6..7e24d4e90 100644 --- a/.github/workflows/e2e-s3tests.yml +++ b/.github/workflows/e2e-s3tests.yml @@ -311,7 +311,7 @@ jobs: - name: Wait for RustFS ready run: | for _ in {1..120}; do - if curl -sf "http://${S3_HOST}:${S3_PORT}/health" >/dev/null 2>&1; then + if curl -sf "http://${S3_HOST}:${S3_PORT}/health/ready" >/dev/null 2>&1; then echo "RustFS is ready" exit 0 fi diff --git a/.github/workflows/mint.yml b/.github/workflows/mint.yml index 25ae02916..425e13609 100644 --- a/.github/workflows/mint.yml +++ b/.github/workflows/mint.yml @@ -155,7 +155,7 @@ jobs: - name: Wait for RustFS ready run: | for _ in {1..60}; do - if curl -sf http://127.0.0.1:9000/health >/dev/null 2>&1; then + if curl -sf http://127.0.0.1:9000/health/ready >/dev/null 2>&1; then echo "RustFS is ready" exit 0 fi diff --git a/scripts/check_test_wiring.py b/scripts/check_test_wiring.py index 4f652a14c..fc7101045 100755 --- a/scripts/check_test_wiring.py +++ b/scripts/check_test_wiring.py @@ -228,9 +228,120 @@ def check_runner_selection(root: Path) -> list[str]: def check_s3_tests_runner(root: Path) -> list[str]: runner = (root / "scripts/s3-tests/run.sh").read_text() + errors: list[str] = [] if "--showlocals" in runner: - return ["scripts/s3-tests/run.sh: pytest failure diagnostics must not dump local values"] - return [] + errors.append("scripts/s3-tests/run.sh: pytest failure diagnostics must not dump local values") + readiness = re.search( + r"test_s3_api_ready\(\) \{(?P.*?)\n\}\n\n# First, wait", + runner, + re.DOTALL, + ) + readiness_body = readiness.group("body") if readiness else "" + if ( + '"http://${S3_HOST}:${S3_PORT}/health/ready"' not in readiness_body + or re.search(r"/health(?=[\"'\s])", readiness_body) + or '[ "${READY_CODE}" != "200" ]' not in readiness_body + ): + errors.append("scripts/s3-tests/run.sh: startup must require the ready endpoint to return HTTP 200") + signed_probe = re.search( + r"if command -v awscurl\b[^\n]*; then(?P.*?)\n\s*fi\s*" + r"(?:#[^\n]*\n\s*)*return 0\s*$", + readiness_body, + re.DOTALL, + ) + signed_body = signed_probe.group("body") if signed_probe else "" + readiness_code = "\n".join(line.split("#", 1)[0] for line in readiness_body.splitlines()) + signed_code = "\n".join(line.split("#", 1)[0] for line in signed_body.splitlines()) + signed_commands = [ + command + for line in signed_body.splitlines() + if (command := line.split("#", 1)[0].strip()) + ] + signed_success = re.search( + r'if echo "\$\{RESPONSE\}" \| grep -q ".*?)\)\s*$", + signed_code, + re.DOTALL | re.MULTILINE, + ) + response_command = response_capture.group("command") if response_capture else "" + response_command = response_command.replace("\\\n", " ").strip() + response_operators = re.search( + r";|\|\||&&|(?|])\|(?!\|)|(?&])&(?![>&0-9])|\$\(|[<>]\(|`", + response_command, + ) + if ( + len(re.findall(r"\breturn\s+0\b", readiness_code)) != 2 + or len(re.findall(r"\breturn\s+0\b", signed_code)) != 1 + or len(re.findall(r"(? list[str]: + errors: list[str] = [] + for relative in (".github/workflows/e2e-s3tests.yml", ".github/workflows/mint.yml"): + path = root / relative + try: + lines = path.read_text().splitlines() + except FileNotFoundError: + errors.append(f"{relative}: missing workflow") + continue + start = next( + (index for index, line in enumerate(lines) if line.strip() == "- name: Wait for RustFS ready"), + None, + ) + if start is None: + errors.append(f"{relative}: missing RustFS readiness step") + continue + indent = len(lines[start]) - len(lines[start].lstrip()) + end = next( + ( + index + for index in range(start + 1, len(lines)) + if lines[index].strip().startswith("- name:") + and len(lines[index]) - len(lines[index].lstrip()) <= indent + ), + len(lines), + ) + readiness_step = "\n".join(lines[start:end]) + ready_branch = re.search( + r"if curl [^\n]*/health/ready[^\n]*; then(?P.*?)\n\s*fi", + readiness_step, + re.DOTALL, + ) + ready_body = ready_branch.group("body") if ready_branch else "" + ready_condition = ready_branch.group(0).splitlines()[0].rsplit("; then", 1)[0] if ready_branch else "" + step_commands = [ + command + for line in readiness_step.splitlines() + if (command := line.split("#", 1)[0].strip()) + ] + step_code = "\n".join(step_commands) + ready_code = "\n".join(line.split("#", 1)[0] for line in ready_body.splitlines()) + if ( + "/health/ready" not in readiness_step + or re.search(r"/health(?=[\"'\s])", readiness_step) + or not re.search(r"curl [^\n]*(?:-sf|-fs|--fail)", readiness_step) + or not ready_branch + or re.search(r"\|\||&&|(?|])\|(?!\|)|;|(?&])&(?![>&])", ready_condition) + or len(re.findall(r"\bexit\s+0\b", step_code)) != 1 + or not re.search(r"\bexit\s+0\b", ready_code) + or not step_commands + or step_commands[-1] != "exit 1" + ): + errors.append(f"{relative}: RustFS readiness step must fail closed on /health/ready") + return errors def profile_selection_entries(root: Path, profile: str) -> tuple[Path, list[str], dict[str, str]]: @@ -607,6 +718,7 @@ def validate(root: Path) -> list[str]: errors.extend(check_fuzz_targets(root)) errors.extend(check_runner_selection(root)) errors.extend(check_s3_tests_runner(root)) + errors.extend(check_workflow_readiness(root)) errors.extend(check_profile_definitions(root)) errors.extend(check_scheduled_alerts(root)) return errors @@ -715,19 +827,114 @@ class SelfTests(unittest.TestCase): root = Path(tmp) runner = root / "scripts/s3-tests/run.sh" runner.parent.mkdir(parents=True) - runner.write_text("tox -- -vv -ra --tb=long\n") + valid_runner = ( + "test_s3_api_ready() {\n" + ' READY_CODE=$(curl "http://${S3_HOST}:${S3_PORT}/health/ready")\n' + ' if [ "${READY_CODE}" != "200" ]; then\n' + " return 1\n" + " fi\n" + " if command -v awscurl; then\n" + " RESPONSE=$(awscurl --service s3)\n" + ' if echo "${RESPONSE}" | grep -q " None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + workflows = root / ".github/workflows" + workflows.mkdir(parents=True) + valid_workflow = ( + "jobs:\n" + " test:\n" + " steps:\n" + " - name: Wait for RustFS ready\n" + " run: |\n" + " for _ in {1..60}; do\n" + " if curl -sf http://127.0.0.1:9000/health/ready; then\n" + " exit 0\n" + " fi\n" + " done\n" + " exit 1\n" + " - name: Run tests\n" + " run: true\n" + ) + for name in ("e2e-s3tests.yml", "mint.yml"): + (workflows / name).write_text(valid_workflow) + self.assertEqual(check_workflow_readiness(root), []) + + (workflows / "mint.yml").write_text(valid_workflow.replace("/health/ready", "/health")) + self.assertEqual(len(check_workflow_readiness(root)), 1) + (workflows / "mint.yml").write_text( + valid_workflow.replace( + "if curl -sf http://127.0.0.1:9000/health/ready; then", + "if curl -sf http://127.0.0.1:9000/health/ready || true; then", + ) + ) + self.assertEqual(len(check_workflow_readiness(root)), 1) + (workflows / "mint.yml").write_text( + valid_workflow.replace( + "if curl -sf http://127.0.0.1:9000/health/ready; then", + "if curl -sf http://127.0.0.1:9000/health/ready || :; then", + ) + ) + self.assertEqual(len(check_workflow_readiness(root)), 1) + (workflows / "mint.yml").write_text( + valid_workflow.replace( + "if curl -sf http://127.0.0.1:9000/health/ready; then\n exit 0\n fi", + "curl -sf http://127.0.0.1:9000/health/ready || true\n exit 0", + ) + ) + self.assertEqual(len(check_workflow_readiness(root)), 1) + def test_profile_listing_enforces_selection(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/scripts/s3-tests/run.sh b/scripts/s3-tests/run.sh index 74e2289f6..bd197e0f5 100755 --- a/scripts/s3-tests/run.sh +++ b/scripts/s3-tests/run.sh @@ -584,19 +584,17 @@ check_server_ready_from_log() { # Test S3 API readiness test_s3_api_ready() { - # Step 1: Check if server is responding using /health endpoint - # /health is a probe path that bypasses readiness gate, so it can be used - # to check if the server is up and running, even if readiness gate is not ready yet - HEALTH_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + # Step 1: Require the dependency-aware readiness endpoint. + READY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ -X GET \ - "http://${S3_HOST}:${S3_PORT}/health" \ + "http://${S3_HOST}:${S3_PORT}/health/ready" \ --max-time 5 2>/dev/null || echo "000") - if [ "${HEALTH_CODE}" = "000" ]; then + if [ "${READY_CODE}" = "000" ]; then # Connection failed - server might not be running or not listening yet return 1 - elif [ "${HEALTH_CODE}" != "200" ]; then - # Health endpoint returned non-200 status, server might have issues + elif [ "${READY_CODE}" != "200" ]; then + # The service is live but its storage/IAM dependencies are not ready. return 1 fi @@ -617,16 +615,11 @@ test_s3_api_ready() { if echo "${RESPONSE}" | grep -q "503\|Service not ready"; then return 1 # Not ready yet (readiness gate is blocking S3 API) fi - # Other errors from awscurl - might be auth issues or other problems - # But server is up, so we'll consider it ready (S3 API might have other issues) - return 0 + # Authentication, server, and transport failures are not readiness. + return 1 fi - # Step 3: Fallback - if /health returns 200, server is up and readiness gate is ready - # Since /health is a probe path and returns 200, and we don't have awscurl to test S3 API, - # we can assume the server is ready. The readiness gate would have blocked /health if not ready. - # Note: Root path "/" with HEAD method returns 501 Not Implemented (S3 doesn't support HEAD on root), - # so we can't use it as a reliable test. Since /health already confirmed readiness, we return success. + # /health/ready is authoritative when the optional signed probe is absent. return 0 }