Skip to content

Commit d892331

Browse files
Correct qualified recovery authority validation and consumer regressions
1 parent 657cdb4 commit d892331

3 files changed

Lines changed: 278 additions & 14 deletions

File tree

scripts/ci/component-release-recovery.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,9 @@
2929
from typing import Any
3030

3131
import tomllib
32-
from recovery_workflow_authority import (
33-
SOURCE_IDENTITY as RECOVERY_WORKFLOW_AUTHORITY_SOURCE,
34-
)
3532
from recovery_workflow_authority import (
3633
RecoveryWorkflowAuthorityError,
37-
authority_url,
38-
decode_authority,
34+
load_qualified_authority,
3935
verify_workflow_source,
4036
)
4137

@@ -732,14 +728,15 @@ def discover_plan(
732728
return tag, commit, plan, preparation
733729

734730

735-
def load_recovery_workflow_authority(client: PublicClient) -> dict[str, dict[str, str]]:
731+
def load_recovery_workflow_authority(
732+
client: PublicClient,
733+
) -> tuple[dict[str, dict[str, str]], dict[str, Any]]:
736734
identities = {
737735
name: (component.repository, component.default_branch)
738736
for name, component in COMPONENTS.items()
739737
}
740738
try:
741-
raw = client.bytes(authority_url(), accept="application/vnd.github.raw+json")
742-
return decode_authority(raw, identities)
739+
return load_qualified_authority(client, identities)
743740
except RecoveryWorkflowAuthorityError as error:
744741
raise RecoveryError(str(error), "default-branch-preflight") from error
745742

@@ -816,7 +813,7 @@ def verify_plan_authority(
816813
foundation = read_record(client, FOUNDATION_TAG, FOUNDATION_COMMIT, "candidate.json")
817814
if foundation.get("candidate") != "beta-continuity-foundation":
818815
raise RecoveryError("immutable candidate foundation has an unexpected identity", "plan-preflight")
819-
authority = load_recovery_workflow_authority(client)
816+
authority, authority_source = load_recovery_workflow_authority(client)
820817
branches: dict[str, str] = {}
821818
recovery_workflows: dict[str, dict[str, Any]] = {}
822819
for name, component in COMPONENTS.items():
@@ -845,7 +842,7 @@ def verify_plan_authority(
845842
).decode("utf-8")
846843
source_sha256 = verify_recovery_workflow_source(name, source, expected["sha256"])
847844
recovery_workflows[name] = {
848-
"authority": RECOVERY_WORKFLOW_AUTHORITY_SOURCE,
845+
"authority": authority_source,
849846
"default_branch": component.default_branch,
850847
"path": expected_path,
851848
"sha256": source_sha256,
@@ -1368,6 +1365,9 @@ def resolve_component(
13681365
),
13691366
}
13701367
)
1368+
authority_evidence = next(iter(recovery_workflows.values()), {}).get("authority")
1369+
if authority_evidence is not None:
1370+
state["recovery_workflow_authority"] = authority_evidence
13711371
if prepared_identity is not None:
13721372
state["release_preparation"] = {
13731373
"record_commit": record_commit,

scripts/ci/recovery_workflow_authority.py

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Validate the protected component release-recovery workflow tuple."""
1+
"""Resolve and validate the qualified component recovery-workflow authority."""
22

33
from __future__ import annotations
44

@@ -8,15 +8,22 @@
88
from collections.abc import Mapping
99
from typing import Any
1010

11-
SCHEMA = "durable-workflow.component-release-recovery-authority/v1"
11+
SCHEMA = "durable-workflow.component-release-recovery-authority/v2"
1212
CONTROL_REPOSITORY = "durable-workflow/.github"
1313
AUTHORITY_REF = "main"
1414
AUTHORITY_PATH = "release-recovery/authority.json"
15+
QUALIFICATION_WORKFLOW = ".github/workflows/beta-candidate.yml"
16+
QUALIFICATION_EVENT = "push"
17+
QUALIFICATION_REF_PATH = f"{QUALIFICATION_WORKFLOW}@{AUTHORITY_REF}"
1518
WORKFLOW_PATH = ".github/workflows/release-plan-recovery.yml"
1619
SOURCE_IDENTITY = {
1720
"repository": CONTROL_REPOSITORY,
1821
"ref": f"refs/heads/{AUTHORITY_REF}",
1922
"path": AUTHORITY_PATH,
23+
"qualification": {
24+
"workflow": QUALIFICATION_WORKFLOW,
25+
"event": QUALIFICATION_EVENT,
26+
},
2027
}
2128

2229

@@ -28,13 +35,123 @@ def normalized_source_sha256(source: str) -> str:
2835
return hashlib.sha256(source.replace("\r\n", "\n").encode("utf-8")).hexdigest()
2936

3037

31-
def authority_url() -> str:
38+
def authority_ref_url() -> str:
39+
return f"https://api.github.com/repos/{CONTROL_REPOSITORY}/commits/{AUTHORITY_REF}"
40+
41+
42+
def authority_url(commit: str) -> str:
3243
return (
3344
f"https://api.github.com/repos/{CONTROL_REPOSITORY}/contents/{AUTHORITY_PATH}"
34-
f"?ref={AUTHORITY_REF}"
45+
f"?ref={commit}"
46+
)
47+
48+
49+
def qualification_runs_url(commit: str) -> str:
50+
workflow = QUALIFICATION_WORKFLOW.rsplit("/", 1)[-1]
51+
return (
52+
f"https://api.github.com/repos/{CONTROL_REPOSITORY}/actions/workflows/{workflow}/runs"
53+
f"?branch={AUTHORITY_REF}&event={QUALIFICATION_EVENT}&head_sha={commit}&per_page=100"
54+
)
55+
56+
57+
def validate_authority_commit(value: Any) -> str:
58+
commit = value.get("sha") if isinstance(value, dict) else None
59+
if (
60+
not isinstance(commit, str)
61+
or len(commit) != 40
62+
or any(character not in "0123456789abcdef" for character in commit)
63+
):
64+
raise RecoveryWorkflowAuthorityError("recovery workflow authority ref has an invalid commit")
65+
return commit
66+
67+
68+
def _qualification_evidence(run: dict[str, Any], commit: str) -> dict[str, Any]:
69+
run_id = run.get("id")
70+
run_attempt = run.get("run_attempt")
71+
if (
72+
not isinstance(run_id, int)
73+
or isinstance(run_id, bool)
74+
or run_id < 1
75+
or not isinstance(run_attempt, int)
76+
or isinstance(run_attempt, bool)
77+
or run_attempt < 1
78+
):
79+
raise RecoveryWorkflowAuthorityError(
80+
"recovery workflow authority qualification has an invalid run identity"
81+
)
82+
return {
83+
"workflow": QUALIFICATION_WORKFLOW,
84+
"path": run["path"],
85+
"event": QUALIFICATION_EVENT,
86+
"head_branch": AUTHORITY_REF,
87+
"head_sha": commit,
88+
"run_id": run_id,
89+
"run_attempt": run_attempt,
90+
"status": "completed",
91+
"conclusion": "success",
92+
"url": f"https://github.com/{CONTROL_REPOSITORY}/actions/runs/{run_id}",
93+
}
94+
95+
96+
def validate_authority_qualification(value: Any, commit: str) -> dict[str, Any]:
97+
runs = value.get("workflow_runs") if isinstance(value, dict) else None
98+
if not isinstance(runs, list):
99+
raise RecoveryWorkflowAuthorityError(
100+
"recovery workflow authority qualification response has an invalid shape"
101+
)
102+
103+
candidates = [
104+
run
105+
for run in runs
106+
if isinstance(run, dict)
107+
and run.get("path") in (QUALIFICATION_WORKFLOW, QUALIFICATION_REF_PATH)
108+
and run.get("event") == QUALIFICATION_EVENT
109+
and run.get("head_branch") == AUTHORITY_REF
110+
]
111+
if not candidates:
112+
raise RecoveryWorkflowAuthorityError(
113+
"recovery workflow authority qualification is absent for the resolved commit"
114+
)
115+
if any(run.get("head_sha") != commit for run in candidates):
116+
raise RecoveryWorkflowAuthorityError(
117+
"recovery workflow authority qualification is bound to another commit"
118+
)
119+
120+
successful = [
121+
run
122+
for run in candidates
123+
if run.get("status") == "completed" and run.get("conclusion") == "success"
124+
]
125+
if successful:
126+
return _qualification_evidence(successful[0], commit)
127+
if any(run.get("status") != "completed" for run in candidates):
128+
raise RecoveryWorkflowAuthorityError(
129+
"recovery workflow authority qualification is pending for the resolved commit"
130+
)
131+
if any(run.get("conclusion") == "cancelled" for run in candidates):
132+
raise RecoveryWorkflowAuthorityError(
133+
"recovery workflow authority qualification was cancelled for the resolved commit"
134+
)
135+
raise RecoveryWorkflowAuthorityError(
136+
"recovery workflow authority qualification failed for the resolved commit"
35137
)
36138

37139

140+
def qualified_source_identity(
141+
raw: bytes,
142+
commit: str,
143+
qualification: dict[str, Any],
144+
) -> dict[str, Any]:
145+
return {
146+
"repository": CONTROL_REPOSITORY,
147+
"ref": f"refs/heads/{AUTHORITY_REF}",
148+
"commit": commit,
149+
"path": AUTHORITY_PATH,
150+
"sha256": hashlib.sha256(raw).hexdigest(),
151+
"qualification": qualification,
152+
}
153+
154+
38155
def validate_authority(
39156
value: Any,
40157
components: Mapping[str, tuple[str, str]],
@@ -83,6 +200,20 @@ def decode_authority(
83200
return validate_authority(value, components)
84201

85202

203+
def load_qualified_authority(
204+
client: Any,
205+
components: Mapping[str, tuple[str, str]],
206+
) -> tuple[dict[str, dict[str, str]], dict[str, Any]]:
207+
commit = validate_authority_commit(client.json(authority_ref_url()))
208+
qualification = validate_authority_qualification(
209+
client.json(qualification_runs_url(commit)),
210+
commit,
211+
)
212+
raw = client.bytes(authority_url(commit), accept="application/vnd.github.raw+json")
213+
workflows = decode_authority(raw, components)
214+
return workflows, qualified_source_identity(raw, commit, qualification)
215+
216+
86217
def verify_workflow_source(name: str, source: str, expected_sha256: str) -> str:
87218
actual_sha256 = normalized_source_sha256(source)
88219
if not hmac.compare_digest(actual_sha256, expected_sha256):

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

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import hashlib
77
import importlib.util
88
import io
9+
import json
910
import sys
1011
import unittest
1112
import urllib.error
@@ -16,6 +17,15 @@
1617
CliRecoveryWorkflowSourceTest,
1718
CliReleaseAuthorityTest,
1819
)
20+
from recovery_workflow_authority import (
21+
SCHEMA as AUTHORITY_SCHEMA,
22+
)
23+
from recovery_workflow_authority import (
24+
SOURCE_IDENTITY,
25+
authority_ref_url,
26+
authority_url,
27+
qualification_runs_url,
28+
)
1929

2030
RECOVERY_SCRIPT = Path(__file__).with_name("component-release-recovery.py")
2131
RUST_WORKFLOW_FIXTURE = Path(__file__).with_name("sdk-rust-release-plan-recovery.fixture.yml")
@@ -73,6 +83,129 @@ def load_recovery_for_retry_tests():
7383
return loader()
7484

7585

86+
AUTHORITY_COMMIT = "a" * 40
87+
88+
89+
def qualification_run(
90+
status: str = "completed",
91+
conclusion: str | None = "success",
92+
*,
93+
head_sha: str = AUTHORITY_COMMIT,
94+
head_branch: str = "main",
95+
path: str = ".github/workflows/beta-candidate.yml",
96+
) -> dict[str, object]:
97+
return {
98+
"id": 81,
99+
"run_attempt": 2,
100+
"name": "Beta candidate",
101+
"workflow_id": 37,
102+
"path": path,
103+
"event": "push",
104+
"head_branch": head_branch,
105+
"head_sha": head_sha,
106+
"status": status,
107+
"conclusion": conclusion,
108+
"url": "https://api.github.com/repos/durable-workflow/.github/actions/runs/81",
109+
"html_url": "https://github.com/durable-workflow/.github/actions/runs/81",
110+
}
111+
112+
113+
class QualifiedAuthorityConsumerTest(unittest.TestCase):
114+
@classmethod
115+
def setUpClass(cls) -> None:
116+
cls.recovery = load_recovery_for_retry_tests()
117+
118+
def authority(self) -> dict[str, object]:
119+
return {
120+
"schema": AUTHORITY_SCHEMA,
121+
"source": SOURCE_IDENTITY,
122+
"workflows": {
123+
name: {
124+
"repository": component.repository,
125+
"ref": f"refs/heads/{component.default_branch}",
126+
"path": ".github/workflows/release-plan-recovery.yml",
127+
"state": "active",
128+
"sha256": "b" * 64,
129+
}
130+
for name, component in self.recovery.COMPONENTS.items()
131+
},
132+
}
133+
134+
def client(self, runs: list[dict[str, object]]):
135+
authority_raw = json.dumps(self.authority()).encode("utf-8")
136+
137+
class Client:
138+
def __init__(self) -> None:
139+
self.requests: list[tuple[str, str]] = []
140+
141+
def json(self, url: str) -> dict[str, object]:
142+
self.requests.append(("json", url))
143+
if url == authority_ref_url():
144+
return {"sha": AUTHORITY_COMMIT}
145+
if url == qualification_runs_url(AUTHORITY_COMMIT):
146+
return {"total_count": len(runs), "workflow_runs": runs}
147+
raise AssertionError(f"peer source was read before authority qualification: {url}")
148+
149+
def bytes(self, url: str, *, accept: str | None = None) -> bytes:
150+
self.requests.append(("bytes", url))
151+
if url != authority_url(AUTHORITY_COMMIT):
152+
raise AssertionError(f"peer source was read before authority qualification: {url}")
153+
return authority_raw
154+
155+
return Client(), authority_raw
156+
157+
def test_green_qualification_binds_manifest_bytes_and_revision(self) -> None:
158+
client, authority_raw = self.client([qualification_run()])
159+
workflows, source = self.recovery.load_recovery_workflow_authority(client)
160+
161+
self.assertEqual(set(self.recovery.COMPONENTS), set(workflows))
162+
self.assertEqual(AUTHORITY_COMMIT, source["commit"])
163+
self.assertEqual(hashlib.sha256(authority_raw).hexdigest(), source["sha256"])
164+
self.assertEqual(AUTHORITY_COMMIT, source["qualification"]["head_sha"])
165+
self.assertEqual(".github/workflows/beta-candidate.yml", source["qualification"]["path"])
166+
self.assertEqual("main", source["qualification"]["head_branch"])
167+
self.assertEqual(
168+
[
169+
("json", authority_ref_url()),
170+
("json", qualification_runs_url(AUTHORITY_COMMIT)),
171+
("bytes", authority_url(AUTHORITY_COMMIT)),
172+
],
173+
client.requests,
174+
)
175+
176+
def test_non_green_fails_before_authority_or_peer_source_reads(self) -> None:
177+
cases = (
178+
("pending", [qualification_run("in_progress", None)], "pending"),
179+
("failed", [qualification_run("completed", "failure")], "failed"),
180+
("cancelled", [qualification_run("completed", "cancelled")], "cancelled"),
181+
("absent", [], "absent"),
182+
("revision-mismatch", [qualification_run(head_sha="c" * 40)], "another commit"),
183+
(
184+
"wrong-workflow",
185+
[qualification_run(path=".github/workflows/source-qualification.yml")],
186+
"absent",
187+
),
188+
("wrong-ref", [qualification_run(head_branch="v2")], "absent"),
189+
(
190+
"wrong-path-ref",
191+
[qualification_run(path=".github/workflows/beta-candidate.yml@v2")],
192+
"absent",
193+
),
194+
)
195+
for label, runs, message in cases:
196+
with self.subTest(state=label):
197+
client, _authority_raw = self.client(runs)
198+
with self.assertRaisesRegex(self.recovery.RecoveryError, message):
199+
self.recovery.load_recovery_workflow_authority(client)
200+
self.assertEqual(
201+
[
202+
("json", authority_ref_url()),
203+
("json", qualification_runs_url(AUTHORITY_COMMIT)),
204+
],
205+
client.requests,
206+
)
207+
208+
76209
class ContinuityGateTest(unittest.TestCase):
77210
@classmethod
78211
def setUpClass(cls) -> None:

0 commit comments

Comments
 (0)