mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-13 09:17:41 +08:00
* refactor(storage): bind rename rollback to its commit context (#8324) * refactor(storage): separate metadata scheduling from quorum decisions (#8327) * test(storage): verify rejected rollback inspection on real disks (#8331) * test(storage): make commit fault checkpoints independent (#8326) * test(storage): make commit fault checkpoints independent * test(storage): simplify commit checkpoint lookup * fix(listing): count logical prefixes before pagination (#8328) * fix(listing): count logical prefixes before pagination * test(listing): normalize the fixed corpus seed * fix(scanner): isolate and coalesce dirty usage journals (#8325) * fix(scanner): isolate and coalesce dirty usage journals Bind dirty replay records to a stable node owner so peers cannot overwrite or delete another node's pending journal. Replace the unbounded command queue with bounded coalesced state and revision-aware background saves. Keep legacy records isolated and enforce replay byte limits with conservative whole-bucket or unverified fallback. Add publication-stage diagnostics and storage regressions for ownership, clearing, retry, and restart behavior. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * fix(scanner): collapse confirmed journal clear condition Use a let-chain to retain the generation fence before removing a confirmed bucket and updating its scope byte accounting. Satisfy collapsible_if without changing journal clearing behavior. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(storage): retain typed owners across borrowed writes (#8329) * fix(storage): retain typed owners across borrowed writes * fix(storage): preserve Copy write completion policy * refactor(storage): remove redundant write owner references * fix(ecstore): hide rollback backup residue in prefix listings * test(connect): isolate short HTTP fixtures in nextest * fix(protocols): enforce S3 policies for file protocol operations (#8262) * test(ecstore): enforce main core regression gates (#8338) * test(ecstore): enforce main core regression gates * test(ecstore): pin external shards and completion regressions * fix(test): restore authenticated cluster fault scenarios (#8340) * fix(test): restore authenticated cluster fault scenarios * fix(test): use the E2E storage API boundary * ci: coerce the pool timeout input to a number timeout-minutes requires a numeric value, but inputs.pool_timeout_minutes is a string, so '${{ inputs.pool_timeout_minutes || 240 }}' evaluated to the string '240' and the runner rejected the template: 'An error occurred when attempting to determine the step timeout ... Unexpected value 240' (chain run 36505625402 job at 02:25:52, standalone run 36393453114 at 07:46:37). Both pool runs since #8155 merged have therefore executed without the intended step budget. Wrap the expression in fromJSON and update the policy test that pinned the broken literal. * ci: coerce the pool timeout input to a number timeout-minutes requires a numeric value, but inputs.pool_timeout_minutes is a string, so '${{ inputs.pool_timeout_minutes || 240 }}' evaluated to the string '240' and the runner rejected the template: 'An error occurred when attempting to determine the step timeout ... Unexpected value 240' (chain run 36505625402 job at 02:25:52, standalone run 36393453114 at 07:46:37). Both pool runs since #8155 merged have therefore executed without the intended step budget. Wrap the expression in fromJSON and update the policy test that pinned the broken literal. * fix(ecstore): own listing workers through cancellation (#8342) Consolidate the three page collectors, preserve completed worker failures, and abort nested merges when their owner is dropped. Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> * fix(ecstore): route commit sync through guarded helper Use the Linux-capable guarded directory sync entry point for both commit sync sites. This keeps group commit and file-sync admission behavior while ensuring the already-open commit directory remains the sync target. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> * fix(ecstore): expose rename directory guard in production The commit path now acquires the directory guard in production builds, not only tests, so destination metadata reads and durable sync share the guarded context. Co-Authored-By: heihutu <heihutu@gmail.com> Co-Authored-By: zhi22915 <qiuzgang@gmail.com> --------- Co-authored-by: Chris <anzhengchao@gmail.com> Co-authored-by: heihutu <heihutu@gmail.com> Co-authored-by: zhi22915 <qiuzgang@gmail.com> Co-authored-by: GatewayJ <835269233@qq.com> Co-authored-by: hector <42570491+majinghe@users.noreply.github.com>
479 lines
30 KiB
Python
479 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise candidate substitution and complete-chain acceptance boundaries."""
|
|
import copy
|
|
import hashlib
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import subprocess
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
import zipfile
|
|
|
|
import functional_chain_evidence as evidence
|
|
import resolve_functional_candidate as candidate
|
|
|
|
|
|
class CandidateTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.run = {"id": 123, "run_attempt": 2, "head_sha": "a" * 40}
|
|
self.manifest = {"schema": 2, "workflow_sha": "a" * 40, "source_sha": "b" * 40, "source_ref": "release",
|
|
"build_run_id": 123, "build_run_attempt": 2, "package_sha256": "c" * 64,
|
|
"package_url": "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/runs/123/2/" + "c" * 64 + "/rustfs.deb"}
|
|
|
|
def archive(self, names=None):
|
|
output = io.BytesIO()
|
|
with zipfile.ZipFile(output, "w") as archive:
|
|
for name in names or ["nightly-candidate-123-2.json"]:
|
|
archive.writestr(name, json.dumps(self.manifest))
|
|
payload = output.getvalue()
|
|
artifact = {"id": 789, "name": "nightly-candidate-123-2", "expired": False, "size_in_bytes": len(payload),
|
|
"digest": "sha256:" + hashlib.sha256(payload).hexdigest(), "workflow_run": {"id": 123, "head_sha": "a" * 40}}
|
|
return payload, artifact
|
|
|
|
def test_distinct_build_source_preserves_both_identities(self):
|
|
payload, artifact = self.archive()
|
|
result = candidate.read_manifest(payload, artifact, self.run)
|
|
self.assertEqual(result["source_sha"], "b" * 40)
|
|
self.assertEqual(result["workflow_sha"], "a" * 40)
|
|
self.assertEqual(result["source_ref"], "release")
|
|
|
|
def test_legacy_requires_the_build_and_workflow_sha_to_agree(self):
|
|
self.manifest["schema"] = 1
|
|
del self.manifest["workflow_sha"], self.manifest["source_ref"]
|
|
with self.assertRaisesRegex(ValueError, "legacy"):
|
|
candidate.validate_manifest(self.manifest, self.run)
|
|
self.manifest["source_sha"] = self.run["head_sha"]
|
|
candidate.validate_manifest(self.manifest, self.run)
|
|
|
|
def test_manifest_substitutions_fail(self):
|
|
for key, value in (("workflow_sha", "d" * 40), ("build_run_id", 124), ("build_run_attempt", 1),
|
|
("package_sha256", "d" * 64), ("package_url", "https://example.com/package.deb"),
|
|
("schema", True), ("source_ref", "release\nFORGED=value")):
|
|
with self.subTest(key=key), self.assertRaises(ValueError):
|
|
candidate.validate_manifest({**self.manifest, key: value}, self.run)
|
|
|
|
def test_artifact_substitutions_and_archive_members_fail(self):
|
|
payload, artifact = self.archive()
|
|
for key, value in (("expired", True), ("name", "nightly-candidate-123-1"), ("size_in_bytes", 1),
|
|
("digest", "sha256:" + "d" * 64), ("workflow_run", {"id": 124, "head_sha": "a" * 40})):
|
|
with self.subTest(key=key), self.assertRaises(ValueError):
|
|
candidate.read_manifest(payload, {**artifact, key: value}, self.run)
|
|
for names in (["../nightly-candidate-123-2.json"], ["nightly-candidate-123-2.json", "extra.json"]):
|
|
payload, artifact = self.archive(names)
|
|
with self.assertRaises(ValueError):
|
|
candidate.read_manifest(payload, artifact, self.run)
|
|
|
|
def test_resolver_uses_attempt_metadata_and_does_not_resolve_moving_branch(self):
|
|
payload, artifact = self.archive()
|
|
run = {**self.run, "path": ".github/workflows/nightly-gnu.yml", "head_branch": "main", "head_repository": {"full_name": "rustfs/rustfs"},
|
|
"event": "schedule", "status": "completed", "conclusion": "success", "run_started_at": "2026-09-12T00:00:00Z"}
|
|
with mock.patch.object(candidate, "api", side_effect=[run, {"artifacts": [artifact]}, payload]) as api:
|
|
result = candidate.resolve(123, 2)
|
|
self.assertTrue(api.call_args_list[0].args[0].endswith("/attempts/2"))
|
|
self.assertFalse(any("branches/" in call.args[0] for call in api.call_args_list))
|
|
self.assertEqual(result["artifact_id"], 789)
|
|
self.assertEqual(result["manifest"]["source_sha"], "b" * 40)
|
|
|
|
|
|
class EvidenceTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.directory = Path(self.temp.name)
|
|
self.chain = {"run_id": 456, "attempt": 1, "candidate": {"source_sha": "a" * 40, "workflow_sha": "b" * 40}}
|
|
self.needs = {suite: {"result": "success"} for suite in evidence.SUITES}
|
|
for suite in evidence.SUITES:
|
|
value = {"schema": 1, "suite": suite, "chain": self.chain, "valid": True, "report_sha256": "c" * 64,
|
|
"counts": {"PASS": 1, "FAIL": 0, "SKIP": 0, "UNSUPPORTED": 0, "RUNNING": 0}}
|
|
(self.directory / (suite + ".json")).write_text(json.dumps(value))
|
|
|
|
def test_complete_chain_retains_source_identity(self):
|
|
result = evidence.aggregate(self.chain, self.directory, self.needs)
|
|
self.assertTrue(result["complete"])
|
|
self.assertEqual(result["chain"], self.chain)
|
|
self.assertEqual([suite["suite"] for suite in result["suites"]], [
|
|
"upgrade", "s3", "kms", "tier", "storage", "heal", "pool", "security", "replication",
|
|
"fault-tolerance", "table", "performance"])
|
|
|
|
def test_release_suites_cannot_be_missing_failed_or_have_missing_artifacts(self):
|
|
for suite in ("fault-tolerance", "table"):
|
|
with self.subTest(suite=suite):
|
|
missing = {key: value for key, value in self.needs.items() if key != suite}
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, missing)
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, {**self.needs, suite: {"result": "failure"}})
|
|
path = self.directory / (suite + ".json")
|
|
original = path.read_text()
|
|
path.unlink()
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, self.needs)
|
|
path.write_text(original)
|
|
|
|
def test_failure_report_preserves_counts_without_weakening_success_gate(self):
|
|
path = self.directory / "s3.json"
|
|
record = json.loads(path.read_text())
|
|
record.update(valid=False, counts={**record["counts"], "FAIL": 2}, error="failed cases")
|
|
path.write_text(json.dumps(record))
|
|
needs = {**self.needs, "s3": {"result": "failure"}, "performance": {"result": "cancelled"}}
|
|
(self.directory / "performance.json").unlink()
|
|
result = evidence.summarize(self.chain, self.directory, needs)
|
|
self.assertFalse(result["complete"])
|
|
self.assertEqual(result["lanes"][1]["evidence"]["counts"]["FAIL"], 2)
|
|
self.assertEqual(result["lanes"][-1]["result"], "cancelled")
|
|
self.assertIsNone(result["lanes"][-1]["evidence"])
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, needs)
|
|
|
|
def test_preparation_failure_still_has_all_required_lanes(self):
|
|
result = evidence.summarize(None, self.directory / "absent", {"prepare": {"result": "failure"}})
|
|
self.assertFalse(result["complete"])
|
|
self.assertEqual([lane["suite"] for lane in result["lanes"]], list(evidence.SUITES))
|
|
self.assertIn("| performance | missing | missing |", evidence.render_summary(result))
|
|
self.assertIn("Preparation: failure", evidence.render_summary(result))
|
|
|
|
def test_malformed_or_cross_attempt_record_is_not_reused(self):
|
|
for payload in ("broken json", "[]", json.dumps({"suite": "s3", "chain": {"attempt": 9}})):
|
|
(self.directory / "s3.json").write_text(payload)
|
|
result = evidence.summarize(self.chain, self.directory, self.needs)
|
|
self.assertFalse(result["complete"])
|
|
self.assertIsNone(result["lanes"][1]["evidence"])
|
|
|
|
def test_missing_failed_cancelled_or_skipped_lane_never_passes(self):
|
|
for state in ("failure", "cancelled", "skipped", "pending"):
|
|
with self.subTest(state=state), self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, {**self.needs, "s3": {"result": state}})
|
|
del self.needs["s3"]
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, self.needs)
|
|
|
|
def test_partial_rerun_missing_artifact_and_zero_test_fail(self):
|
|
path = self.directory / "s3.json"
|
|
original = json.loads(path.read_text())
|
|
values = [{**original, "chain": {**self.chain, "attempt": 2}},
|
|
{**original, "counts": {**original["counts"], "PASS": 0}},
|
|
{**original, "counts": {**original["counts"], "FAIL": 1}},
|
|
{**original, "valid": False}, {**original, "report_sha256": ""}]
|
|
for value in values:
|
|
path.write_text(json.dumps(value))
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, self.needs)
|
|
path.unlink()
|
|
with self.assertRaises(ValueError):
|
|
evidence.aggregate(self.chain, self.directory, self.needs)
|
|
|
|
def test_reports_count_case_status_not_cleanup_status(self):
|
|
text = "| Topology | Case | Name | Status | Cleanup |\n| --- | --- | --- | --- | --- |\n| sns | TIER-1 | test | UNSUPPORTED | PASS |\n| sns | TIER-2 | test | FAIL | PASS |\n"
|
|
counts = evidence.report_counts(text)
|
|
self.assertEqual(counts["PASS"], 0)
|
|
self.assertEqual(counts["FAIL"], 1)
|
|
self.assertEqual(counts["UNSUPPORTED"], 1)
|
|
self.assertEqual(evidence.report_counts("")["PASS"], 0)
|
|
|
|
def test_performance_needs_real_complete_metrics(self):
|
|
header = "method\tsize\tthroughput\tobj_per_s\treq_avg\treq_p50\treq_p90\treq_p99\n"
|
|
row = "put\t1MiB\t100MiB/s\t100\t1ms\t1ms\t2ms\t3ms\n"
|
|
self.assertEqual(evidence.report_counts(header + row, True)["PASS"], 1)
|
|
self.assertEqual(evidence.report_counts(header, True)["PASS"], 0)
|
|
with self.assertRaises(ValueError):
|
|
evidence.report_counts(header + row + row, True)
|
|
with self.assertRaises(ValueError):
|
|
evidence.report_counts(header + "put\t1MiB\t\t\t\t\t\t\n", True)
|
|
|
|
def test_fault_tolerance_requires_complete_known_verdicts(self):
|
|
passing = "FT-CASE: A-read verdict=pass observed expected result\n"
|
|
divergent = "FT-CASE: C2-read verdict=known-divergence lock majority unavailable\n"
|
|
summary = "FT-SUMMARY: unexpected=0 known-divergence=1 strict=0\n"
|
|
counts = evidence.fault_tolerance_counts(passing + divergent + summary)
|
|
self.assertEqual(counts["PASS"], 1)
|
|
self.assertEqual(counts["UNSUPPORTED"], 1)
|
|
for invalid in (passing, passing + passing + summary, passing + divergent + summary.replace("strict=0", "strict=1"),
|
|
passing + divergent + summary.replace("known-divergence=1", "known-divergence=0"),
|
|
passing.replace("verdict=pass", "verdict=UNKNOWN") + summary):
|
|
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
|
|
evidence.fault_tolerance_counts(invalid)
|
|
|
|
|
|
class EnvelopeTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
(self.root / ".config").mkdir()
|
|
(self.root / ".config/functional-script-revision.txt").write_text("d" * 40)
|
|
manifest = {"schema": 2, "workflow_sha": "a" * 40, "source_sha": "b" * 40, "source_ref": "release",
|
|
"build_run_id": 123, "build_run_attempt": 2, "package_sha256": "c" * 64,
|
|
"package_url": "https://dl.rustfs.com/artifacts/rustfs/packages/nightly/runs/123/2/" + "c" * 64 + "/rustfs.deb"}
|
|
self.chain = {"schema": 1, "run_id": 456, "attempt": 3, "workflow_sha": "e" * 40, "testing_sha": "d" * 40,
|
|
"candidate": {"manifest": manifest, "artifact_id": 789, "artifact_digest": "sha256:" + "f" * 64,
|
|
"workflow_sha": "a" * 40, "workflow_ref": "main", "build_started_at": "2026-09-12T00:00:00Z"}}
|
|
self.env = {"CHAIN_MANIFEST": json.dumps(self.chain), "GITHUB_RUN_ID": "456", "GITHUB_RUN_ATTEMPT": "3", "GITHUB_SHA": "e" * 40,
|
|
"GITHUB_ENV": str(self.root / "env"), "GITHUB_OUTPUT": str(self.root / "output"),
|
|
"CHAIN_JOB_STATUS": "success", "CHAIN_TEST_OUTCOME": "success", "CHAIN_REPORT_OUTCOME": "success"}
|
|
|
|
def test_consumer_exports_the_same_package_and_checksum_to_installers(self):
|
|
with mock.patch.object(evidence, "ROOT", self.root), mock.patch.dict(evidence.os.environ, self.env), mock.patch.object(evidence.subprocess, "check_output", return_value="e" * 40):
|
|
evidence.consume(evidence.current_chain())
|
|
variables = dict(line.split("=", 1) for line in (self.root / "env").read_text().splitlines())
|
|
self.assertEqual(variables["PACKAGE_SHA256"], "c" * 64)
|
|
self.assertEqual(variables["TO_SHA256"], "c" * 64)
|
|
self.assertEqual(variables["RUSTFS_NIGHTLY_PACKAGE_URL"], self.chain["candidate"]["manifest"]["package_url"])
|
|
|
|
def test_partial_rerun_and_wrong_lane_checkout_fail_before_install(self):
|
|
for changes, head in (({"GITHUB_RUN_ATTEMPT": "4"}, "e" * 40), ({}, "f" * 40), ({"GITHUB_RUN_ID": "457"}, "e" * 40)):
|
|
with mock.patch.object(evidence, "ROOT", self.root), mock.patch.dict(evidence.os.environ, {**self.env, **changes}), mock.patch.object(evidence.subprocess, "check_output", return_value=head), self.assertRaises(ValueError):
|
|
evidence.current_chain()
|
|
self.assertFalse((self.root / "env").exists())
|
|
|
|
def test_testing_sha_fallback_is_accepted_while_garbage_is_rejected(self):
|
|
# prepare's >24h staleness fallback legitimately sets testing_sha to
|
|
# auto-testing main HEAD, which differs from the committed pin; only
|
|
# the sha format is an invariant now.
|
|
for testing_sha, ok in (("d" * 40, True), ("1" * 40, True), ("xyz", False), ("", False)):
|
|
chain = dict(self.chain, testing_sha=testing_sha)
|
|
env = dict(self.env, CHAIN_MANIFEST=json.dumps(chain))
|
|
if ok:
|
|
with mock.patch.object(evidence, "ROOT", self.root), mock.patch.dict(evidence.os.environ, env), mock.patch.object(evidence.subprocess, "check_output", return_value="e" * 40):
|
|
evidence.consume(evidence.current_chain())
|
|
else:
|
|
with mock.patch.object(evidence, "ROOT", self.root), mock.patch.dict(evidence.os.environ, env), mock.patch.object(evidence.subprocess, "check_output", return_value="e" * 40), self.assertRaises(ValueError):
|
|
evidence.current_chain()
|
|
|
|
def test_report_or_swallowed_test_failure_cannot_produce_valid_evidence(self):
|
|
report = self.root / "cases.md"
|
|
report.write_text("| Case | Name | Status |\n| --- | --- | --- |\n| KMS-1 | fixture | PASS |\n")
|
|
for index, (key, status) in enumerate((("CHAIN_TEST_OUTCOME", "failure"), ("CHAIN_REPORT_OUTCOME", "failure"), ("CHAIN_JOB_STATUS", "cancelled"))):
|
|
output = self.root / str(index) / "kms.json"
|
|
Path(self.env["GITHUB_OUTPUT"]).unlink(missing_ok=True)
|
|
with mock.patch.dict(evidence.os.environ, {**self.env, key: status}), mock.patch.object(evidence.subprocess, "check_output", return_value="d" * 40), self.assertRaises(ValueError):
|
|
evidence.record(self.chain, "kms", report, output)
|
|
self.assertFalse(json.loads(output.read_text())["valid"])
|
|
self.assertIn("error", json.loads(output.read_text()))
|
|
self.assertEqual(Path(self.env["GITHUB_OUTPUT"]).read_text(), "written=true\n")
|
|
output = self.root / "success" / "kms.json"
|
|
with mock.patch.dict(evidence.os.environ, self.env), mock.patch.object(evidence.subprocess, "check_output", return_value="d" * 40):
|
|
evidence.record(self.chain, "kms", report, output)
|
|
self.assertTrue(json.loads(output.read_text())["valid"])
|
|
|
|
def test_collision_or_failed_write_never_authorizes_evidence_upload(self):
|
|
report = self.root / "cases.md"
|
|
report.write_text("| Case | Status |\n| --- | --- |\n| KMS-1 | PASS |\n")
|
|
output = self.root / "stale" / "kms.json"
|
|
output.parent.mkdir()
|
|
output.write_text("OLD RUN EVIDENCE")
|
|
with mock.patch.dict(evidence.os.environ, self.env), mock.patch.object(evidence.subprocess, "check_output", return_value="d" * 40):
|
|
with self.assertRaises(FileExistsError):
|
|
evidence.record(self.chain, "kms", report, output)
|
|
self.assertEqual(output.read_text(), "OLD RUN EVIDENCE")
|
|
self.assertFalse(Path(self.env["GITHUB_OUTPUT"]).exists())
|
|
with mock.patch.object(Path, "write_text", side_effect=OSError("disk full")), self.assertRaises(OSError):
|
|
evidence.record(self.chain, "kms", report, self.root / "new" / "kms.json")
|
|
self.assertFalse(Path(self.env["GITHUB_OUTPUT"]).exists())
|
|
|
|
def test_record_uses_workspace_testing_checkout_for_nested_lane(self):
|
|
lane_checkout = self.root / "rustfs-repo"
|
|
lane_checkout.mkdir()
|
|
private_checkout = self.root / "auto-testing"
|
|
private_checkout.mkdir()
|
|
subprocess.run(["git", "init", "--quiet", str(private_checkout)], check=True)
|
|
subprocess.run(["git", "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid",
|
|
"-c", "commit.gpgSign=false", "commit", "--allow-empty", "-qm", "fixture"],
|
|
cwd=private_checkout, check=True)
|
|
private_head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=private_checkout, text=True).strip()
|
|
chain = {**self.chain, "testing_sha": private_head}
|
|
report = self.root / "security.md"
|
|
report.write_text("| Case | Status |\n| --- | --- |\n| SEC-1 | PASS |\n")
|
|
env = {**self.env, "GITHUB_WORKSPACE": str(self.root)}
|
|
output = self.root / "nested-success" / "security.json"
|
|
with mock.patch.object(evidence, "ROOT", lane_checkout), mock.patch.dict(evidence.os.environ, env):
|
|
evidence.record(chain, "security", report, output)
|
|
self.assertTrue(json.loads(output.read_text())["valid"])
|
|
wrong_pin_output = self.root / "nested-wrong-pin" / "security.json"
|
|
with self.assertRaisesRegex(ValueError, "suite used a different private script revision"):
|
|
evidence.record({**chain, "testing_sha": "f" * 40}, "security", report, wrong_pin_output)
|
|
self.assertFalse(json.loads(wrong_pin_output.read_text())["valid"])
|
|
fallback_output = self.root / "local-success" / "security.json"
|
|
with mock.patch.object(evidence, "ROOT", self.root), mock.patch.dict(evidence.os.environ, self.env):
|
|
evidence.os.environ.pop("GITHUB_WORKSPACE", None)
|
|
evidence.record(chain, "security", report, fallback_output)
|
|
self.assertTrue(json.loads(fallback_output.read_text())["valid"])
|
|
|
|
def test_unknown_status_cannot_hide_among_passing_cases(self):
|
|
text = "| Case | Name | Status |\n| --- | --- | --- |\n| KMS-1 | fixture | PASS |\n| KMS-2 | fixture | NOT RUN |\n"
|
|
with self.assertRaises(ValueError):
|
|
evidence.report_counts(text)
|
|
|
|
def test_release_suite_records_require_successful_execution_and_complete_reports(self):
|
|
reports = {
|
|
"table": "| Case | Name | Status |\n| --- | --- | --- |\n| TBL-101 | Iceberg smoke | PASS |\n",
|
|
"fault-tolerance": "FT-CASE: A-read verdict=pass read succeeded\nFT-SUMMARY: unexpected=0 known-divergence=0 strict=0\n",
|
|
}
|
|
for suite, text in reports.items():
|
|
report = self.root / (suite + ".txt")
|
|
report.write_text(text)
|
|
for status in ("success", "failure"):
|
|
with self.subTest(suite=suite, status=status):
|
|
output = self.root / (suite + "-" + status) / (suite + ".json")
|
|
with mock.patch.dict(evidence.os.environ, {**self.env, "CHAIN_TEST_OUTCOME": status}), \
|
|
mock.patch.object(evidence.subprocess, "check_output", return_value="d" * 40):
|
|
if status == "success":
|
|
evidence.record(self.chain, suite, report, output)
|
|
else:
|
|
with self.assertRaises(ValueError):
|
|
evidence.record(self.chain, suite, report, output)
|
|
self.assertEqual(json.loads(output.read_text())["valid"], status == "success")
|
|
|
|
def test_driver_passes_one_manifest_and_runs_every_lane_after_failure(self):
|
|
from check_test_wiring import yaml_block
|
|
lines = (candidate.ROOT / ".github/workflows/rustfs-functional-chain.yml").read_text().splitlines()
|
|
previous = None
|
|
for suite in evidence.SUITES:
|
|
job = "\n".join(yaml_block(lines, suite, 2))
|
|
self.assertIn("needs: [prepare" + (", " + previous if previous else "") + "]", job)
|
|
if suite == "performance":
|
|
# gated on the preflight probe: runs only when its fleet is online
|
|
self.assertIn("if: ${{ always() && needs.prepare.result == 'success' && needs.prepare.outputs.performance_ready == 'online' }}", job)
|
|
else:
|
|
self.assertIn("if: ${{ always() && needs.prepare.result == 'success' }}", job)
|
|
self.assertIn("chain_manifest: ${{ needs.prepare.outputs.manifest }}", job)
|
|
previous = suite
|
|
complete = "\n".join(yaml_block(lines, "complete-chain", 2))
|
|
self.assertIn("needs: [prepare, " + ", ".join(evidence.SUITES) + "]", complete)
|
|
self.assertIn("functional_chain_evidence.py aggregate", complete)
|
|
self.assertIn("functional_chain_evidence.py summarize", complete)
|
|
self.assertIn("functional-chain-report-", complete)
|
|
self.assertIn("needs.prepare.result != 'skipped'", complete)
|
|
prepare = "\n".join(yaml_block(lines, "prepare", 2))
|
|
self.assertIn("Check shared functional fleet runner before scheduling suites", prepare)
|
|
self.assertIn("check_functional_runners.py smoke-testing", prepare)
|
|
self.assertIn("Probe performance fleet runner", prepare)
|
|
self.assertIn("check_functional_runners.py pf-testing", prepare)
|
|
self.assertIn("performance_ready: ${{ steps.perf_probe.outputs.performance_ready }}", prepare)
|
|
|
|
def test_every_lane_retains_failed_evidence_and_identifies_its_own_attempt(self):
|
|
paths = list((candidate.ROOT / ".github/workflows").glob("rustfs-*-test.yml"))
|
|
lanes = [path for path in paths if "name: Upload chain evidence" in path.read_text()]
|
|
self.assertEqual(len(lanes), len(evidence.SUITES))
|
|
for path in lanes:
|
|
with self.subTest(path=path.name):
|
|
text = path.read_text()
|
|
self.assertIn("if: ${{ always() && steps.chain_record.outputs.written == 'true' }}", text)
|
|
self.assertIn("python3 auto-testing/scripts/issue_manager.py handle", text)
|
|
self.assertIn('--run-id "${GITHUB_RUN_ID}"', text)
|
|
self.assertIn('--attempt "${GITHUB_RUN_ATTEMPT}"', text)
|
|
run_url = next(line for line in text.splitlines() if "--run-url" in line)
|
|
self.assertIn('/attempts/${GITHUB_RUN_ATTEMPT}#summary"', run_url)
|
|
|
|
def test_table_issue_manager_receives_outcome_and_case_evidence(self):
|
|
text = (candidate.ROOT / ".github/workflows/rustfs-table-test.yml").read_text()
|
|
self.assertIn("--outcome '${{ steps.test.outcome }}'", text)
|
|
self.assertIn('--report "${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"', text)
|
|
|
|
def test_pool_topology_is_checked_before_cleanup_with_four_node_defaults(self):
|
|
text = (candidate.ROOT / ".github/workflows/rustfs-pool-expand-test.yml").read_text()
|
|
self.assertIn("http://rustfs-node4:9000", text)
|
|
self.assertIn("vars.RUSTFS_NODES || 'vm000 vm001 vm002'", text)
|
|
self.assertLess(text.index("name: Validate pool topology"), text.index("name: Cleanup environment (before)"))
|
|
self.assertIn("steps.topology.outcome == 'success' && inputs.cleanup_after", text)
|
|
|
|
|
|
class RunnerTests(unittest.TestCase):
|
|
def test_offline_or_missing_runner_fails_before_dispatch(self):
|
|
import check_functional_runners as runners
|
|
for inventory in ([], [{"status": "offline", "labels": [{"name": "pf-testing"}]}]):
|
|
with mock.patch.object(runners, "api", return_value={"runners": inventory}), self.assertRaisesRegex(ValueError, "pf-testing"):
|
|
runners.check(["pf-testing"])
|
|
|
|
def test_busy_online_runner_is_available_and_inventory_is_paginated(self):
|
|
import check_functional_runners as runners
|
|
pages = [{"runners": [{"status": "online", "labels": []}] * 100},
|
|
{"runners": [{"status": "online", "busy": True, "labels": [{"name": "pf-testing"}]}]}]
|
|
with mock.patch.object(runners, "api", side_effect=pages) as api:
|
|
runners.check(["pf-testing"])
|
|
self.assertIn("page=2", api.call_args.args[0])
|
|
|
|
def test_unavailable_inventory_does_not_assume_online(self):
|
|
import check_functional_runners as runners
|
|
with mock.patch.object(runners, "api", side_effect=OSError("forbidden")), self.assertRaises(OSError):
|
|
runners.check(["pf-testing"])
|
|
|
|
def test_release_lanes_use_pinned_scripts_and_verified_package_before_recording(self):
|
|
for suite, report in (("fault-tolerance", "suite.log"), ("table", "cases.md")):
|
|
with self.subTest(suite=suite):
|
|
workflow = (candidate.ROOT / f".github/workflows/rustfs-{suite}-test.yml").read_text()
|
|
self.assertIn(" workflow_call:", workflow)
|
|
self.assertIn(" workflow_dispatch:", workflow)
|
|
self.assertIn("group: rustfs-shared-functional-tests-v2", workflow)
|
|
expected_ref = (
|
|
"ref: ${{ steps.chain.outputs.testing_sha || inputs.auto_testing_ref || 'main' }}"
|
|
if suite == "fault-tolerance"
|
|
else "ref: ${{ steps.chain.outputs.testing_sha || 'main' }}"
|
|
)
|
|
self.assertIn(expected_ref, workflow)
|
|
self.assertIn("steps.chain_package.outputs.package_url || inputs.package_url", workflow)
|
|
self.assertLess(workflow.index("prepare_functional_package.py prepare"), workflow.index("id: test"))
|
|
self.assertIn("prepare_functional_package.py cleanup", workflow)
|
|
self.assertIn(f"functional_chain_evidence.py record --suite {suite}", workflow)
|
|
self.assertIn('--report "${FUNCTIONAL_ARTIFACTS_DIR}/' + report + '"', workflow)
|
|
self.assertIn("CHAIN_TEST_OUTCOME: ${{ steps.test.outcome }}", workflow)
|
|
self.assertIn("CHAIN_REPORT_OUTCOME: ${{ steps.chain_report.outcome }}", workflow)
|
|
|
|
|
|
class WorkflowTimeoutTests(unittest.TestCase):
|
|
def test_non_performance_suites_have_hard_and_step_deadlines(self):
|
|
from check_test_wiring import yaml_block
|
|
from test_security_workflow import FunctionalWorkflowTests, named_steps
|
|
jobs = {**FunctionalWorkflowTests.JOBS, "security": "security-test"}
|
|
jobs.pop("performance")
|
|
self.assertEqual(len(jobs), 11)
|
|
for suite, job_id in jobs.items():
|
|
with self.subTest(suite=suite):
|
|
source = (candidate.ROOT / f".github/workflows/rustfs-{suite}-test.yml").read_text()
|
|
job = yaml_block(source.splitlines(), job_id, 2)
|
|
job_timeout = 360 if suite == "pool-expand" else 60
|
|
self.assertIn(f" timeout-minutes: {job_timeout}", job)
|
|
steps = named_steps(job)
|
|
primary = [step for step in steps.values() if any(
|
|
line in (" id: test", " id: pool_test") for line in step)]
|
|
self.assertEqual(len(primary), 1)
|
|
if suite == "pool-expand":
|
|
self.assertIn(" timeout-minutes: ${{ fromJSON(inputs.pool_timeout_minutes || '240') }}", primary[0])
|
|
for event in ("workflow_call", "workflow_dispatch"):
|
|
event_block = yaml_block(source.splitlines(), event, 2)
|
|
timeout_input = yaml_block(event_block, "pool_timeout_minutes", 6)
|
|
self.assertIsNotNone(timeout_input, event)
|
|
self.assertIn(" default: '240'", timeout_input)
|
|
self.assertIn(" required: false", timeout_input)
|
|
else:
|
|
self.assertIn(" timeout-minutes: 45", primary[0])
|
|
cleanup = "Cleanup environment"
|
|
for phase in ("before", "after"):
|
|
self.assertIn(" timeout-minutes: 5", steps[f"{cleanup} ({phase})"])
|
|
self.assertTrue(any("always()" in line for line in steps[f"{cleanup} (after)"]))
|
|
for name, step in steps.items():
|
|
if name.startswith(("Generate report", "Upload functional report", "File failure issue",
|
|
"Upload report", "Upload test logs", "Manage backlog issues")):
|
|
self.assertIn(" timeout-minutes: 2", step, name)
|
|
if suite in ("heal", "pool-expand"):
|
|
install = next(step for name, step in steps.items() if name.startswith("Install RustFS package"))
|
|
self.assertIn(" timeout-minutes: 5", install)
|
|
self.assertIn(" timeout-minutes: 5", steps["Preflight checks"])
|
|
|
|
def test_performance_is_exempt_from_functional_timeouts(self):
|
|
from check_test_wiring import yaml_block
|
|
source = (candidate.ROOT / ".github/workflows/rustfs-performance-test.yml").read_text()
|
|
job = "\n".join(yaml_block(source.splitlines(), "performance-test", 2))
|
|
self.assertIn(" timeout-minutes: 900", job)
|
|
self.assertNotIn(" timeout-minutes:", job)
|
|
self.assertIn("RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}", job)
|
|
self.assertNotIn("RUSTFS_WARP_SLEEP:", job)
|
|
self.assertIn("RUSTFS_WARP_METHODS: ${{ inputs.test_method }}", job)
|
|
self.assertIn("RUSTFS_WARP_SIZES: ${{ inputs.object_size }}", job)
|
|
self.assertIn("default: '5m'", source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|