Skip to content

Commit ffc0f37

Browse files
Merge deployment recovery hardening (#110)
2 parents 76257bd + d37d37f commit ffc0f37

8 files changed

Lines changed: 351 additions & 18 deletions

File tree

_docs/compatibility/development-terminology-allowlist.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@
180180
},
181181
{
182182
"path": "core/tests/test_deployment_release.py",
183-
"sha256": "c905b5bd64f5defdf7ca86be5548fbbdddbad47723e715140d7efee5cfbef975",
183+
"sha256": "9d8881f2fbb73182bdafd5dcb06629e93ad08dada39a0c86e90626d8038f062a",
184184
"class": "legacy_contract_test",
185185
"reason": "Release tests exercise exact physical task, secret, repository, and tag values.",
186186
"follow_up": "#94",
@@ -196,7 +196,7 @@
196196
},
197197
{
198198
"path": "core/tests/test_deployment_workflow.py",
199-
"sha256": "b5dca94b59c246569909ea1d5bc6ce0da5d9aa5d86e729f7b0e97dc98f284cf8",
199+
"sha256": "e98b201c57383085dabcaf8a38cc36d167cab0b62016e2d4a1ca3c1f3354f09f",
200200
"class": "legacy_contract_test",
201201
"reason": "Workflow tests exercise removed inputs, frozen Gate-B records, and exact physical values.",
202202
"follow_up": "#94",

_docs/runbooks/development-release.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,15 @@ Local runs use `local-development-build-version-not-configured` with null source
4646
deployed smoke reject it. A strict schema-1 reader exists only for an already-active or recorded
4747
prior/rollback target and represents its VERSION as its full source SHA; it may not invent a
4848
timestamp, publish a new schema-1 image, register a schema-1 task, or write a schema-1 success
49-
record.
49+
record. Recovery of that prior target first proves the exact receipt-bound ECS task-definition
50+
pair, task identity, image digest, source SHA, terminal counts, and singleton worker. Its final
51+
public proof then requires the exact schema-1 health contract: liveness contains only
52+
`status=ok` plus `version=<full source SHA>`, and readiness contains only `status=ready` plus the
53+
successful configuration, database, and migrations checks. Schema-1 verification never accepts a
54+
schema-2 or mixed health shape. Schema-2 recovery continues to require the exact recorded
55+
VERSION/source/digest triplet on both health endpoints. A retained receipt/observation error or a
56+
failed terminal-pair proof prevents either schema's public-health request; evidence records
57+
`not_attempted` and never claims exact prior-SHA readiness in that state.
5058

5159
## One-time bootstrap
5260

@@ -812,8 +820,11 @@ failure after web mutation restores the prior exact web task definition, count,
812820
deployment ID. If worker `UpdateService` was actually invoked, its restoration is receipt-bound as
813821
well. If worker was untouched, compensation issues no worker mutation and instead read-only proves
814822
its captured task definition, count, PRIMARY deployment ID, terminal state, and singleton bound as
815-
part of the exact pair. It then validates the prior digest/SHA and public health. The database
816-
remains migrated forward.
823+
part of the exact pair. It then validates the prior digest/SHA and uses the identity schema from
824+
that proved pair to select the final public-health contract: exact legacy full-SHA health for a
825+
schema-1 prior, or the exact VERSION/source/digest triplet for schema 2. A receipt, observation, or
826+
terminal-pair failure blocks that request and records public health as not attempted and false.
827+
The database remains migrated forward.
817828

818829
The workflow concurrency group is `website-development-release` with cancellation disabled. Never
819830
cancel an in-progress release to start another one.

core/tests/test_deployment_release.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,140 @@ def test_independent_inclusive_240_420_deadlines_have_one_final_read(self) -> No
677677
self.assertFalse(any(value > 420 for value in gateway.observation_times["worker"]))
678678
self.assertEqual(gateway.current, 420)
679679

680+
def test_schema1_recovery_proves_terminal_receipts_before_legacy_public_health(self) -> None:
681+
gateway = CooperativeRecoveryGateway(complete_at={"web": 0, "worker": 0})
682+
legacy_identity = ReleaseIdentity.legacy(SHA_A, DIGEST_A, REPOSITORY)
683+
original_update = gateway.update_service
684+
685+
def update_legacy(*args, **kwargs): # type: ignore[no-untyped-def]
686+
receipt = original_update(*args, **kwargs)
687+
workload = receipt.workload
688+
snapshot = gateway.snapshots[workload]
689+
gateway.snapshots[workload] = ServiceSnapshot(
690+
service_name=snapshot.service_name,
691+
task_definition_arn=snapshot.task_definition_arn,
692+
desired_count=snapshot.desired_count,
693+
running_count=snapshot.running_count,
694+
pending_count=snapshot.pending_count,
695+
source_sha=legacy_identity.source_sha,
696+
image_digest=legacy_identity.image_digest,
697+
primary_deployment_id=snapshot.primary_deployment_id,
698+
version=legacy_identity.version,
699+
identity_schema=legacy_identity.identity_schema,
700+
)
701+
return receipt
702+
703+
gateway.update_service = update_legacy # type: ignore[method-assign]
704+
targets, terminal, attempted = self.restore_phase()
705+
attempted_states: dict[str, ServiceTarget | ServicePredecessor] = dict(attempted)
706+
707+
_compensate(
708+
gateway,
709+
targets,
710+
terminal,
711+
legacy_identity,
712+
attempted_states,
713+
)
714+
715+
terminal_index = gateway.operations.index("terminal-at:0")
716+
health_index = gateway.operations.index("health-at:0")
717+
self.assertLess(terminal_index, health_index)
718+
self.assertIn(f"health:{SHA_A}:{SHA_A}", gateway.operations)
719+
720+
def test_schema1_mixed_worker_never_attempts_or_passes_public_health(self) -> None:
721+
gateway = CooperativeRecoveryGateway(complete_at={"web": 0, "worker": 0})
722+
legacy_identity = ReleaseIdentity.legacy(SHA_A, DIGEST_A, REPOSITORY)
723+
original_update = gateway.update_service
724+
725+
def update_mixed_worker(*args, **kwargs): # type: ignore[no-untyped-def]
726+
receipt = original_update(*args, **kwargs)
727+
workload = receipt.workload
728+
snapshot = gateway.snapshots[workload]
729+
source_sha = "f" * 40 if workload == "worker" else legacy_identity.source_sha
730+
gateway.snapshots[workload] = ServiceSnapshot(
731+
service_name=snapshot.service_name,
732+
task_definition_arn=snapshot.task_definition_arn,
733+
desired_count=snapshot.desired_count,
734+
running_count=snapshot.running_count,
735+
pending_count=snapshot.pending_count,
736+
source_sha=source_sha,
737+
image_digest=legacy_identity.image_digest,
738+
primary_deployment_id=snapshot.primary_deployment_id,
739+
version=source_sha,
740+
identity_schema=legacy_identity.identity_schema,
741+
)
742+
return receipt
743+
744+
gateway.update_service = update_mixed_worker # type: ignore[method-assign]
745+
targets, terminal, attempted = self.restore_phase()
746+
attempted_states: dict[str, ServiceTarget | ServicePredecessor] = dict(attempted)
747+
Path(".tmp").mkdir(exist_ok=True)
748+
with tempfile.TemporaryDirectory(dir=".tmp") as directory:
749+
evidence_path = Path(directory) / "recovery-evidence.json"
750+
with self.assertRaises(CompensationError):
751+
_compensate(
752+
gateway,
753+
targets,
754+
terminal,
755+
legacy_identity,
756+
attempted_states,
757+
evidence_path=evidence_path,
758+
)
759+
stages = json.loads(evidence_path.read_text())["stages"]
760+
761+
self.assertFalse(any(operation.startswith("health") for operation in gateway.operations))
762+
terminal_evidence = next(
763+
item for item in stages if item["stage"] == "recovery_terminal_pair"
764+
)
765+
public_evidence = next(item for item in stages if item["stage"] == "recovery_public_health")
766+
total_evidence = next(item for item in stages if item["stage"] == "recovery_total")
767+
self.assertEqual(terminal_evidence["result"], "contract_contradiction")
768+
self.assertEqual(public_evidence["result"], "not_attempted")
769+
self.assertEqual(public_evidence["proof"]["attempted"], False)
770+
self.assertEqual(public_evidence["proof"]["exact_prior_sha_ready"], False)
771+
self.assertEqual(total_evidence["result"], "contract_contradiction")
772+
self.assertEqual(total_evidence["proof"]["terminal_pair"], False)
773+
self.assertEqual(total_evidence["proof"]["public_health"], False)
774+
775+
def test_retained_receipt_error_blocks_schema2_public_health_after_terminal_read(self) -> None:
776+
gateway = CooperativeRecoveryGateway(complete_at={"web": 0, "worker": 0})
777+
original_observe = gateway.observe_recovery_receipt
778+
779+
def observe_with_worker_error(
780+
receipt: ServiceUpdateReceipt,
781+
*,
782+
workload_deadline: float,
783+
phase_deadline: float,
784+
) -> bool:
785+
if receipt.workload == "worker":
786+
raise ReleaseContractError("injected worker receipt contradiction")
787+
return original_observe(
788+
receipt,
789+
workload_deadline=workload_deadline,
790+
phase_deadline=phase_deadline,
791+
)
792+
793+
gateway.observe_recovery_receipt = observe_with_worker_error # type: ignore[method-assign]
794+
Path(".tmp").mkdir(exist_ok=True)
795+
with tempfile.TemporaryDirectory(dir=".tmp") as directory:
796+
evidence_path = Path(directory) / "recovery-evidence.json"
797+
with self.assertRaises(CompensationError):
798+
self.compensate(gateway, evidence_path=evidence_path)
799+
stages = json.loads(evidence_path.read_text())["stages"]
800+
801+
self.assertFalse(any(operation.startswith("health") for operation in gateway.operations))
802+
terminal_evidence = next(
803+
item for item in stages if item["stage"] == "recovery_terminal_pair"
804+
)
805+
public_evidence = next(item for item in stages if item["stage"] == "recovery_public_health")
806+
total_evidence = next(item for item in stages if item["stage"] == "recovery_total")
807+
self.assertEqual(terminal_evidence["result"], "passed")
808+
self.assertEqual(public_evidence["result"], "not_attempted")
809+
self.assertEqual(public_evidence["proof"]["attempted"], False)
810+
self.assertEqual(public_evidence["proof"]["exact_prior_sha_ready"], False)
811+
self.assertEqual(total_evidence["result"], "contract_contradiction")
812+
self.assertEqual(total_evidence["proof"]["public_health"], False)
813+
680814
def test_worker_deadline_cannot_be_rescued_by_a_later_terminal_fixture(self) -> None:
681815
gateway = CooperativeRecoveryGateway(complete_at={"web": 10, "worker": 430})
682816

core/tests/test_deployment_workflow.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
ServiceTarget,
4040
ServiceUpdateReceipt,
4141
)
42+
from deploy.legacy_development_compatibility import ECR_REPOSITORY_URI
4243
from deploy.task_definitions import (
4344
FIXED_NONSECRET_ENVIRONMENT,
4445
TaskDefinitionConfig,
@@ -5808,3 +5809,27 @@ def test_public_health_polls_until_exact_readiness_or_timeout(self) -> None:
58085809
self.assertRaisesMessage(ReleaseContractError, "ALB target readiness"),
58095810
):
58105811
gateway.verify_public_web(identity)
5812+
5813+
def test_schema1_public_recovery_uses_only_the_legacy_health_contract(self) -> None:
5814+
gateway = self.gateway(FakeMigrationEcs({}, {}))
5815+
source_sha = "a" * 40
5816+
identity = ReleaseIdentity.legacy(
5817+
source_sha,
5818+
f"sha256:{'a' * 64}",
5819+
ECR_REPOSITORY_URI,
5820+
)
5821+
gateway._service = Mock(return_value={"desiredCount": 1}) # type: ignore[method-assign]
5822+
gateway.elbv2 = Mock()
5823+
gateway.elbv2.describe_target_health.return_value = {
5824+
"TargetHealthDescriptions": [{"TargetHealth": {"State": "healthy"}}]
5825+
}
5826+
5827+
with (
5828+
patch("deploy.aws_gateway.time.monotonic", side_effect=[0] * 8),
5829+
patch("deploy.aws_gateway.verify_legacy_health") as legacy_health,
5830+
patch("deploy.aws_gateway.verify_health") as schema2_health,
5831+
):
5832+
gateway.verify_public_web(identity)
5833+
5834+
legacy_health.assert_called_once_with(gateway.config.base_url, source_sha)
5835+
schema2_health.assert_not_called()

