Skip to content

Commit 4cb74ce

Browse files
Bind the release-recovery adapter to the contract it validates
1 parent 3986551 commit 4cb74ce

5 files changed

Lines changed: 139 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ jobs:
6262
--contract scripts/ci/release-recovery-consumer-contract.json \
6363
--adapter scripts/ci/release-recovery-consumer-adapter.json \
6464
"${arguments[@]}"
65+
python scripts/ci/test-component-release-recovery.py ConsumerContractIdentityRegressionTest
6566
- run: python -m build
6667
- run: twine check dist/*
6768
- run: python scripts/smoke-built-package.py

scripts/ci/release-recovery-consumer-adapter.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
"consumer": "scripts/ci/component-release-recovery.py",
44
"contract": {
55
"path": "scripts/ci/release-recovery-consumer-contract.json",
6-
"sha256": "6220a07457da22160c6522e94c019b012945ae1c9a4392ce6cf7d2cd8ab20794",
7-
"version": "1.4.0"
6+
"sha256": "b0852dc8cabf53498b1380fdeb8ffd437ec8ba7b6ad8ba5e6c6814f90126e4ab",
7+
"version": "1.4.1"
88
},
99
"distribution_verification": {
1010
"command": [
@@ -16,7 +16,7 @@
1616
"schema": "durable-workflow.release-recovery-consumer-adapter/v1",
1717
"suite": {
1818
"path": "scripts/ci/release_recovery_consumer_conformance.py",
19-
"sha256": "4efc61459e77c9102bc8236de3955bca0883c04c271e7a1134b5c54819cd9f87"
19+
"sha256": "e5893129289f7af2688f9ea232b5b36d06370839840b33b0972ee6976608ec3d"
2020
},
2121
"target_branch": "main"
2222
}

scripts/ci/release-recovery-consumer-contract.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@
7676
],
7777
"schema": "durable-workflow.release-recovery-consumer-conformance/v1",
7878
"suite": {
79-
"sha256": "4efc61459e77c9102bc8236de3955bca0883c04c271e7a1134b5c54819cd9f87"
79+
"sha256": "e5893129289f7af2688f9ea232b5b36d06370839840b33b0972ee6976608ec3d"
8080
},
81-
"version": "1.4.0"
81+
"version": "1.4.1"
8282
}

scripts/ci/release_recovery_consumer_conformance.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,12 @@ def relative_file(root: Path, value: Any, label: str) -> Path:
127127
path = PurePosixPath(value)
128128
if path.is_absolute() or ".." in path.parts:
129129
raise ConformanceError(f"{label} must stay within the repository")
130-
resolved = root.joinpath(*path.parts)
130+
repository_root = root.resolve()
131+
resolved = root.joinpath(*path.parts).resolve()
132+
try:
133+
resolved.relative_to(repository_root)
134+
except ValueError as error:
135+
raise ConformanceError(f"{label} must stay within the repository") from error
131136
if not resolved.is_file():
132137
raise ConformanceError(f"{label} does not exist: {value}")
133138
return resolved
@@ -220,6 +225,7 @@ def validate_adapter(
220225
contract_sha256: str,
221226
repository_root: Path,
222227
current_suite: Path,
228+
current_contract: Path,
223229
) -> tuple[Path, list[str]]:
224230
expected_keys = {
225231
"component",
@@ -241,13 +247,19 @@ def validate_adapter(
241247
if identity not in CONSUMERS or identity not in contract["consumers"]:
242248
raise ConformanceError("consumer adapter is not in the contract target topology")
243249
contract_pin = adapter.get("contract")
244-
if (
245-
not isinstance(contract_pin, dict)
246-
or set(contract_pin) != {"path", "sha256", "version"}
247-
or contract_pin.get("version") != contract["version"]
248-
or contract_pin.get("sha256") != contract_sha256
249-
):
250-
raise ConformanceError("consumer adapter does not pin the exact contract version and digest")
250+
if not isinstance(contract_pin, dict) or set(contract_pin) != {"path", "sha256", "version"}:
251+
raise ConformanceError("consumer adapter does not declare the exact contract pin shape")
252+
adapter_contract = relative_file(repository_root, contract_pin["path"], "adapter contract")
253+
invoked_contract = current_contract.resolve()
254+
if adapter_contract != invoked_contract:
255+
raise ConformanceError("the invoked contract is not the adapter's declared contract")
256+
declared_contract, declared_contract_raw = load_json_object(adapter_contract, "adapter contract")
257+
if declared_contract.get("version") != contract_pin.get("version") or sha256_bytes(
258+
declared_contract_raw
259+
) != contract_pin.get("sha256"):
260+
raise ConformanceError("the adapter's declared contract does not match its version and digest pins")
261+
if contract_pin.get("version") != contract["version"] or contract_pin.get("sha256") != contract_sha256:
262+
raise ConformanceError("consumer adapter does not pin the exact invoked contract version and digest")
251263
suite_pin = adapter.get("suite")
252264
if (
253265
not isinstance(suite_pin, dict)
@@ -258,7 +270,6 @@ def validate_adapter(
258270
adapter_suite = relative_file(repository_root, suite_pin["path"], "adapter suite")
259271
if adapter_suite.resolve() != current_suite.resolve():
260272
raise ConformanceError("the invoked suite is not the adapter's declared suite")
261-
relative_file(repository_root, contract_pin["path"], "adapter contract")
262273
consumer = relative_file(repository_root, adapter.get("consumer"), "adapter consumer")
263274
distribution = adapter.get("distribution_verification")
264275
if (
@@ -1162,6 +1173,7 @@ def main() -> int:
11621173
contract_sha256,
11631174
repository_root,
11641175
suite_path,
1176+
contract_path,
11651177
)
11661178
module = load_consumer(consumer_path)
11671179
cases, failures = run_cases(module)

scripts/ci/test-component-release-recovery.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
from __future__ import annotations
55

6+
import copy
67
import datetime as dt
78
import hashlib
89
import importlib.util
910
import io
1011
import json
12+
import shutil
1113
import subprocess
1214
import sys
1315
import tempfile
@@ -16,6 +18,7 @@
1618
from pathlib import Path
1719
from unittest import mock
1820

21+
import release_recovery_consumer_conformance as consumer_conformance
1922
from cli_release_verifier_contract import ( # noqa: F401 - imported for unittest discovery
2023
CliRecoveryWorkflowSourceTest,
2124
CliReleaseAuthorityTest,
@@ -33,6 +36,7 @@
3336
RECOVERY_SCRIPT = Path(__file__).with_name("component-release-recovery.py")
3437
CONSUMER_CONFORMANCE_SCRIPT = Path(__file__).with_name("release_recovery_consumer_conformance.py")
3538
CONSUMER_CONTRACT_PATH = Path(__file__).with_name("release-recovery-consumer-contract.json")
39+
CONSUMER_ADAPTER_PATH = Path(__file__).with_name("release-recovery-consumer-adapter.json")
3640
RUST_WORKFLOW_FIXTURE = Path(__file__).with_name("sdk-rust-release-plan-recovery.fixture.yml")
3741
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
3842
RECOVERY_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/release-plan-recovery.yml"
@@ -236,6 +240,114 @@ def test_unavailable_previous_commit_is_rejected(self):
236240
)
237241

238242

243+
class ConsumerContractIdentityRegressionTest(unittest.TestCase):
244+
def adapter_fixture(
245+
self,
246+
root: Path,
247+
) -> tuple[dict[str, object], dict[str, object], str, Path, Path]:
248+
ci_root = root / "scripts/ci"
249+
ci_root.mkdir(parents=True)
250+
suite_path = ci_root / CONSUMER_CONFORMANCE_SCRIPT.name
251+
contract_path = ci_root / CONSUMER_CONTRACT_PATH.name
252+
consumer_path = ci_root / RECOVERY_SCRIPT.name
253+
verifier_path = ci_root / Path(__file__).name
254+
shutil.copyfile(CONSUMER_CONFORMANCE_SCRIPT, suite_path)
255+
contract = json.loads(CONSUMER_CONTRACT_PATH.read_text())
256+
contract_raw = consumer_conformance.canonical_json(contract)
257+
contract_path.write_bytes(contract_raw)
258+
consumer_path.write_text("# consumer fixture\n")
259+
verifier_path.write_text("# verifier fixture\n")
260+
adapter = json.loads(CONSUMER_ADAPTER_PATH.read_text())
261+
return (
262+
adapter,
263+
contract,
264+
consumer_conformance.sha256_bytes(contract_raw),
265+
suite_path,
266+
contract_path,
267+
)
268+
269+
def test_matching_declared_and_invoked_contract_passes(self):
270+
with tempfile.TemporaryDirectory() as temporary:
271+
root = Path(temporary)
272+
adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root)
273+
274+
consumer, command = consumer_conformance.validate_adapter(
275+
adapter,
276+
contract,
277+
digest,
278+
root,
279+
suite_path,
280+
contract_path,
281+
)
282+
283+
self.assertEqual("component-release-recovery.py", consumer.name)
284+
self.assertEqual(["{python}", "scripts/ci/test-component-release-recovery.py"], command)
285+
286+
def test_alternate_invoked_contract_is_rejected(self):
287+
with tempfile.TemporaryDirectory() as temporary:
288+
root = Path(temporary)
289+
adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root)
290+
alternate_path = contract_path.with_name("alternate-contract.json")
291+
alternate_path.write_bytes(contract_path.read_bytes())
292+
293+
with self.assertRaisesRegex(
294+
consumer_conformance.ConformanceError,
295+
"invoked contract is not the adapter's declared contract",
296+
):
297+
consumer_conformance.validate_adapter(
298+
adapter,
299+
contract,
300+
digest,
301+
root,
302+
suite_path,
303+
alternate_path,
304+
)
305+
306+
def test_stale_declared_contract_is_rejected(self):
307+
with tempfile.TemporaryDirectory() as temporary:
308+
root = Path(temporary)
309+
adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root)
310+
stale_path = contract_path.with_name("stale-contract.json")
311+
stale_contract = copy.deepcopy(contract)
312+
stale_contract["version"] = "1.4.0"
313+
stale_path.write_bytes(consumer_conformance.canonical_json(stale_contract))
314+
adapter["contract"]["path"] = stale_path.relative_to(root).as_posix()
315+
316+
with self.assertRaisesRegex(
317+
consumer_conformance.ConformanceError,
318+
"invoked contract is not the adapter's declared contract",
319+
):
320+
consumer_conformance.validate_adapter(
321+
adapter,
322+
contract,
323+
digest,
324+
root,
325+
suite_path,
326+
contract_path,
327+
)
328+
329+
def test_mismatched_declared_contract_bytes_are_rejected(self):
330+
with tempfile.TemporaryDirectory() as temporary:
331+
root = Path(temporary)
332+
adapter, contract, digest, suite_path, contract_path = self.adapter_fixture(root)
333+
mismatched_contract = copy.deepcopy(contract)
334+
mismatched_contract["cases"][0]["requirement"] += " (mismatched declared bytes)"
335+
contract_path.write_bytes(consumer_conformance.canonical_json(mismatched_contract))
336+
337+
with self.assertRaisesRegex(
338+
consumer_conformance.ConformanceError,
339+
"declared contract does not match its version and digest pins",
340+
):
341+
consumer_conformance.validate_adapter(
342+
adapter,
343+
contract,
344+
digest,
345+
root,
346+
suite_path,
347+
contract_path,
348+
)
349+
350+
239351
def load_recovery_for_retry_tests():
240352
loaded = globals().get("recovery")
241353
if loaded is not None:

0 commit comments

Comments
 (0)