Skip to content

Commit c9db719

Browse files
Merge issue-219-engineer: Repair populated content release digest migration (#219)
2 parents e575e1a + 5320174 commit c9db719

9 files changed

Lines changed: 731 additions & 7 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# This replacement keeps the published 0004 migration immutable while giving databases that
2+
# have not recorded it a safe state transition. The physical constraint is repaired by 0006,
3+
# which also handles databases where the published 0004 constraint was already recorded.
4+
5+
import django.core.validators
6+
from django.db import migrations, models
7+
8+
LEGACY_PUBLIC_CONTRACT_DIGEST = "50f875806217865ef35b74f58ed885c4b5c832284391dbea7f84344d3416f66d"
9+
PUBLIC_CONTRACT_DIGEST = "31f505350566bfcde0a30109dadcfb3565042fd395b4c1bd151966f94d361332"
10+
SUPPORTED_PUBLIC_CONTRACT_DIGESTS = (
11+
PUBLIC_CONTRACT_DIGEST,
12+
LEGACY_PUBLIC_CONTRACT_DIGEST,
13+
)
14+
15+
16+
class Migration(migrations.Migration):
17+
replaces = [
18+
("content", "0004_remove_contentrelease_content_release_contract_sha_ck_and_more"),
19+
]
20+
21+
dependencies = [
22+
("content", "0003_content_document_structured_data"),
23+
]
24+
25+
operations = [
26+
migrations.SeparateDatabaseAndState(
27+
database_operations=[],
28+
state_operations=[
29+
migrations.AlterField(
30+
model_name="contentrelease",
31+
name="public_contracts_sha256",
32+
field=models.CharField(
33+
default=PUBLIC_CONTRACT_DIGEST,
34+
max_length=64,
35+
validators=[
36+
django.core.validators.RegexValidator(
37+
"^[0-9a-f]{64}$", "Enter a lowercase SHA-256 digest."
38+
)
39+
],
40+
),
41+
),
42+
migrations.RemoveConstraint(
43+
model_name="contentrelease",
44+
name="content_release_contract_sha_ck",
45+
),
46+
migrations.AddConstraint(
47+
model_name="contentrelease",
48+
constraint=models.CheckConstraint(
49+
condition=models.Q(
50+
("public_contracts_sha256__in", SUPPORTED_PUBLIC_CONTRACT_DIGESTS)
51+
),
52+
name="content_release_contract_sha_ck",
53+
),
54+
),
55+
],
56+
),
57+
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from django.db import migrations, models
2+
3+
LEGACY_PUBLIC_CONTRACT_DIGEST = "50f875806217865ef35b74f58ed885c4b5c832284391dbea7f84344d3416f66d"
4+
PUBLIC_CONTRACT_DIGEST = "31f505350566bfcde0a30109dadcfb3565042fd395b4c1bd151966f94d361332"
5+
SUPPORTED_PUBLIC_CONTRACT_DIGESTS = (
6+
PUBLIC_CONTRACT_DIGEST,
7+
LEGACY_PUBLIC_CONTRACT_DIGEST,
8+
)
9+
10+
11+
class Migration(migrations.Migration):
12+
dependencies = [
13+
("content", "0005_repair_content_release_contract_digest"),
14+
]
15+
16+
operations = [
17+
# The database may still have the old 0003 check (pre-repair path) or the published 0004
18+
# current-only check (already-recorded path). Both use this stable constraint name.
19+
migrations.RemoveConstraint(
20+
model_name="contentrelease",
21+
name="content_release_contract_sha_ck",
22+
),
23+
migrations.AddConstraint(
24+
model_name="contentrelease",
25+
constraint=models.CheckConstraint(
26+
condition=models.Q(
27+
("public_contracts_sha256__in", SUPPORTED_PUBLIC_CONTRACT_DIGESTS)
28+
),
29+
name="content_release_contract_sha_ck",
30+
),
31+
),
32+
]

content/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
SHA1_PATTERN = r"^[0-9a-f]{40}$"
2222
SHA256_PATTERN = r"^[0-9a-f]{64}$"
2323
PUBLIC_CONTRACT_DIGEST = "31f505350566bfcde0a30109dadcfb3565042fd395b4c1bd151966f94d361332"
24+
LEGACY_PUBLIC_CONTRACT_DIGEST = "50f875806217865ef35b74f58ed885c4b5c832284391dbea7f84344d3416f66d"
25+
SUPPORTED_PUBLIC_CONTRACT_DIGESTS = (
26+
PUBLIC_CONTRACT_DIGEST,
27+
LEGACY_PUBLIC_CONTRACT_DIGEST,
28+
)
2429
FROZEN_RELEASE_STATUSES = frozenset({"ready", "active", "superseded", "invalid", "failed"})
2530

2631
sha1_validator = RegexValidator(SHA1_PATTERN, "Enter a full lowercase Git SHA.")
@@ -274,7 +279,7 @@ class Meta:
274279
name="content_release_manifest_sha_ck",
275280
),
276281
models.CheckConstraint(
277-
condition=Q(public_contracts_sha256=PUBLIC_CONTRACT_DIGEST),
282+
condition=Q(public_contracts_sha256__in=SUPPORTED_PUBLIC_CONTRACT_DIGESTS),
278283
name="content_release_contract_sha_ck",
279284
),
280285
models.CheckConstraint(

content/services.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
from .inventory import content_route_contracts
2626
from .models import (
27+
LEGACY_PUBLIC_CONTRACT_DIGEST,
2728
PUBLIC_CONTRACT_DIGEST,
2829
ActiveContentPath,
2930
ContentAsset,
@@ -425,6 +426,10 @@ def create_content_release(
425426
_validate_version(command.rendering_version, field_name="rendering_version")
426427
if not _SHA256.fullmatch(command.public_contracts_sha256):
427428
raise ValueError("public_contracts_sha256 must be a lowercase SHA-256 digest")
429+
if command.public_contracts_sha256 != PUBLIC_CONTRACT_DIGEST:
430+
raise ContentReadinessError(
431+
"new content releases must use the checked public contract artifact"
432+
)
428433
provenance = _safe_json_object(command.request_provenance)
429434
with transaction.atomic(using=using):
430435
source = lock_revisioned(
@@ -864,9 +869,19 @@ def _readiness_counts(release: ContentRelease, *, using: str) -> tuple[int, int,
864869
return document_count, relation_count, asset_count
865870

866871

867-
def _validate_frozen_readiness(release: ContentRelease, *, using: str) -> None:
868-
if release.public_contracts_sha256 != PUBLIC_CONTRACT_DIGEST:
869-
raise ContentReadinessError("release is not bound to the checked public contract artifact")
872+
def _validate_frozen_readiness(
873+
release: ContentRelease,
874+
*,
875+
using: str,
876+
allow_legacy_contract: bool = False,
877+
) -> None:
878+
allowed_digests = (
879+
(PUBLIC_CONTRACT_DIGEST, LEGACY_PUBLIC_CONTRACT_DIGEST)
880+
if allow_legacy_contract
881+
else (PUBLIC_CONTRACT_DIGEST,)
882+
)
883+
if release.public_contracts_sha256 not in allowed_digests:
884+
raise ContentReadinessError("release is not bound to a supported public contract artifact")
870885
if not release.asset_manifest_checksum or not _SHA256.fullmatch(
871886
release.asset_manifest_checksum
872887
):
@@ -1366,7 +1381,7 @@ def _rollback_swap_state(
13661381
raise ContentLifecycleError("rollback target was not a previously active release")
13671382
if retained.activated_at is None or retained.superseded_at is None:
13681383
raise ContentLifecycleError("rollback target lacks retained activation evidence")
1369-
_validate_frozen_readiness(retained, using=using)
1384+
_validate_frozen_readiness(retained, using=using, allow_legacy_contract=True)
13701385
_validate_active_path_claims(source, current, using=using)
13711386
_validate_enabled_namespace(source, retained, using=using)
13721387
return source, current, retained

content/tests/test_services.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
from django.core.exceptions import ValidationError
88
from django.test import TestCase
99

10-
from content.models import ContentDocument, ContentRelease, expected_storage_prefix
10+
from content.models import (
11+
LEGACY_PUBLIC_CONTRACT_DIGEST,
12+
PUBLIC_CONTRACT_DIGEST,
13+
ContentDocument,
14+
ContentRelease,
15+
expected_storage_prefix,
16+
)
1117
from content.queries import (
1218
ResolvePublicAsset,
1319
ResolvePublicDocument,
@@ -64,6 +70,77 @@ def _create_queued(self, source, character: str = "d") -> ContentRelease:
6470
context=CONTEXT,
6571
)
6672

73+
def test_new_release_digest_is_current_and_legacy_release_can_be_rolled_back(self) -> None:
74+
source = make_source()
75+
source.refresh_from_db()
76+
with self.assertRaisesRegex(
77+
ContentReadinessError,
78+
"checked public contract artifact",
79+
):
80+
create_content_release(
81+
CreateContentRelease(
82+
source_id=source.id,
83+
expected_source_revision=source.revision,
84+
commit_sha="a" * 40,
85+
parser_version="parser-v1",
86+
rendering_version="renderer-v1",
87+
request_provenance={"mode": "legacy-digest-rejection"},
88+
public_contracts_sha256=LEGACY_PUBLIC_CONTRACT_DIGEST,
89+
),
90+
context=CONTEXT,
91+
)
92+
self.assertEqual(ContentRelease.objects.count(), 0)
93+
94+
first = activate(source, make_ready_release(source, commit_character="b"))
95+
self.assertEqual(first.public_contracts_sha256, PUBLIC_CONTRACT_DIGEST)
96+
second = make_ready_release(source, commit_character="c")
97+
first.refresh_from_db()
98+
source.refresh_from_db()
99+
ContentRelease.objects.filter(pk=second.id).update(
100+
public_contracts_sha256=LEGACY_PUBLIC_CONTRACT_DIGEST,
101+
)
102+
second.refresh_from_db()
103+
with self.assertRaises(ContentReadinessError):
104+
activate_content_release(
105+
ActivateContentRelease(
106+
source.id,
107+
second.id,
108+
source.revision,
109+
second.revision,
110+
"legacy digest cannot activate as a new candidate",
111+
),
112+
context=CONTEXT,
113+
)
114+
source.refresh_from_db()
115+
self.assertEqual(source.active_release_id, first.id)
116+
117+
ContentRelease.objects.filter(pk=second.id).update(
118+
public_contracts_sha256=PUBLIC_CONTRACT_DIGEST,
119+
)
120+
second.refresh_from_db()
121+
source.refresh_from_db()
122+
activate(source, second)
123+
124+
ContentRelease.objects.filter(pk=first.id).update(
125+
public_contracts_sha256=LEGACY_PUBLIC_CONTRACT_DIGEST,
126+
)
127+
first.refresh_from_db()
128+
source.refresh_from_db()
129+
rollback_content_release(
130+
RollbackContentRelease(
131+
source.id,
132+
first.id,
133+
source.revision,
134+
first.revision,
135+
"retain legacy release",
136+
),
137+
context=CONTEXT,
138+
)
139+
source.refresh_from_db()
140+
first.refresh_from_db()
141+
self.assertEqual(source.active_release_id, first.id)
142+
self.assertEqual(first.public_contracts_sha256, LEGACY_PUBLIC_CONTRACT_DIGEST)
143+
67144
def test_allowed_terminal_edges_and_omitted_transitions_fail_closed(self) -> None:
68145
source = make_source()
69146
queued = self._create_queued(source, "d")

content_sync/dtc_content/preparation.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77

88
from django.db import IntegrityError, transaction
99

10-
from content.models import ContentDocument, ContentRelease, ContentSource, expected_storage_prefix
10+
from content.models import (
11+
PUBLIC_CONTRACT_DIGEST,
12+
ContentDocument,
13+
ContentRelease,
14+
ContentSource,
15+
expected_storage_prefix,
16+
)
1117
from content.services import (
1218
CreateContentRelease,
1319
MarkReleaseReady,
@@ -101,6 +107,8 @@ def _existing_release(
101107
)
102108
if release is None:
103109
return None
110+
if release.public_contracts_sha256 != PUBLIC_CONTRACT_DIGEST:
111+
raise DtcContentValidationError("existing_release_contract_digest_mismatch")
104112
if release.status not in {
105113
ContentRelease.Status.READY,
106114
ContentRelease.Status.ACTIVE,

0 commit comments

Comments
 (0)