core/tests/test_release_identity.py

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
SourceIdentityConstructor,
2525
validate_schema2_version,
2626
)
27-
from deploy.smoke import verify_health
27+
from deploy.smoke import Response, verify_health, verify_legacy_health
2828

2929
SHA = "3f6c227" + "a" * 33
3030
DIGEST = f"sha256:{'b' * 64}"
@@ -331,6 +331,127 @@ def test_task_capture_and_smoke_reject_calendar_invalid_version_before_use() ->
331331
request.assert_not_called()
332332

333333

334+
def test_schema1_health_accepts_the_exact_legacy_contract() -> None:
335+
responses = [
336+
Response(
337+
status=200,
338+
headers={"x-robots-tag": "noindex, nofollow"},
339+
body=json.dumps({"status": "ok", "version": SHA}).encode(),
340+
),
341+
Response(
342+
status=200,
343+
headers={"x-robots-tag": "noindex, nofollow"},
344+
body=json.dumps(
345+
{
346+
"status": "ready",
347+
"checks": {
348+
"configuration": {"status": "ok"},
349+
"database": {"status": "ok"},
350+
"migrations": {"status": "ok"},
351+
},
352+
}
353+
).encode(),
354+
),
355+
]
356+
357+
with patch("deploy.smoke._request", side_effect=responses) as request:
358+
verify_legacy_health("https://web.dtcdev.click", SHA)
359+
360+
assert [call.args[1] for call in request.call_args_list] == [
361+
"/health/live",
362+
"/health/ready",
363+
]
364+
365+
366+
@pytest.mark.parametrize(
367+
("live_payload", "ready_payload", "message"),
368+
[
369+
(
370+
{"status": "ok", "version": "4" * 40},
371+
{
372+
"status": "ready",
373+
"checks": {
374+
"configuration": {"status": "ok"},
375+
"database": {"status": "ok"},
376+
"migrations": {"status": "ok"},
377+
},
378+
},
379+
"exact legacy release identity",
380+
),
381+
(
382+
{"status": "ok", "version": SHA, "source_sha": SHA},
383+
{
384+
"status": "ready",
385+
"checks": {
386+
"configuration": {"status": "ok"},
387+
"database": {"status": "ok"},
388+
"migrations": {"status": "ok"},
389+
},
390+
},
391+
"exact legacy release identity",
392+
),
393+
(
394+
{"status": "ok", "version": SHA},
395+
{
396+
"status": "ready",
397+
"source_sha": "4" * 40,
398+
"checks": {
399+
"configuration": {"status": "ok"},
400+
"database": {"status": "ok"},
401+
"migrations": {"status": "ok"},
402+
},
403+
},
404+
"exact legacy readiness contract",
405+
),
406+
],
407+
)
408+
def test_schema1_health_rejects_wrong_or_mixed_identity_contracts(
409+
live_payload: dict[str, object],
410+
ready_payload: dict[str, object],
411+
message: str,
412+
) -> None:
413+
responses = [
414+
Response(
415+
status=200,
416+
headers={"x-robots-tag": "noindex, nofollow"},
417+
body=json.dumps(live_payload).encode(),
418+
),
419+
Response(
420+
status=200,
421+
headers={"x-robots-tag": "noindex, nofollow"},
422+
body=json.dumps(ready_payload).encode(),
423+
),
424+
]
425+
426+
with (
427+
patch("deploy.smoke._request", side_effect=responses),
428+
pytest.raises(ReleaseContractError, match=message),
429+
):
430+
verify_legacy_health("https://web.dtcdev.click", SHA)
431+
432+
433+
def test_schema2_health_still_rejects_a_mismatched_exact_triplet() -> None:
434+
version = f"20260809-143205-{SHA[:7]}"
435+
live = Response(
436+
status=200,
437+
headers={"x-robots-tag": "noindex, nofollow"},
438+
body=json.dumps(
439+
{
440+
"status": "ok",
441+
"version": version,
442+
"source_sha": "4" * 40,
443+
"image_digest": DIGEST,
444+
}
445+
).encode(),
446+
)
447+
448+
with (
449+
patch("deploy.smoke._request", return_value=live),
450+
pytest.raises(ReleaseContractError, match="exact release identity"),
451+
):
452+
verify_health("https://web.dtcdev.click", version, SHA, DIGEST)
453+
454+
334455
def test_schema2_version_format_has_one_parser_implementation() -> None:
335456
definitions = []
336457
for source_root in (ROOT / "core", ROOT / "deploy"):

deploy/aws_gateway.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
ECR_REPOSITORY_URI,
4040
RESOURCE_ENVIRONMENT_TAG,
4141
)
42-
from deploy.smoke import run_http_smoke, verify_health
42+
from deploy.smoke import run_http_smoke, verify_health, verify_legacy_health
4343
from deploy.task_definitions import (
4444
TaskDefinitionConfig,
4545
assert_normalized_service_pair,
@@ -2495,12 +2495,15 @@ def verify_public_web(
24952495

24962496
while time.monotonic() < deadline:
24972497
try:
2498-
verify_health(
2499-
self.config.base_url,
2500-
identity.version,
2501-
identity.source_sha,
2502-
identity.image_digest,
2503-
)
2498+
if identity.identity_schema == 1:
2499+
verify_legacy_health(self.config.base_url, identity.source_sha)
2500+
else:
2501+
verify_health(
2502+
self.config.base_url,
2503+
identity.version,
2504+
identity.source_sha,
2505+
identity.image_digest,
2506+
)
25042507
self._require_not_after_deadline(deadline, context="public web health")
25052508
return
25062509
except Exception as error:

0 commit comments

Comments
 (0)